"""
Galaxy Bot – Telegram Card Collection Game
Improved UI version with emojis, badges, clear formatting
Environment variables: BOT_TOKEN, OWNER_ID, ADMIN_IDS (comma separated)
"""
import os
import sys
import time
import json
import random
import logging
from datetime import datetime, timedelta
from collections import defaultdict
from typing import Optional, Tuple, List
from cv2 import line
from dotenv import load_dotenv
import sqlite3
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto, InputMediaVideo, BotCommand, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
from telegram.constants import ParseMode
from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes

load_dotenv()
TOKEN = os.getenv('BOT_TOKEN')
# Support multiple owners via OWNER_IDS (comma-separated) or legacy OWNER_ID
OWNER_IDS_STR = os.getenv('OWNER_IDS', os.getenv('OWNER_ID', '0'))
OWNER_IDS = [int(x.strip()) for x in OWNER_IDS_STR.split(',') if x.strip()]
ADMIN_IDS_STR = os.getenv('ADMIN_IDS', '')
ADMIN_IDS = [int(x.strip()) for x in ADMIN_IDS_STR.split(',') if x.strip()]
MARKET_CHANNEL = os.getenv('MARKET_CHANNEL', '@GalaxyCatcherMarket')
DATABASE_CHANNEL = os.getenv('DATABASE_CHANNEL', '@GalaxyCatcherDatabase')
WALLET_DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'galaxy_point.db')
if not TOKEN:
    print('Error: BOT_TOKEN not set')
    sys.exit(1)
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
FONT_MAP = {'A': '𝗔', 'B': '𝗕', 'C': '𝗖', 'D': '𝗗', 'E': ' 𝗘', 'F': '𝗙', '𝖦': '𝗚', 'H': '𝗛', 'I': '𝗜', 'J': '𝗝', 'K': '𝗞', 'L': '𝗟', 'M': '𝗠', 'N': '𝗡', 'O': '𝗢', 'P': '𝗣', 'Q': '𝗤', 'R': '𝗥', 'S': '𝗦', 'T': '𝗧', 'U': '𝗨', 'V': '𝗩', 'W': '𝗪', '𝗫': '𝗫', 'Y': '𝗬', 'Z': '𝗭', 'a': '𝗮', 'b': '𝗯', 'c': '𝗰', 'd': '𝗱', 'e': '𝗲', 'f': '𝗳', 'g': '𝗴', 'h': '𝗵', 'i': '𝗶', 'j': '𝗷', 'k': '𝗸', 'l': '𝗹', 'm': '𝗺', 'n': '𝗻', 'o': '𝗼', 'p': '𝗽', 'q': '𝗾', 'r': '𝗿', 's': '𝘀', 't': '𝘁', 'u': '𝘂', 'v': '𝘃', 'w': '𝘄', 'x': '𝘅', 'y': '𝘆', 'z': '𝘇'}

def stylize_text(text):
    if not isinstance(text, str):
        return text
    return ''.join((FONT_MAP.get(ch, ch) for ch in text))
DB_PATH = 'galaxy_bot.db'
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
cursor = conn.cursor()
cursor.executescript("\n    CREATE TABLE IF NOT EXISTS users (\n        user_id INTEGER PRIMARY KEY,\n        username TEXT,\n        first_name TEXT,\n        card_count INTEGER DEFAULT 0,\n        fav_card_id INTEGER DEFAULT NULL,\n        is_banned INTEGER DEFAULT 0,\n        ban_until REAL DEFAULT NULL,\n        is_uploader INTEGER DEFAULT 0\n    );\n\n    CREATE TABLE IF NOT EXISTS cards (\n        card_id INTEGER PRIMARY KEY AUTOINCREMENT,\n        character_name TEXT NOT NULL,\n        anime_name TEXT NOT NULL,\n        rank TEXT NOT NULL CHECK(rank IN ('common','uncommon','legendary','mythic','galaxy')),\n        file_id TEXT NOT NULL,\n        file_type TEXT NOT NULL CHECK(file_type IN ('photo','video')),\n        uploader_id INTEGER NOT NULL,\n        uploaded_at REAL NOT NULL,\n        total_caught INTEGER DEFAULT 0,\n        FOREIGN KEY (uploader_id) REFERENCES users(user_id)\n    );\n\n    CREATE TABLE IF NOT EXISTS user_cards (\n        user_id INTEGER NOT NULL,\n        card_id INTEGER NOT NULL,\n        quantity INTEGER DEFAULT 1,\n        caught_at REAL NOT NULL,\n        PRIMARY KEY (user_id, card_id),\n        FOREIGN KEY (user_id) REFERENCES users(user_id),\n        FOREIGN KEY (card_id) REFERENCES cards(card_id)\n    );\n\n    CREATE TABLE IF NOT EXISTS spawns (\n        chat_id INTEGER NOT NULL,\n        card_id INTEGER NOT NULL,\n        spawned_at REAL NOT NULL,\n        caught_by INTEGER DEFAULT NULL,\n        active INTEGER DEFAULT 1,\n        PRIMARY KEY (chat_id, card_id)\n    );\n\n    CREATE TABLE IF NOT EXISTS trades (\n        trade_id INTEGER PRIMARY KEY AUTOINCREMENT,\n        sender_id INTEGER NOT NULL,\n        receiver_id INTEGER NOT NULL,\n        offer_card_id INTEGER NOT NULL,\n        want_card_id INTEGER NOT NULL,\n        status TEXT DEFAULT 'pending'\n    );\n    \n    CREATE TABLE IF NOT EXISTS gift_requests (\n        gift_id INTEGER PRIMARY KEY AUTOINCREMENT,\n        sender_id INTEGER NOT NULL,\n        receiver_id INTEGER NOT NULL,\n        card_id INTEGER NOT NULL,\n        request_type TEXT NOT NULL CHECK(request_type IN ('gift','giftc')),\n        status TEXT NOT NULL CHECK(status IN ('pending','accepted','declined')),\n        created_at REAL NOT NULL,\n        FOREIGN KEY (sender_id) REFERENCES users(user_id),\n        FOREIGN KEY (receiver_id) REFERENCES users(user_id),\n        FOREIGN KEY (card_id) REFERENCES cards(card_id)\n    );\n\n    CREATE TABLE IF NOT EXISTS user_filters (\n        user_id INTEGER PRIMARY KEY,\n        filter_type TEXT NOT NULL DEFAULT 'old' CHECK(filter_type IN ('old','rarity')),\n        filter_value TEXT DEFAULT NULL,\n        FOREIGN KEY (user_id) REFERENCES users(user_id)\n    );\n")
conn.commit()
# Ensure legacy databases get the is_admin column
cursor.execute("PRAGMA table_info(users)")
columns = [row[1] for row in cursor.fetchall()]
if 'is_admin' not in columns:
    cursor.execute('ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0')
    conn.commit()
RANK_UI = {'common': '🟢 Common', 'uncommon': '🔵 Uncommon', 'legendary': '🟣 Legendary', 'mythic': '🔴 Mythic', 'galaxy': '🌟 Galaxy'}
RANK_EMOJI = {'common': '🟢', 'uncommon': '🔵', 'legendary': '🟣', 'mythic': '🔴', 'galaxy': '🌟'}
RANK_WEIGHTS = {'common': 80, 'uncommon': 40, 'legendary': 14, 'mythic': 4, 'galaxy': 1}
RANKS_ORDERED = ['common', 'uncommon', 'legendary', 'mythic', 'galaxy']
chat_message_count = defaultdict(int)
spawn_cooldown = defaultdict(float)
spawn_rate_settings = defaultdict(lambda: 100)  # Custom spawn rate per group (default 100)
rank_spawn_settings = defaultdict(lambda: {rank: True for rank in RANKS_ORDERED})  # Rank spawn open/closed per group
# Per-chat per-user recent message timestamps (for anti-spam)
# Store per-chat per-user recent messages as list of (timestamp, message_id)
user_message_times: dict = defaultdict(lambda: defaultdict(list))
# Tracks which group chats have been initialized for anti-spam since the bot was started
chat_spam_initialized = set()
BOT_START_TIME = time.time()

# Anti-spam configuration
SPAM_WINDOW_SECONDS = 8
SPAM_THRESHOLD = 8
SPAM_TIMEOUT_MINUTES = 5


def initialize_spam_for_chat(chat_id: int):
    if chat_id in chat_spam_initialized:
        return
    chat_message_count[chat_id] = 0
    user_message_times.pop(chat_id, None)
    spawn_cooldown.pop(chat_id, None)
    chat_spam_initialized.add(chat_id)
    logger.info('Spam initialization: ignoring prior messages in chat %s and starting fresh', chat_id)


def is_prebot_message(chat_id: int, message_date: datetime) -> bool:
    if chat_id in chat_spam_initialized:
        return False
    if message_date.timestamp() < BOT_START_TIME:
        logger.info('Ignoring backlog message in chat %s dated before bot start: %s', chat_id, message_date.isoformat())
        return True
    initialize_spam_for_chat(chat_id)
    return False

def get_user(user_id: int) -> Optional[tuple]:
    cursor.execute('SELECT * FROM users WHERE user_id = ?', (user_id,))
    return cursor.fetchone()

def create_user(user_id: int, username: str='', first_name: str=''):
    cursor.execute('INSERT OR IGNORE INTO users (user_id, username, first_name) VALUES (?, ?, ?)', (user_id, username or '', first_name or ''))
    conn.commit()

def is_banned(user_id: int) -> bool:
    row = cursor.execute('SELECT is_banned, ban_until FROM users WHERE user_id = ?', (user_id,)).fetchone()
    if row is None:
        return False
    is_banned, ban_until = row
    if is_banned:
        if ban_until is not None and time.time() > ban_until:
            cursor.execute('UPDATE users SET is_banned = 0, ban_until = NULL WHERE user_id = ?', (user_id,))
            conn.commit()
            return False
        return True
    return False

def ban_notice_text(user_id: int) -> str:
    row = cursor.execute('SELECT is_banned, ban_until FROM users WHERE user_id = ?', (user_id,)).fetchone()
    if not row or not row[0]:
        return '⏳ You are banned.'
    _, ban_until = row
    if ban_until is None:
        return '⛔ You are permanently banned from using this bot.'
    return f'⏳ You are timed out until {datetime.fromtimestamp(ban_until).strftime("%Y-%m-%d %H:%M")}.\nPlease wait until your timeout expires.'

def is_owner(user_id: int) -> bool:
    try:
        return int(user_id) in OWNER_IDS
    except Exception:
        return False

def is_admin(user_id: int) -> bool:
    if user_id in ADMIN_IDS:
        return True
    row = cursor.execute('SELECT is_admin FROM users WHERE user_id = ?', (user_id,)).fetchone()
    return row is not None and row[0] == 1

def is_uploader(user_id: int) -> bool:
    row = cursor.execute('SELECT is_uploader FROM users WHERE user_id = ?', (user_id,)).fetchone()
    return row is not None and row[0] == 1

def get_card(card_id: int) -> Optional[tuple]:
    cursor.execute('SELECT * FROM cards WHERE card_id = ?', (card_id,))
    return cursor.fetchone()

def get_user_card(user_id: int, card_id: int) -> Optional[tuple]:
    cursor.execute('SELECT * FROM user_cards WHERE user_id = ? AND card_id = ?', (user_id, card_id))
    return cursor.fetchone()

def find_user_by_username(username: str) -> Optional[tuple]:
    username = username.lstrip('@')
    cursor.execute('SELECT * FROM users WHERE LOWER(username) = LOWER(?)', (username,))
    return cursor.fetchone()

def random_card_by_rank(chat_id: int | None = None) -> Optional[int]:
    cursor.execute('SELECT card_id, rank FROM cards')
    rows = cursor.fetchall()
    if not rows:
        return None
    rank_pool = defaultdict(list)
    for cid, rank in rows:
        rank_pool[rank].append(cid)
    available_ranks = list(rank_pool.keys())
    if chat_id is not None:
        enabled_ranks = [rank for rank, enabled in rank_spawn_settings[chat_id].items() if enabled and rank in rank_pool]
    else:
        enabled_ranks = available_ranks
    if not enabled_ranks:
        return None
    eligible_weights = [RANK_WEIGHTS[rank] for rank in enabled_ranks]
    chosen_rank = random.choices(enabled_ranks, weights=eligible_weights, k=1)[0]
    pool = rank_pool.get(chosen_rank, [])
    if not pool:
        all_ids = [r[0] for r in rows]
        return random.choice(all_ids)
    return random.choice(pool)

def spawn_card(chat_id: int) -> Optional[int]:
    now = time.time()
    if now - spawn_cooldown.get(chat_id, 0) < 30:
        return None
    card_id = random_card_by_rank(chat_id)
    if card_id is None:
        return None
    card = get_card(card_id)
    if not card:
        return None
    cursor.execute('INSERT OR REPLACE INTO spawns (chat_id, card_id, spawned_at, active) VALUES (?, ?, ?, 1)', (chat_id, card_id, now))
    conn.commit()
    spawn_cooldown[chat_id] = now
    return card_id

def format_rank(rank: str) -> str:
    """Return rank with emoji and capital letter."""
    return RANK_UI.get(rank, rank.capitalize())

def get_anime_totals() -> dict[str, int]:
    cursor.execute('SELECT anime_name, COUNT(*) FROM cards GROUP BY anime_name')
    return dict(cursor.fetchall())


# Wallet DB helpers (talk directly to galaxypoint DB)
wallet_conn = None
wallet_cursor = None
try:
    wallet_conn = sqlite3.connect(WALLET_DB_PATH, check_same_thread=False)
    wallet_cursor = wallet_conn.cursor()
except Exception:
    wallet_conn = None
    wallet_cursor = None


def wallet_get_points(user_id: int) -> int:
    if wallet_cursor is None:
        return 0
    wallet_cursor.execute('SELECT points FROM users WHERE user_id = ?', (user_id,))
    row = wallet_cursor.fetchone()
    return row[0] if row else 0


def wallet_transfer(buyer_id: int, seller_id: int, amount: int) -> bool:
    if wallet_conn is None:
        return False
    # Use a transaction
    try:
        wallet_cursor.execute('BEGIN')
        wallet_cursor.execute('SELECT points FROM users WHERE user_id = ?', (buyer_id,))
        brow = wallet_cursor.fetchone()
        buyer_points = brow[0] if brow else 0
        if buyer_points < amount:
            wallet_cursor.execute('ROLLBACK')
            return False
        wallet_cursor.execute('UPDATE users SET points = points - ? WHERE user_id = ?', (amount, buyer_id))
        wallet_cursor.execute('INSERT OR IGNORE INTO users (user_id, username, first_name, points) VALUES (?, ?, ?, 0)', (seller_id, '', ''))
        wallet_cursor.execute('UPDATE users SET points = points + ? WHERE user_id = ?', (amount, seller_id))
        wallet_conn.commit()
        return True
    except Exception:
        try:
            wallet_cursor.execute('ROLLBACK')
        except:
            pass
        return False


def wallet_ban_user(user_id: int, until: float = None):
    if wallet_cursor is None:
        return
    wallet_cursor.execute('INSERT OR REPLACE INTO banned_users (user_id, ban_until) VALUES (?, ?)', (user_id, until))
    wallet_conn.commit()


def wallet_unban_user(user_id: int):
    if wallet_cursor is None:
        return
    wallet_cursor.execute('DELETE FROM banned_users WHERE user_id = ?', (user_id,))
    wallet_conn.commit()


# Market table
cursor.execute("""
CREATE TABLE IF NOT EXISTS market_sales (
    sale_id INTEGER PRIMARY KEY AUTOINCREMENT,
    seller_id INTEGER NOT NULL,
    card_id INTEGER NOT NULL,
    price INTEGER NOT NULL,
    channel_id TEXT,
    channel_message_id INTEGER,
    active INTEGER DEFAULT 1,
    created_at REAL NOT NULL
)
""")

# Known chat tracking for group-wide commands like /grankset
cursor.execute("""
CREATE TABLE IF NOT EXISTS known_chats (
    chat_id INTEGER PRIMARY KEY,
    chat_type TEXT
)
""")
conn.commit()


def record_chat(chat_id: int, chat_type: str):
    try:
        cursor.execute('INSERT OR IGNORE INTO known_chats (chat_id, chat_type) VALUES (?, ?)', (chat_id, chat_type))
        conn.commit()
    except Exception:
        pass


def get_harem_rows(user_id: int):
    user_filter = get_user_filter(user_id)
    filter_type, filter_value = ('old', None)
    if user_filter:
        filter_type, filter_value = user_filter
    query = (
        'SELECT u.card_id, c.character_name, c.anime_name, c.rank, u.quantity, u.caught_at '
        'FROM user_cards u JOIN cards c ON u.card_id = c.card_id '
        'WHERE u.user_id = ?'
    )
    params = [user_id]
    if filter_type == 'rarity' and filter_value:
        query += ' AND c.rank = ?'
        params.append(filter_value)
    if filter_type == 'old':
        query += ' ORDER BY c.anime_name ASC, u.caught_at ASC'
    else:
        query += ' ORDER BY c.anime_name ASC, c.character_name ASC'
    cursor.execute(query, tuple(params))
    rows = cursor.fetchall()
    return rows, filter_type, filter_value


def build_harem_text(first_name: str, all_rows: list[tuple], page_rows: list[tuple], filter_type: str, filter_value: str, anime_totals: dict[str, int]) -> str:
    total_quantity = sum(row[4] for row in all_rows)
    unique_count = len(all_rows)
    if filter_type == 'rarity' and filter_value:
        filter_display = f'Filter: Rarity – {RANK_EMOJI.get(filter_value, "")} {filter_value.capitalize()}'
    else:
        filter_display = 'Sort: Anime Collection'
    safe_first_name = first_name.replace('_', '\\_') if first_name else 'User'
    header = (
        f'📚 **{safe_first_name}\'s Harem** (Total: {total_quantity} | Unique: {unique_count})\n'
        f'• {filter_display}\n\n'
    )
    grouped = defaultdict(list)
    for cid, name, anime, rank, qty, _ in page_rows:
        grouped[anime].append((cid, name, rank, qty))
    if not grouped:
        return header
    sections = []
    for anime in sorted(grouped, key=str.casefold):
        cards = grouped[anime]
        owned_unique = len(cards)
        total_anime = anime_totals.get(anime, owned_unique)
        sections.append('━━━━━━━━━━━━━━━')
        sections.append(f'➩ {anime} ({owned_unique}/{total_anime})')
        for cid, name, rank, qty in cards:
            rank_emoji = RANK_EMOJI.get(rank, '')
            safe_name = (name or '').replace('_', '\\_')
            duplicates = f' (x{qty})' if qty and qty > 1 else ''
            sections.append(f'› {rank_emoji} [{cid}] | {safe_name}{duplicates}')
    return header + '\n'.join(sections)


def build_card_text(card_id: int, name: str, anime: str, rank: str, owner_text: str='', total_caught: int=0) -> str:
    return f'╔══════════════════════════╗\n        🆔 Card #{card_id}\n╠══════════════════════════╣\n👤 Name   : {name}\n🎬 Anime  : {anime}\n⭐ Rank   : {format_rank(rank)}\n📊 Caught : {total_caught} times\n{owner_text}╚══════════════════════════╝'

def sync_user_card_count(user_id: int):
    """Sync a single user's card_count based on their actual cards."""
    cursor.execute('SELECT COALESCE(COUNT(DISTINCT card_id), 0) FROM user_cards WHERE user_id = ?', (user_id,))
    total = cursor.fetchone()[0]
    cursor.execute('UPDATE users SET card_count = ? WHERE user_id = ?', (total, user_id))
    conn.commit()

def refresh_favorite_card(user_id: int):
    """Check if user's favorite card still exists. If not, pick a random one or set to NULL."""
    cursor.execute('SELECT fav_card_id FROM users WHERE user_id = ?', (user_id,))
    user = cursor.fetchone()
    if not user or user[0] is None:
        return
    fav_card_id = user[0]
    cursor.execute('SELECT quantity FROM user_cards WHERE user_id = ? AND card_id = ?', (user_id, fav_card_id))
    owns = cursor.fetchone()
    if owns and owns[0] > 0:
        return
    cursor.execute('SELECT card_id FROM user_cards WHERE user_id = ? AND quantity > 0 ORDER BY RANDOM() LIMIT 1', (user_id,))
    random_card = cursor.fetchone()
    if random_card:
        new_fav_id = random_card[0]
    else:
        new_fav_id = None
    cursor.execute('UPDATE users SET fav_card_id = ? WHERE user_id = ?', (new_fav_id, user_id))
    conn.commit()

def get_next_card_id() -> int:
    """Return the smallest missing positive card_id."""
    cursor.execute('SELECT card_id FROM cards ORDER BY card_id')
    rows = cursor.fetchall()
    next_id = 1
    for card_id, in rows:
        if card_id == next_id:
            next_id += 1
        elif card_id > next_id:
            break
    return next_id

def recalculate_card_counts():
    """Recalculate card_count for all users based on user_cards table."""
    cursor.execute('SELECT DISTINCT user_id FROM users')
    users = cursor.fetchall()
    for user_id, in users:
        cursor.execute('SELECT COALESCE(COUNT(DISTINCT card_id), 0) FROM user_cards WHERE user_id = ?', (user_id,))
        total = cursor.fetchone()[0]
        cursor.execute('UPDATE users SET card_count = ? WHERE user_id = ?', (total, user_id))
    conn.commit()

def create_gift_request(sender_id: int, receiver_id: int, card_id: int, request_type: str) -> int:
    now = time.time()
    cursor.execute("INSERT INTO gift_requests (sender_id, receiver_id, card_id, request_type, status, created_at) VALUES (?, ?, ?, ?, 'pending', ?)", (sender_id, receiver_id, card_id, request_type, now))
    conn.commit()
    return cursor.lastrowid

def get_gift_request(gift_id: int) -> Optional[tuple]:
    cursor.execute('SELECT gift_id, sender_id, receiver_id, card_id, request_type, status FROM gift_requests WHERE gift_id = ?', (gift_id,))
    return cursor.fetchone()

def set_gift_request_status(gift_id: int, status: str):
    cursor.execute('UPDATE gift_requests SET status = ? WHERE gift_id = ?', (status, gift_id))
    conn.commit()

def get_user_filter(user_id: int) -> Optional[tuple]:
    cursor.execute('SELECT filter_type, filter_value FROM user_filters WHERE user_id = ?', (user_id,))
    return cursor.fetchone()

def set_user_filter(user_id: int, filter_type: str, filter_value: str=None):
    cursor.execute('INSERT INTO user_filters (user_id, filter_type, filter_value) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET filter_type = ?, filter_value = ?', (user_id, filter_type, filter_value, filter_type, filter_value))
    conn.commit()

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    create_user(user.id, user.username or '', user.first_name or '')
    welcome = '🌌 *Welcome to Galaxy Bot!* 🌌\n\nI am a bot that spawns random cards in this group after *100 messages*.\nUse `/catch [name]` to catch a card that appears.\nBuild your harem, trade with friends, and become the top collector!\n\nUse /help to see all commands.\n─────────────────\n⚡ *Tip:* Uploaders can add new cards!\n👑 Reach out to the group owner to become an uploader.'
    # Inline buttons: Database, Market, Announcements, Main Group
    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton('🗂 Database', url='https://t.me/GalaxyCatcherDatabase'), InlineKeyboardButton('🛒 Market', url='https://t.me/GalaxyCatcherMarket')],
        [InlineKeyboardButton('📣 Announcements', url='https://t.me/GalaxyCatcherAnnounce'), InlineKeyboardButton('👥 Main Group', url='https://t.me/ThisGalaxyGp')]
    ])
    await update.message.reply_text(stylize_text(welcome), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    text = '🌟 *Galaxy Bot – Command List* 🌟\n\n*🎯 Catch & Collection*\n`/catch [name]` – Catch a spawned card\n`/harem` – View your card collection\n`/favorite [id]` – Set a favourite card\n`/card [id]` – View card details\n`/search [name]` – Search cards by name or anime\n\n*🤝 Trading & Gifting*\n`/trade [my_id] [target_id]` – Trade cards (reply to user)\n`/gift [id]` – Gift a card (reply to user)\n\n*📊 Stats & Ranking*\n`/profile` – Your personal stats\n`/leaderboard` – Top collectors\n\n*👑 Roles & Admins*\n`Owner:` Full control over the bot (all commands). Owner-only: `/setuploader`, `/ban`, `/fixcardcount`, `/deletecard`, `/giftc`.\n`Admins:` /timeout, /upload, /unban, /untime, /editcard, /del\n`Uploaders:` /upload, /editcard\n\n─────────────────\n⚡ *Galaxy Bot v2.0* ⚡'
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)

async def catch(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    chat_id = update.effective_chat.id
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if not context.args:
        await update.message.reply_text(stylize_text('📖 Use: `/catch [character name]`'), parse_mode=ParseMode.MARKDOWN)
        return
    guess = ' '.join(context.args).strip().lower()
    cursor.execute('SELECT card_id, spawned_at FROM spawns WHERE chat_id = ? AND active = 1 AND caught_by IS NULL', (chat_id,))
    row = cursor.fetchone()
    if not row:
        await update.message.reply_text(stylize_text('❌ No active card to catch right now. Wait for the next spawn!'))
        return
    card_id, spawned_at = row
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('⚠️ Card data error. Try again.'))
        return
    card_name, anime, rank, file_id, file_type = (card[1], card[2], card[3], card[4], card[5])
    if guess == card_name.lower():
        cursor.execute('UPDATE spawns SET caught_by = ?, active = 0 WHERE chat_id = ? AND card_id = ?', (user.id, chat_id, card_id))
        now = time.time()
        cursor.execute('INSERT INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = quantity + 1', (user.id, card_id, now))
        cursor.execute('UPDATE cards SET total_caught = total_caught + 1 WHERE card_id = ?', (card_id,))
        sync_user_card_count(user.id)
        user_name = user.username or user.first_name
        safe_user_name = user_name.replace('_', '\\_')
        await update.message.reply_text(stylize_text(f'🎉 **Congratulations @{safe_user_name}!** 🎉\n\nYou caught a **{format_rank(rank)}** card!\n👤 **Name:** {card_name}\n🎬 **Anime:** {anime}\n🆔 **ID:** #{card_id}\n\nUse /harem to see your collection.\n─────────────────\n🌟 *Galaxy Bot*'), parse_mode=ParseMode.MARKDOWN)
    else:
        await update.message.reply_text(stylize_text('❌ Wrong name! Try again using /catch [correct name].'))

async def harem(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    rows, filter_type, filter_value = get_harem_rows(user.id)
    if not rows:
        await update.message.reply_text(stylize_text('📭 You have no cards yet. Catch some using /catch when a card appears!'))
        return
    try:
        page = int(context.args[0]) if context.args else 1
    except (IndexError, ValueError):
        page = 1
    page = max(1, page)
    per_page = 10
    total_pages = (len(rows) + per_page - 1) // per_page
    if page > total_pages:
        page = total_pages
    start_idx = (page - 1) * per_page
    end_idx = start_idx + per_page
    page_rows = rows[start_idx:end_idx]
    anime_totals = get_anime_totals()
    text = build_harem_text(user.first_name, rows, page_rows, filter_type, filter_value, anime_totals)
    buttons = []
    nav_row = [InlineKeyboardButton('◀️', callback_data=f'harem_prev:{page}:{user.id}'), InlineKeyboardButton(f'{page}/{total_pages}', callback_data=f'harem_page:{page}:{user.id}'), InlineKeyboardButton('▶️', callback_data=f'harem_next:{page}:{user.id}')]
    buttons.append(nav_row)
    buttons.append([InlineKeyboardButton('🔍 Filter', callback_data=f'harem_filter:{user.id}'), InlineKeyboardButton('❌ Close', callback_data='harem_close')])
    keyboard = InlineKeyboardMarkup(buttons)
    user_row = get_user(user.id)
    fav_id = user_row[4] if user_row else None
    fav_text = ''
    fav_media_sent = False
    if fav_id:
        fav_card = get_card(fav_id)
        if fav_card:
            fav_name = fav_card[1]
            fav_anime = fav_card[2]
            fav_rank = fav_card[3]
            fav_emoji = RANK_EMOJI.get(fav_rank, '')
            fav_text = f'⭐ Favourite: `#{fav_id}` {fav_name} – {fav_anime} [{fav_emoji}]\n\n'
            file_id = fav_card[4]
            file_type = fav_card[5]
            caption = fav_text + text
            if file_type == 'photo':
                await update.message.reply_photo(photo=file_id, caption=caption, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
                fav_media_sent = True
            else:
                await update.message.reply_video(video=file_id, caption=caption, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
                fav_media_sent = True
    if not fav_media_sent:
        await update.message.reply_text(stylize_text(fav_text + text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def filter_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Allow user to set harem filter preferences: rarity or old/new sorting."""
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('🌟 Rarity', callback_data=f'filter_rarity:{user.id}')], [InlineKeyboardButton('📅 Old', callback_data=f'filter_old:{user.id}')]])
    await update.message.reply_text(stylize_text('🔍 **Choose filter type for your harem:**\n\n**🌟 Rarity**: Filter cards by rank (common, uncommon, legendary, mythic, galaxy)\n**📅 Old**: Sort from oldest to newest caught card'), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def filter_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle filter selection: rarity submenu or old sorting."""
    query = update.callback_query
    user = query.from_user
    data = query.data
    if not data.endswith(f':{user.id}'):
        await query.answer(stylize_text('This menu is not yours!'), show_alert=True)
        return
    has_media = query.message.photo or query.message.video
    if data.startswith('filter_rarity:'):
        ranks = ['common', 'uncommon', 'legendary', 'mythic', 'galaxy']
        buttons = []
        for rank in ranks:
            rank_emoji = RANK_EMOJI.get(rank, '')
            buttons.append([InlineKeyboardButton(f'{rank_emoji} {rank.capitalize()}', callback_data=f'filter_rank:{rank}:{user.id}')])
        keyboard = InlineKeyboardMarkup(buttons)
        text = '🌟 **Select rank to filter:**'
        await query.answer(stylize_text('Select a rank to filter your harem'), show_alert=False)
        if has_media:
            await query.edit_message_caption(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
        else:
            await query.edit_message_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
    elif data.startswith('filter_old:'):
        set_user_filter(user.id, 'old', None)
        text = '✅ Filter set to **📅 Old** (oldest to newest)\n\nUse /harem to view your filtered collection.'
        await query.answer(stylize_text('Filter set: Old (oldest first)'), show_alert=True)
        if has_media:
            await query.edit_message_caption(stylize_text(text), parse_mode=ParseMode.MARKDOWN)
        else:
            await query.edit_message_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)
    elif data.startswith('filter_rank:'):
        parts = data.split(':')
        rank = parts[1]
        user_id = parts[2]
        if str(user_id) != str(user.id):
            await query.answer(stylize_text('This menu is not yours!'), show_alert=True)
            return
        set_user_filter(user.id, 'rarity', rank)
        rank_emoji = RANK_EMOJI.get(rank, '')
        text = f'✅ Filter set to **{rank_emoji} {rank.capitalize()}**\n\nUse /harem to view your filtered collection.'
        await query.answer(stylize_text(f'Filter set: {rank.capitalize()}'), show_alert=True)
        if has_media:
            await query.edit_message_caption(stylize_text(text), parse_mode=ParseMode.MARKDOWN)
        else:
            await query.edit_message_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)

async def harem_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    if data.startswith('harem_filter:'):
        user_id = int(data.split(':')[1])
        if query.from_user.id != user_id:
            await query.answer(stylize_text('This menu is not yours!'), show_alert=True)
            return
        keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('🌟 Rarity', callback_data=f'filter_rarity:{user_id}')], [InlineKeyboardButton('📅 Old', callback_data=f'filter_old:{user_id}')]])
        await query.answer(stylize_text('Opening filter menu...'), show_alert=False)
        await query.edit_message_caption(stylize_text('🔍 **Choose filter type for your harem:**\n\n**🌟 Rarity**: Filter cards by rank\n**📅 Old**: Sort from oldest to newest'), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
        return
    if data == 'harem_close':
        try:
            await query.message.delete()
        except Exception:
            if query.message.photo or query.message.video:
                await query.edit_message_caption(stylize_text('❌ Closed.'))
            else:
                await query.edit_message_text(stylize_text('❌ Closed.'))
        return
    parts = data.split(':')
    action = parts[0]
    orig_page = int(parts[1])
    page = orig_page
    user_id = int(parts[2])
    user = query.from_user
    if user.id != user_id:
        await query.answer(stylize_text('This menu is not yours.'), show_alert=True)
        return
    rows, filter_type, filter_value = get_harem_rows(user_id)
    if not rows:
        await query.edit_message_text(stylize_text('No cards.'))
        return
    per_page = 10
    total_pages = (len(rows) + per_page - 1) // per_page
    if action == 'harem_prev':
        page -= 1
    elif action == 'harem_next':
        page += 1
    elif action == 'harem_page':
        await query.answer(stylize_text(f'Page {page}/{total_pages}'), show_alert=False)
        return
    if page < 1:
        page = 1
    if page > total_pages:
        page = total_pages
    if page == orig_page:
        if action == 'harem_prev':
            await query.answer(stylize_text("You're already on the first page."), show_alert=True)
        elif action == 'harem_next':
            await query.answer(stylize_text("You're already on the last page."), show_alert=True)
        else:
            await query.answer(stylize_text('No changes.'), show_alert=False)
        return
    start_idx = (page - 1) * per_page
    end_idx = start_idx + per_page
    page_rows = rows[start_idx:end_idx]
    anime_totals = get_anime_totals()
    text = build_harem_text(user.first_name, rows, page_rows, filter_type, filter_value, anime_totals)
    buttons = []
    nav_row = [InlineKeyboardButton('◀️', callback_data=f'harem_prev:{page}:{user_id}'), InlineKeyboardButton(f'{page}/{total_pages}', callback_data=f'harem_page:{page}:{user_id}'), InlineKeyboardButton('▶️', callback_data=f'harem_next:{page}:{user_id}')]
    buttons.append(nav_row)
    buttons.append([InlineKeyboardButton('🔍 Filter', callback_data=f'harem_filter:{user_id}'), InlineKeyboardButton('❌ Close', callback_data='harem_close')])
    keyboard = InlineKeyboardMarkup(buttons)
    try:
        await query.answer(stylize_text(f'Page {page}/{total_pages}'), show_alert=False)
        if query.message.photo or query.message.video:
            await query.edit_message_caption(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
        else:
            await query.edit_message_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
    except Exception as e:
        err = str(e)
        if 'Message is not modified' in err or 'specified new message content and reply markup are exactly the same' in err:
            await query.answer(stylize_text('No changes.'), show_alert=False)
            return
        raise

async def trade(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if len(context.args) != 2:
        await update.message.reply_text(stylize_text('📖 Use: `/trade [your_card_id] [target_card_id]` (reply to the user you want to trade with)'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        offer_id = int(context.args[0])
        want_id = int(context.args[1])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card IDs must be numbers.'))
        return
    offer_row = get_user_card(user.id, offer_id)
    if not offer_row or offer_row[2] <= 0:
        await update.message.reply_text(stylize_text("❌ You don't own the offered card."))
        return
    if not update.message.reply_to_message:
        await update.message.reply_text(stylize_text('❌ Please reply to the user you want to trade with.'))
        return
    receiver = update.message.reply_to_message.from_user
    if receiver.id == user.id:
        await update.message.reply_text(stylize_text('❌ You cannot trade with yourself.'))
        return
    create_user(receiver.id, receiver.username or '', receiver.first_name or '')
    want_row = get_user_card(receiver.id, want_id)
    if not want_row or want_row[2] <= 0:
        await update.message.reply_text(stylize_text('❌ The target user does not own the wanted card.'))
        return
    cursor.execute("INSERT INTO trades (sender_id, receiver_id, offer_card_id, want_card_id, status) VALUES (?, ?, ?, ?, 'pending')", (user.id, receiver.id, offer_id, want_id))
    conn.commit()
    trade_id = cursor.lastrowid
    offer_card = get_card(offer_id)
    want_card = get_card(want_id)
    offer_name = offer_card[1] if offer_card else 'Unknown'
    want_name = want_card[1] if want_card else 'Unknown'
    user_name = user.username or user.first_name
    safe_user_name = user_name.replace('_', '\\_')
    text = f'🤝 *Trade Offer from @{safe_user_name}*\n\n📤 **Offering:** #{offer_id} – {offer_name}\n📥 **Wanting:** #{want_id} – {want_name}\n\nWould you like to accept?'
    keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('✅ Accept Trade', callback_data=f'trade_accept:{trade_id}')], [InlineKeyboardButton('❌ Decline', callback_data=f'trade_cancel:{trade_id}')]])
    try:
        await context.bot.send_message(chat_id=update.effective_chat.id, text=text, parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard, reply_to_message_id=update.message.message_id)
    except Exception as e:
        await update.message.reply_text(stylize_text('❌ Could not send trade request in this chat.'))
        cursor.execute('DELETE FROM trades WHERE trade_id = ?', (trade_id,))
        conn.commit()

async def trade_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    user = query.from_user
    if data.startswith('trade_accept:'):
        trade_id = int(data.split(':')[1])
        cursor.execute("SELECT * FROM trades WHERE trade_id = ? AND status='pending'", (trade_id,))
        trade = cursor.fetchone()
        if not trade:
            await query.edit_message_text(stylize_text('❌ This trade offer has expired or was already processed.'))
            return
        sender_id, receiver_id, offer_id, want_id = (trade[1], trade[2], trade[3], trade[4])
        if user.id != receiver_id:
            await query.answer(stylize_text('This trade is not for you.'), show_alert=True)
            return
        cursor.execute('UPDATE user_cards SET quantity = quantity - 1 WHERE user_id = ? AND card_id = ? AND quantity > 0', (sender_id, offer_id))
        cursor.execute('DELETE FROM user_cards WHERE user_id = ? AND card_id = ? AND quantity <= 0', (sender_id, offer_id))
        cursor.execute('UPDATE user_cards SET quantity = quantity - 1 WHERE user_id = ? AND card_id = ? AND quantity > 0', (receiver_id, want_id))
        cursor.execute('DELETE FROM user_cards WHERE user_id = ? AND card_id = ? AND quantity <= 0', (receiver_id, want_id))
        cursor.execute('INSERT INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = quantity + 1', (sender_id, want_id, time.time()))
        cursor.execute('INSERT INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = quantity + 1', (receiver_id, offer_id, time.time()))
        cursor.execute("UPDATE trades SET status = 'completed' WHERE trade_id = ?", (trade_id,))
        conn.commit()
        sync_user_card_count(sender_id)
        sync_user_card_count(receiver_id)
        refresh_favorite_card(sender_id)
        refresh_favorite_card(receiver_id)
        await query.edit_message_text(stylize_text('✅ Trade completed successfully! 🎉'))
        await context.bot.send_message(chat_id=sender_id, text='✅ Your trade has been accepted! Check your harem.')
    elif data.startswith('trade_cancel:'):
        trade_id = int(data.split(':')[1])
        cursor.execute("UPDATE trades SET status = 'cancelled' WHERE trade_id = ?", (trade_id,))
        conn.commit()
        await query.edit_message_text(stylize_text('❌ Trade declined.'))
        cursor.execute('SELECT sender_id FROM trades WHERE trade_id = ?', (trade_id,))
        row = cursor.fetchone()
        if row:
            await context.bot.send_message(chat_id=row[0], text='❌ Your trade offer was declined.')

async def gift_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    action, gift_id_str = data.split(':', 1)
    try:
        gift_id = int(gift_id_str)
    except ValueError:
        await query.answer(stylize_text('Invalid gift request.'), show_alert=True)
        return
    gift = get_gift_request(gift_id)
    if not gift:
        await query.answer(stylize_text('Gift request not found.'), show_alert=True)
        return
    _, sender_id, receiver_id, card_id, request_type, status = gift
    if query.from_user.id != sender_id:
        await query.answer(stylize_text('This gift request is not for you.'), show_alert=True)
        return
    if status != 'pending':
        await query.answer(stylize_text('This gift request has already been processed.'), show_alert=True)
        return
    card = get_card(card_id)
    card_name = card[1] if card else 'Unknown'
    if action == 'gift_accept':
        if request_type == 'gift':
            sender_card = get_user_card(sender_id, card_id)
            if not sender_card or sender_card[2] <= 0:
                set_gift_request_status(gift_id, 'declined')
                await query.edit_message_text(stylize_text('❌ Gift failed: sender no longer owns this card.'))
                await context.bot.send_message(chat_id=sender_id, text=f'❌ Your gift of `#{card_id} – {card_name}` could not be completed because you no longer own it.', parse_mode=ParseMode.MARKDOWN)
                return
            cursor.execute('UPDATE user_cards SET quantity = quantity - 1 WHERE user_id = ? AND card_id = ?', (sender_id, card_id))
            cursor.execute('DELETE FROM user_cards WHERE user_id = ? AND card_id = ? AND quantity <= 0', (sender_id, card_id))
            cursor.execute('INSERT INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = quantity + 1', (receiver_id, card_id, time.time()))
            conn.commit()
            sync_user_card_count(sender_id)
            sync_user_card_count(receiver_id)
            refresh_favorite_card(sender_id)
            set_gift_request_status(gift_id, 'accepted')
            await query.edit_message_text(stylize_text(f'✅ Gift accepted! `#{card_id} – {card_name}` has been added to your harem.'), parse_mode=ParseMode.MARKDOWN)
            await context.bot.send_message(chat_id=sender_id, text=f'✅ Your gift of `#{card_id} – {card_name}` was accepted.', parse_mode=ParseMode.MARKDOWN)
        else:
            cursor.execute('INSERT INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, card_id) DO UPDATE SET quantity = quantity + 1', (receiver_id, card_id, time.time()))
            conn.commit()
            sync_user_card_count(receiver_id)
            set_gift_request_status(gift_id, 'accepted')
            await query.edit_message_text(stylize_text(f'✅ Gift accepted! `#{card_id} – {card_name}` has been added to your harem.'), parse_mode=ParseMode.MARKDOWN)
            await context.bot.send_message(chat_id=sender_id, text=f'✅ Your gift request for `#{card_id} – {card_name}` was accepted.', parse_mode=ParseMode.MARKDOWN)
    elif action == 'gift_decline':
        set_gift_request_status(gift_id, 'declined')
        await query.edit_message_text(stylize_text('❌ Gift declined.'))
        await context.bot.send_message(chat_id=sender_id, text=f'❌ Your gift of `#{card_id} – {card_name}` was declined.', parse_mode=ParseMode.MARKDOWN)
    else:
        await query.answer(stylize_text('Unknown gift action.'), show_alert=True)

async def gift(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if not context.args or len(context.args) != 1:
        await update.message.reply_text(stylize_text('📖 Use: `/gift [card_id]` (reply to the user you want to gift to)'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    if not update.message.reply_to_message:
        await update.message.reply_text(stylize_text('❌ Please reply to the user you want to gift the card.'))
        return
    receiver = update.message.reply_to_message.from_user
    if receiver.id == user.id:
        await update.message.reply_text(stylize_text('❌ You cannot gift to yourself.'))
        return
    create_user(receiver.id, receiver.username or '', receiver.first_name or '')
    user_card = get_user_card(user.id, card_id)
    if not user_card or user_card[2] <= 0:
        await update.message.reply_text(stylize_text("❌ You don't own this card."))
        return
    card = get_card(card_id)
    card_name = card[1] if card else 'Unknown'
    safe_user_first_name = user.first_name.replace('_', '\\_') if user.first_name else 'User'
    safe_receiver_first_name = receiver.first_name.replace('_', '\\_') if receiver.first_name else 'User'
    gift_id = create_gift_request(user.id, receiver.id, card_id, 'gift')
    keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('✅ Yes', callback_data=f'gift_accept:{gift_id}'), InlineKeyboardButton('❌ No', callback_data=f'gift_decline:{gift_id}')]])
    try:
        await update.message.reply_text(stylize_text(f'🎁 *Gift Confirmation*\n\nYou are gifting `#{card_id} – {card_name}` to **{safe_receiver_first_name}**.\nPress yes to complete the gift, or no to cancel it.'), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
    except Exception:
        set_gift_request_status(gift_id, 'declined')
        await update.message.reply_text(stylize_text('❌ Could not send gift request. The user may have blocked the bot.'))

async def giftc(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Owner-only command to gift any card from database to a player's harem."""
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    target_id = None
    card_arg = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
        if not context.args:
            await update.message.reply_text(stylize_text('📖 Use: `/giftc [card_id]` while replying to a user or `/giftc @username [card_id]`.'), parse_mode=ParseMode.MARKDOWN)
            return
        card_arg = context.args[0]
    else:
        if len(context.args) < 2:
            await update.message.reply_text(stylize_text('📖 Use: `/giftc @username [card_id]` or reply to a user with `/giftc [card_id]`.'), parse_mode=ParseMode.MARKDOWN)
            return
        target_arg = context.args[0]
        card_arg = context.args[1]
        if target_arg.startswith('@') or not target_arg.isdigit():
            target_user = find_user_by_username(target_arg)
            if not target_user:
                await update.message.reply_text(stylize_text('❌ User not found. Check the username.'))
                return
            target_id = target_user[0]
        else:
            try:
                target_id = int(target_arg)
            except ValueError:
                await update.message.reply_text(stylize_text('❌ Invalid target identifier.'))
                return
    try:
        card_id = int(card_arg)
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found in the database.'))
        return
    create_user(target_id)
    card_name = card[1]
    safe_owner_first_name = user.first_name.replace('_', '\\_') if user.first_name else 'Owner'
    gift_id = create_gift_request(user.id, target_id, card_id, 'giftc')
    keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('✅ Yes', callback_data=f'gift_accept:{gift_id}'), InlineKeyboardButton('❌ No', callback_data=f'gift_decline:{gift_id}')]])
    try:
        await update.message.reply_text(stylize_text(f'🎁 *Gift Confirmation*\n\nYou are gifting `#{card_id} – {card_name}` to the selected player.\nPress yes to complete the gift, or no to cancel it.'), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
    except Exception:
        set_gift_request_status(gift_id, 'declined')
        await update.message.reply_text(stylize_text('❌ Could not send gift request. The user may have blocked the bot.'))

async def profile(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    user_row = get_user(user.id)
    if not user_row:
        await update.message.reply_text(stylize_text('⚠️ User data not found.'))
        return
    # Safely extract fields by index to tolerate schema changes (e.g., added columns)
    username = user_row[1] if len(user_row) > 1 else ''
    first_name = user_row[2] if len(user_row) > 2 else ''
    card_count = user_row[3] if len(user_row) > 3 else 0
    fav_card_id = user_row[4] if len(user_row) > 4 else None
    is_banned_ = user_row[5] if len(user_row) > 5 else 0
    ban_until = user_row[6] if len(user_row) > 6 else None
    is_uploader_ = user_row[7] if len(user_row) > 7 else 0
    total_cards = cursor.execute('SELECT COUNT(*) FROM cards').fetchone()[0]
    global_rank = cursor.execute('SELECT COUNT(*) FROM users WHERE card_count > ?', (card_count,)).fetchone()[0] + 1
    safe_username = username.replace('_', '\\_') if username else ''
    safe_first_name = first_name.replace('_', '\\_') if first_name else ''
    username_display = f'@{safe_username}' if username else safe_first_name
    fav_display = f'#{fav_card_id}' if fav_card_id else 'None'
    uploader_status = '✅ Yes' if is_uploader_ else '❌ No'
    ban_status = '✅ No' if not is_banned_ else '⛔ Yes (until {})'.format(datetime.fromtimestamp(ban_until).strftime('%Y-%m-%d %H:%M') if ban_until else 'permanent')
    text = f'╔══════════════════════════╗\n     👤 *User Profile*\n╠══════════════════════════╣\n👤 User: {username_display}\n🆔 ID: `{user.id}`\n📊 Cards: {card_count} / {total_cards} total\n🏆 Global Rank: #{global_rank}\n⭐ Favourite Card: {fav_display}\n👑 Uploader: {uploader_status}\n🚫 Ban: {ban_status}\n╚══════════════════════════╝\n─────────────────\n🌟 *Galaxy Bot*'
    if fav_card_id:
        fav_card = get_card(fav_card_id)
        if fav_card:
            file_id = fav_card[4]
            file_type = fav_card[5]
            if file_type == 'photo':
                await update.message.reply_photo(photo=file_id, caption=text, parse_mode=ParseMode.MARKDOWN)
            else:
                await update.message.reply_video(video=file_id, caption=text, parse_mode=ParseMode.MARKDOWN)
            return
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)

async def leaderboard(update: Update, context: ContextTypes.DEFAULT_TYPE):
    cursor.execute('\n        SELECT u.user_id, u.username, u.first_name, COALESCE(SUM(uc.quantity), 0) as total_quantity\n        FROM users u\n        LEFT JOIN user_cards uc ON u.user_id = uc.user_id\n        GROUP BY u.user_id\n        ORDER BY total_quantity DESC\n        LIMIT 10\n        ')
    rows = cursor.fetchall()
    if not rows:
        await update.message.reply_text(stylize_text('📭 No users yet.'))
        return
    lines = []
    for idx, (uid, uname, fname, total_qty) in enumerate(rows, start=1):
        display_name = uname if uname else fname
        safe_name = display_name.replace('_', '\\_')
        lines.append(f'{idx} -(@{safe_name}) ({total_qty})')
    text = '🏆 *Leaderboard – Top 10 Collectors* 🏆\n\n' + '\n'.join(lines) + '\n\n─────────────────\n🌟 *Galaxy Bot*'
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)

async def card_details(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Show card with media attached and owners list (clickable to profiles)
    if not context.args:
        await update.message.reply_text(stylize_text('📖 Use: `/card [card_id]`'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found.'))
        return
    cid, name, anime, rank, file_id, file_type, uploader_id, uploaded_at, total_caught = card

    # Build uploader display (prefer @username, otherwise HTML tg://user link)
    uploader_row = get_user(uploader_id)
    from html import escape as html_escape
    if uploader_row:
        u_username = uploader_row[1] if len(uploader_row) > 1 else ''
        u_first = uploader_row[2] if len(uploader_row) > 2 else ''
    else:
        u_username = ''
        u_first = ''
    if u_username:
        uploader_display = f'<a href="https://t.me/{html_escape(u_username)}">@{html_escape(u_username)}</a>'
    else:
        safe_name = html_escape(u_first or 'Unknown')
        uploader_display = f'<a href="tg://user?id={uploader_id}">{safe_name}</a>'

    # Get owners list with duplicate counts
    cursor.execute('SELECT u.user_id, u.username, u.first_name, uc.quantity FROM user_cards uc JOIN users u ON uc.user_id = u.user_id WHERE uc.card_id = ?', (card_id,))
    owners = cursor.fetchall()
    
    # Check if any owner has duplicates
    has_duplicates = any(qty > 1 for _, _, _, qty in owners)
    
    # Sort by quantity descending, then select top 5
    if has_duplicates:
        # Sort by quantity descending to get top 5 by duplicates
        sorted_owners = sorted(owners, key=lambda x: x[3], reverse=True)[:5]
    else:
        # No duplicates: randomly select 5 owners (or all if fewer than 5)
        import random as rand_module
        sorted_owners = rand_module.sample(owners, min(5, len(owners)))
    
    owner_parts = []
    for uid, uname, fname, qty in sorted_owners:
        if uname:
            part = f'<a href="https://t.me/{html_escape(uname)}">@{html_escape(uname)}</a>'
        else:
            display_name = html_escape(fname or 'User')
            part = f'<a href="tg://user?id={uid}">{display_name}</a>'
        # Show (xN) only if qty > 1
        if qty > 1:
            part = f'{part} (x{qty})'
        owner_parts.append(part)
    owners_text = '\n'.join(owner_parts) if owner_parts else 'No owners yet.'

    # Build caption per requested format (use HTML for owner links)
    caption = (
        '━━━━━━━━━━━━━━━━━━\n'
        f'⇝ Name : {html_escape(name)}\n'
        f'⇝ Anime: {html_escape(anime)}\n'
        f'⇝ Rarity: {html_escape(format_rank(rank))}\n'
        f'⇝ Uploader: {uploader_display}\n'
        f'⇝ ID: {cid}\n'
        '━━━━━━━━━━━━━━━━━━\n\n'
        '| owners |\n'
        f'{owners_text}'
    )

    try:
        if file_type == 'photo':
            await update.message.reply_photo(photo=file_id, caption=caption, parse_mode=ParseMode.HTML)
        else:
            await update.message.reply_video(video=file_id, caption=caption, parse_mode=ParseMode.HTML)
    except Exception:
        # Fallback: send media without caption and then text message
        try:
            if file_type == 'photo':
                await update.message.reply_photo(photo=file_id)
            else:
                await update.message.reply_video(video=file_id)
        except Exception:
            pass
        await update.message.reply_text(caption, parse_mode=ParseMode.HTML)

async def search(update: Update, context: ContextTypes.DEFAULT_TYPE):
    if not context.args:
        await update.message.reply_text(stylize_text('📖 Use: `/search [name]`'), parse_mode=ParseMode.MARKDOWN)
        return
    query = ' '.join(context.args).strip()
    cursor.execute('SELECT card_id, character_name, anime_name, rank FROM cards WHERE character_name LIKE ? OR anime_name LIKE ?', (f'%{query}%', f'%{query}%'))
    rows = cursor.fetchall()
    if not rows:
        await update.message.reply_text(stylize_text('❌ No cards found.'))
        return
    lines = []
    for cid, name, anime, rank in rows:
        rank_emoji = RANK_EMOJI.get(rank, '')
        lines.append(f'`#{cid}` {name} – {anime} [{rank_emoji}]')
    text = '🔍 *Search Results*\n\n' + '\n'.join(lines) + '\n\n─────────────────\n🌟 *Galaxy Bot*'
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN)

async def favorite(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if not context.args or len(context.args) != 1:
        await update.message.reply_text(stylize_text('📖 Use: `/favorite [card_id]`'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found.'))
        return
    user_card = get_user_card(user.id, card_id)
    if not user_card or user_card[2] <= 0:
        await update.message.reply_text(stylize_text("❌ You don't own this card."))
        return
    cursor.execute('UPDATE users SET fav_card_id = ? WHERE user_id = ?', (card_id, user.id))
    conn.commit()
    await update.message.reply_text(stylize_text(f'⭐ Favourite card set to `#{card_id}`!'), parse_mode=ParseMode.MARKDOWN)

async def upload(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if not (is_uploader(user.id) or is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ You are not an uploader. Ask an admin or owner to grant you the role.'))
        return
    raw_text = update.message.text or ''
    args_text = raw_text.split(' ', 1)[1].strip() if ' ' in raw_text else ''
    parts = [part.strip() for part in args_text.split('&') if part.strip()]
    if len(parts) != 3:
        await update.message.reply_text(stylize_text("📖 Use: `/upload [character's name] & [anime/movie's name] & [rank]`\nRanks: common, uncommon, legendary, mythic, galaxy\nReply to a photo or video with the command."), parse_mode=ParseMode.MARKDOWN)
        return
    char_name, anime_name, rank = (parts[0], parts[1], parts[2].lower())
    if rank not in RANK_UI:
        await update.message.reply_text(stylize_text('❌ Invalid rank. Use: common, uncommon, legendary, mythic, galaxy.'))
        return
    if not update.message.reply_to_message:
        await update.message.reply_text(stylize_text('❌ Reply to a photo, video, animation, or video document with media.'))
        return
    msg = update.message.reply_to_message
    file_id = None
    file_type = None
    if msg.photo:
        file_id = msg.photo[-1].file_id
        file_type = 'photo'
    elif msg.video:
        file_id = msg.video.file_id
        file_type = 'video'
    elif msg.animation:
        file_id = msg.animation.file_id
        file_type = 'video'
    elif msg.document and msg.document.mime_type and msg.document.mime_type.startswith('video/'):
        file_id = msg.document.file_id
        file_type = 'video'
    else:
        await update.message.reply_text(stylize_text('❌ Please reply to a photo, video, animation, or video document.'))
        return
    next_card_id = get_next_card_id()
    cursor.execute('INSERT INTO cards (card_id, character_name, anime_name, rank, file_id, file_type, uploader_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', (next_card_id, char_name, anime_name, rank, file_id, file_type, user.id, time.time()))
    conn.commit()
    await update.message.reply_text(stylize_text(f'✅ Card uploaded successfully!\n🆔 ID: `#{next_card_id}`\n👤 {char_name} from {anime_name} [{format_rank(rank)}]'), parse_mode=ParseMode.MARKDOWN)
    
    # Announce new card to database channel
    try:
        uploader_display = f"@{user.username}" if user.username else (user.first_name or 'Unknown')
        rank_emoji = RANK_EMOJI.get(rank, '')
        caption = stylize_text(
            '🆕 𝐂𝐇𝐀𝐑𝐀𝐂𝐓𝐄𝐑 𝐀𝐃𝐃𝐄𝐃!\n'
            '─────────────────\n'
            f'👤 𝐍𝐚𝐦𝐞: {char_name}\n'
            f'📺 𝐀𝐧𝐢𝐦𝐞: {anime_name}\n'
            f'💎 𝐑𝐚𝐫𝐢𝐭𝐲: {rank_emoji} {rank.capitalize()}\n'
            f'🆔 𝐈𝐃: {next_card_id}\n'
            '─────────────────\n'
            f'🛠 𝐀𝐝𝐝𝐞𝐝 𝐁𝐲 : {uploader_display}'
        )
        if file_type == 'photo':
            await context.bot.send_photo(chat_id=DATABASE_CHANNEL, photo=file_id, caption=caption)
        else:
            await context.bot.send_video(chat_id=DATABASE_CHANNEL, video=file_id, caption=caption)
    except Exception:
        logger.exception('Failed to announce new card to database channel')

async def editcard(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if is_banned(user.id):
        await update.message.reply_text(stylize_text(ban_notice_text(user.id)), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(user.id, user.username or '', user.first_name or '')
    if not (is_uploader(user.id) or is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text("❌ You don't have permission to edit cards."))
        return
    raw_text = update.message.text or ''
    args_text = raw_text.split(' ', 1)[1].strip() if ' ' in raw_text else ''
    parts = [part.strip() for part in args_text.split('&') if part.strip()]
    if len(parts) != 4:
        await update.message.reply_text(stylize_text('📖 Use: `/editcard [id] & [new character name] & [new anime name] & [new rank]`\nRanks: common, uncommon, legendary, mythic, galaxy'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(parts[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    new_char = parts[1]
    new_anime = parts[2]
    new_rank = parts[3].lower()
    if new_rank not in RANK_UI:
        await update.message.reply_text(stylize_text('❌ Invalid rank. Use: common, uncommon, legendary, mythic, galaxy.'))
        return
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found.'))
        return
    cursor.execute('UPDATE cards SET character_name = ?, anime_name = ?, rank = ? WHERE card_id = ?', (new_char, new_anime, new_rank, card_id))
    conn.commit()
    await update.message.reply_text(stylize_text(f'✅ Card `#{card_id}` updated: {new_char} – {new_anime} [{format_rank(new_rank)}]'), parse_mode=ParseMode.MARKDOWN)

async def setadmin(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if not context.args and not update.message.reply_to_message:
        await update.message.reply_text(stylize_text('📖 Use: `/setadmin [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    target_id = None
    target_name = 'User'
    if update.message.reply_to_message:
        target_user = update.message.reply_to_message.from_user
        target_id = target_user.id
        target_name = target_user.first_name or 'User'
        create_user(target_id, target_user.username or '', target_name)
    else:
        try:
            target_id = int(context.args[0])
            create_user(target_id)
            user_row = get_user(target_id)
            if user_row:
                target_name = user_row[2] or 'User'
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid user ID.'))
            return
    # Grant admin immediately to avoid callback/confirmation issues.
    create_user(target_id)
    cursor.execute('UPDATE users SET is_admin = 1 WHERE user_id = ?', (target_id,))
    if cursor.rowcount == 0:
        # If update didn't affect a row (user may not exist), insert with is_admin=1
        cursor.execute('INSERT OR REPLACE INTO users (user_id, username, first_name, is_admin) VALUES (?, ?, ?, 1)', (target_id, '', target_name))
    conn.commit()
    safe_name = target_name.replace('_', '\\_')
    await update.message.reply_text(stylize_text(f'✅ User **{safe_name}** (`{target_id}`) is now an admin.'), parse_mode=ParseMode.MARKDOWN)
    try:
        await context.bot.send_message(chat_id=target_id, text=stylize_text('🎉 You have been granted admin status! You can now use admin commands.'))
    except:
        pass

async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    user = query.from_user
    if not is_owner(user.id):
        await query.answer(stylize_text('Only the owner can use this.'), show_alert=True)
        return
    if data == 'admin_cancel':
        await query.edit_message_text(stylize_text('❌ Operation cancelled.'))
        return
    if data.startswith('admin_confirm:'):
        target_id = int(data.split(':')[1])
        cursor.execute('SELECT is_admin FROM users WHERE user_id = ?', (target_id,))
        row = cursor.fetchone()
        if not row:
            create_user(target_id)
        cursor.execute('UPDATE users SET is_admin = 1 WHERE user_id = ?', (target_id,))
        conn.commit()
        await query.edit_message_text(stylize_text(f'✅ User `{target_id}` is now an admin.'), parse_mode=ParseMode.MARKDOWN)
        try:
            await context.bot.send_message(chat_id=target_id, text=stylize_text('🎉 You have been granted admin status! You can now use admin commands.'))
        except:
            pass

async def setuploader(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if not context.args and not update.message.reply_to_message:
        await update.message.reply_text(stylize_text('📖 Use: `/setuploader [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    target_id = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
    else:
        try:
            target_id = int(context.args[0])
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid user ID.'))
            return
    create_user(target_id)
    cursor.execute('UPDATE users SET is_uploader = 1 WHERE user_id = ?', (target_id,))
    conn.commit()
    await update.message.reply_text(stylize_text(f'✅ User `{target_id}` is now an uploader.'), parse_mode=ParseMode.MARKDOWN)
    try:
        await context.bot.send_message(chat_id=target_id, text=stylize_text('🎉 You have been granted uploader status! Use /upload to add cards.'))
    except:
        pass

async def unset(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    target_id = None
    target_arg = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
    elif context.args:
        target_arg = context.args[0]
        if target_arg.startswith('@') or not target_arg.isdigit():
            target_row = find_user_by_username(target_arg)
            if target_row:
                target_id = target_row[0]
            elif target_arg.isdigit():
                target_id = int(target_arg)
            else:
                await update.message.reply_text(stylize_text('❌ User not found. Use a valid username or ID.'), parse_mode=ParseMode.MARKDOWN)
                return
        else:
            try:
                target_id = int(target_arg)
            except ValueError:
                await update.message.reply_text(stylize_text('❌ Invalid user identifier. Use @username or user ID.'), parse_mode=ParseMode.MARKDOWN)
                return
    else:
        await update.message.reply_text(stylize_text('📖 Use: `/unset [user_id|@username]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    if target_id is None:
        await update.message.reply_text(stylize_text('❌ Could not determine target user. Use a username or ID, or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    create_user(target_id)
    row = get_user(target_id)
    is_uploader_ = row[7] if len(row) > 7 else 0
    is_admin_ = row[8] if len(row) > 8 else 0
    updated = False
    if is_uploader_:
        cursor.execute('UPDATE users SET is_uploader = 0 WHERE user_id = ?', (target_id,))
        updated = True
    if is_admin_:
        cursor.execute('UPDATE users SET is_admin = 0 WHERE user_id = ?', (target_id,))
        updated = True
    if updated:
        conn.commit()
        await update.message.reply_text(stylize_text(f'✅ User `{target_id}` has been unset from admin/uploader ranks.'), parse_mode=ParseMode.MARKDOWN)
    else:
        await update.message.reply_text(stylize_text('ℹ️ That user was not an admin or uploader.'), parse_mode=ParseMode.MARKDOWN)

async def spawnset(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if not context.args or len(context.args) != 1:
        await update.message.reply_text(stylize_text('📖 Use: `/spawnset [number]`\n\nSet the spawn rate for this group. After [number] messages, a card will spawn.'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        spawn_rate = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Spawn rate must be a number.'))
        return
    if spawn_rate < 1:
        await update.message.reply_text(stylize_text('❌ Spawn rate must be at least 1.'))
        return
    chat_id = update.effective_chat.id
    spawn_rate_settings[chat_id] = spawn_rate
    await update.message.reply_text(stylize_text(f'✅ Spawn rate for this group has been set to {spawn_rate} messages.'), parse_mode=ParseMode.MARKDOWN)

async def rankset(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    chat = update.effective_chat
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if chat.type not in ('group', 'supergroup'):
        await update.message.reply_text(stylize_text('❌ Use this command in a group to configure rank spawns there.'), parse_mode=ParseMode.MARKDOWN)
        return
    buttons = [[InlineKeyboardButton(f"{RANK_EMOJI.get(rank, '')} {rank.capitalize()}", callback_data=f'rankset_rank:{rank}')] for rank in RANKS_ORDERED]
    buttons.append([InlineKeyboardButton('❌ Close', callback_data='rankset_cancel')])
    await update.message.reply_text(stylize_text('Select a rank to open or close its spawn in this group:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))

async def rankset_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    user = query.from_user
    chat = query.message.chat
    if not is_owner(user.id):
        await query.answer(stylize_text('Only the owner can use this.'), show_alert=True)
        return
    data = query.data
    if data == 'rankset_cancel':
        try:
            await query.edit_message_text(stylize_text('Rank spawn setup cancelled.'), parse_mode=ParseMode.MARKDOWN)
        except Exception:
            pass
        return
    if data.startswith('rankset_rank:'):
        rank = data.split(':', 1)[1]
        if rank not in RANKS_ORDERED:
            await query.answer(stylize_text('Invalid rank selected.'), show_alert=True)
            return
        current_state = rank_spawn_settings[chat.id].get(rank, True)
        status_text = 'open' if current_state else 'closed'
        buttons = [
            [InlineKeyboardButton('✅ Open', callback_data=f'rankset_action:{rank}:open'), InlineKeyboardButton('⛔ Close', callback_data=f'rankset_action:{rank}:close')],
            [InlineKeyboardButton('↩️ Back', callback_data='rankset_back')]
        ]
        await query.edit_message_text(stylize_text(f'Rank `{rank.capitalize()}` is currently {status_text}. Choose an action:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))
        return
    if data.startswith('rankset_action:'):
        parts = data.split(':')
        if len(parts) != 3:
            await query.answer(stylize_text('Invalid action.'), show_alert=True)
            return
        rank = parts[1]
        action = parts[2]
        if rank not in RANKS_ORDERED or action not in ('open', 'close'):
            await query.answer(stylize_text('Invalid action.'), show_alert=True)
            return
        enabled = action == 'open'
        rank_spawn_settings[chat.id][rank] = enabled
        try:
            await query.edit_message_text(stylize_text(f'✅ Rank `{rank.capitalize()}` spawn has been {"opened" if enabled else "closed"} for this group.'), parse_mode=ParseMode.MARKDOWN)
        except Exception:
            pass
        return
    if data == 'rankset_back':
        buttons = [[InlineKeyboardButton(f"{RANK_EMOJI.get(rank, '')} {rank.capitalize()}", callback_data=f'rankset_rank:{rank}')] for rank in RANKS_ORDERED]
        buttons.append([InlineKeyboardButton('❌ Close', callback_data='rankset_cancel')])
        try:
            await query.edit_message_text(stylize_text('Select a rank to open or close its spawn in this group:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))
        except Exception:
            pass
        return

def _gather_known_group_chats() -> list:
    """Return a list of known group chat IDs from runtime and DB tables."""
    chats = set()
    # From runtime rank settings
    for cid in list(rank_spawn_settings.keys()):
        try:
            chats.add(int(cid))
        except Exception:
            pass
    # From recent message tracking
    for cid in list(user_message_times.keys()):
        try:
            chats.add(int(cid))
        except Exception:
            pass
    # From spawns table (historic)
    try:
        cursor.execute('SELECT DISTINCT chat_id FROM spawns')
        rows = cursor.fetchall()
        for (cid,) in rows:
            try:
                chats.add(int(cid))
            except Exception:
                pass
    except Exception:
        pass
    # From known chats persisted in DB
    try:
        cursor.execute('SELECT chat_id FROM known_chats')
        rows = cursor.fetchall()
        for (cid,) in rows:
            try:
                chats.add(int(cid))
            except Exception:
                pass
    except Exception:
        pass
    # Filter only group/supergroup IDs (Telegram uses negative ids for groups)
    group_chats = [c for c in chats if isinstance(c, int) and c < 0]
    return sorted(group_chats)

async def grankset(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    buttons = [[InlineKeyboardButton(f"{RANK_EMOJI.get(rank, '')} {rank.capitalize()}", callback_data=f'grankset_rank:{rank}')] for rank in RANKS_ORDERED]
    buttons.append([InlineKeyboardButton('❌ Close', callback_data='grankset_cancel')])
    await update.message.reply_text(stylize_text('Select a rank to open/close across all known groups:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))

async def grankset_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    user = query.from_user
    if not is_owner(user.id):
        await query.answer(stylize_text('Only the owner can use this.'), show_alert=True)
        return
    data = query.data
    if data == 'grankset_cancel':
        try:
            await query.edit_message_text(stylize_text('Global rank spawn setup cancelled.'), parse_mode=ParseMode.MARKDOWN)
        except Exception:
            pass
        return
    if data.startswith('grankset_rank:'):
        rank = data.split(':', 1)[1]
        if rank not in RANKS_ORDERED:
            await query.answer(stylize_text('Invalid rank selected.'), show_alert=True)
            return
        # Offer Open All / Close All
        buttons = [
            [InlineKeyboardButton('✅ Open All', callback_data=f'grankset_action:{rank}:open') , InlineKeyboardButton('⛔ Close All', callback_data=f'grankset_action:{rank}:close')],
            [InlineKeyboardButton('↩️ Back', callback_data='grankset_back')]
        ]
        await query.edit_message_text(stylize_text(f'Global control for rank `{rank.capitalize()}` — choose action:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))
        return
    if data.startswith('grankset_action:'):
        parts = data.split(':')
        if len(parts) != 3:
            await query.answer(stylize_text('Invalid action.'), show_alert=True)
            return
        rank = parts[1]
        action = parts[2]
        if rank not in RANKS_ORDERED or action not in ('open', 'close'):
            await query.answer(stylize_text('Invalid action.'), show_alert=True)
            return
        enabled = action == 'open'
        group_chats = _gather_known_group_chats()
        updated = 0
        for cid in group_chats:
            # Ensure entry exists
            if cid not in rank_spawn_settings:
                rank_spawn_settings[cid] = {r: True for r in RANKS_ORDERED}
            prev = rank_spawn_settings[cid].get(rank, True)
            if prev != enabled:
                rank_spawn_settings[cid][rank] = enabled
                updated += 1
        try:
            await query.edit_message_text(stylize_text(f'✅ Global update complete: {"opened" if enabled else "closed"} `{rank.capitalize()}` for {updated} groups.'), parse_mode=ParseMode.MARKDOWN)
        except Exception:
            pass
        return
    if data == 'grankset_back':
        buttons = [[InlineKeyboardButton(f"{RANK_EMOJI.get(rank, '')} {rank.capitalize()}", callback_data=f'grankset_rank:{rank}')] for rank in RANKS_ORDERED]
        buttons.append([InlineKeyboardButton('❌ Close', callback_data='grankset_cancel')])
        try:
            await query.edit_message_text(stylize_text('Select a rank to open/close across all known groups:'), parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(buttons))
        except Exception:
            pass
        return

async def admin_commands(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return
    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton('𝘁𝗶𝗺𝗲𝗼𝘂𝘁', callback_data='tutorial_timeout_admin')],
        [InlineKeyboardButton('𝘂𝗻𝗯𝗮𝗻', callback_data='tutorial_unban_admin')],
        [InlineKeyboardButton('𝘂𝗻𝘁𝗶𝗺𝗲', callback_data='tutorial_untime_admin')],
        [InlineKeyboardButton('𝗲𝗱𝗶𝘁𝗰𝗮𝗿𝗱', callback_data='tutorial_editcard_admin')],
        [InlineKeyboardButton('𝗱𝗲𝗹', callback_data='tutorial_del_admin')],
        [InlineKeyboardButton('𝘂𝗽𝗹𝗼𝗮𝗱', callback_data='tutorial_upload_admin')]
    ])
    text = '📚 **Admin Commands Menu**\n\nSelect a command to view its tutorial:'
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def owner_commands(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton('𝘁𝗶𝗺𝗲𝗼𝘂𝘁', callback_data='tutorial_timeout_owner')],
        [InlineKeyboardButton('𝗯𝗮𝗻', callback_data='tutorial_ban_owner')],
        [InlineKeyboardButton('𝘂𝗻𝗯𝗮𝗻', callback_data='tutorial_unban_owner')],
        [InlineKeyboardButton('𝘂𝗻𝘁𝗶𝗺𝗲', callback_data='tutorial_untime_owner')],
        [InlineKeyboardButton('𝗲𝗱𝗶𝘁𝗰𝗮𝗿𝗱', callback_data='tutorial_editcard_owner')],
        [InlineKeyboardButton('𝗱𝗲𝗹', callback_data='tutorial_del_owner')],
        [InlineKeyboardButton('𝘂𝗽𝗹𝗼𝗮𝗱', callback_data='tutorial_upload_owner')],
        [InlineKeyboardButton('𝘀𝗲𝘁𝗮𝗱𝗺𝗶𝗻', callback_data='tutorial_setadmin_owner')],
        [InlineKeyboardButton('𝘀𝗲𝘁𝘂𝗽𝗹𝗼𝗮𝗱𝗲𝗿', callback_data='tutorial_setuploader_owner')]
    ])
    text = '📚 **Owner Commands Menu**\n\nSelect a command to view its tutorial:'
    await update.message.reply_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def tutorial_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    user = query.from_user
    
    tutorials = {
        'timeout': '⏳ **TIMEOUT Command**\n\n📖 **Usage:**\n`/timeout [minutes] [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/timeout 30 12345` - Timeout user 12345 for 30 minutes\n• Reply to a user and use `/timeout 60` - Timeout that user for 60 minutes\n\n💡 **Notes:**\n• Only the owner can use this command\n• Times out a user temporarily (they can\'t catch cards)\n• Use `/untime` to cancel the timeout',
        'ban': '🚫 **BAN Command**\n\n📖 **Usage:**\n`/ban [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/ban 12345` - Ban user 12345\n• Reply to a user and use `/ban` - Ban that user\n\n💡 **Notes:**\n• Only the owner can use this command\n• Permanently bans a user from the bot\n• Use `/unban` to unban the user',
        'unban': '✅ **UNBAN Command**\n\n📖 **Usage:**\n`/unban [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/unban 12345` - Unban user 12345\n• Reply to a user and use `/unban` - Unban that user\n\n💡 **Notes:**\n• Admins and owner can use this command\n• Unbans a user that was previously banned',
        'untime': '⏳ **UNTIME Command**\n\n📖 **Usage:**\n`/untime [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/untime 12345` - Cancel timeout for user 12345\n• Reply to a user and use `/untime` - Cancel that user\'s timeout\n\n💡 **Notes:**\n• Admins and owner can use this command\n• Cancels a user\'s temporary timeout\n• User can then catch cards again',
        'editcard': '✏️ **EDITCARD Command**\n\n📖 **Usage:**\n`/editcard [card_id] [field] [value]`\n\n📝 **Examples:**\n• `/editcard 5 character Tanjiro` - Change card 5 character name\n• `/editcard 10 anime Demon Slayer` - Change card 10 anime name\n\n💡 **Notes:**\n• Only uploaders and owner can use this command\n• Editable fields: character, anime, rank\n• Card ID must be a valid number',
        'del': '🗑️ **DEL Command**\n\n📖 **Usage:**\n`/del [card_id]` (reply) or `/del @username [card_id]` or `/del [user_id] [card_id]`\n\n📝 **Examples:**\n• Reply to a user and use `/del 5` - Delete card 5 from that user\n• `/del @username 5` - Delete card 5 from @username\n• `/del 12345 5` - Delete card 5 from user 12345\n\n💡 **Notes:**\n• Admins and owner can use this command\n• Deletes one copy of a card from user\'s harem\n• If user has multiple copies, only one is removed',
        'upload': '📤 **UPLOAD Command**\n\n📖 **Usage:**\n`/upload`\n\n📝 **Steps:**\n1. Use `/upload`\n2. Follow the bot\'s prompts\n3. Provide card details (anime, character, rank)\n4. Send a photo or video\n5. Confirm the upload\n\n💡 **Notes:**\n• Only uploaders and owner can use this command\n• Cards are added to the database\n• Photos and videos are supported',
        'setadmin': '👑 **SETADMIN Command**\n\n📖 **Usage:**\n`/setadmin [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/setadmin 12345` - Make user 12345 an admin\n• Reply to a user and use `/setadmin` - Make that user an admin\n\n💡 **Notes:**\n• Only the owner can use this command\n• Requires confirmation via inline keyboard\n• Admins can use admin commands like timeout, ban, etc.',
        'setuploader': '📚 **SETUPLOADER Command**\n\n📖 **Usage:**\n`/setuploader [user_id]` or reply to a user\n\n📝 **Examples:**\n• `/setuploader 12345` - Make user 12345 an uploader\n• Reply to a user and use `/setuploader` - Make that user an uploader\n\n💡 **Notes:**\n• Only the owner can use this command\n• Uploaders can add cards to the database using `/upload`\n• Uploaders cannot use admin commands'
    }
    
    # Parse callback data to extract command and source
    parts = data.split('_')
    source = parts[-1]  # 'admin' or 'owner'
    command = '_'.join(parts[1:-1])  # Everything between 'tutorial' and source
    
    tutorial_text = tutorials.get(command, '❌ Tutorial not found.')
    
    # Create back button based on source
    back_callback = f'back_{source}'
    keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton('↩️ Back', callback_data=back_callback)]
    ])
    
    if len(tutorial_text) > 4096:
        await query.edit_message_text(stylize_text(tutorial_text[:4096]), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)
    else:
        await query.edit_message_text(stylize_text(tutorial_text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def back_menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    user = query.from_user
    
    if data == 'back_admin':
        # Show admin menu
        keyboard = InlineKeyboardMarkup([
            [InlineKeyboardButton('𝘁𝗶𝗺𝗲𝗼𝘂𝘁', callback_data='tutorial_timeout_admin')],
            [InlineKeyboardButton('𝘂𝗻𝗯𝗮𝗻', callback_data='tutorial_unban_admin')],
            [InlineKeyboardButton('𝘂𝗻𝘁𝗶𝗺𝗲', callback_data='tutorial_untime_admin')],
            [InlineKeyboardButton('𝗲𝗱𝗶𝘁𝗰𝗮𝗿𝗱', callback_data='tutorial_editcard_admin')],
            [InlineKeyboardButton('𝗱𝗲𝗹', callback_data='tutorial_del_admin')],
            [InlineKeyboardButton('𝘂𝗽𝗹𝗼𝗮𝗱', callback_data='tutorial_upload_admin')]
        ])
        text = '📚 **Admin Commands Menu**\n\nSelect a command to view its tutorial:'
    elif data == 'back_owner':
        # Show owner menu
        keyboard = InlineKeyboardMarkup([
            [InlineKeyboardButton('𝘁𝗶𝗺𝗲𝗼𝘂𝘁', callback_data='tutorial_timeout_owner')],
            [InlineKeyboardButton('𝗯𝗮𝗻', callback_data='tutorial_ban_owner')],
            [InlineKeyboardButton('𝘂𝗻𝗯𝗮𝗻', callback_data='tutorial_unban_owner')],
            [InlineKeyboardButton('𝘂𝗻𝘁𝗶𝗺𝗲', callback_data='tutorial_untime_owner')],
            [InlineKeyboardButton('𝗲𝗱𝗶𝘁𝗰𝗮𝗿𝗱', callback_data='tutorial_editcard_owner')],
            [InlineKeyboardButton('𝗱𝗲𝗹', callback_data='tutorial_del_owner')],
            [InlineKeyboardButton('𝘂𝗽𝗹𝗼𝗮𝗱', callback_data='tutorial_upload_owner')],
            [InlineKeyboardButton('𝘀𝗲𝘁𝗮𝗱𝗺𝗶𝗻', callback_data='tutorial_setadmin_owner')],
            [InlineKeyboardButton('𝘀𝗲𝘁𝘂𝗽𝗹𝗼𝗮𝗱𝗲𝗿', callback_data='tutorial_setuploader_owner')]
        ])
        text = '📚 **Owner Commands Menu**\n\nSelect a command to view its tutorial:'
    else:
        return
    
    await query.edit_message_text(stylize_text(text), parse_mode=ParseMode.MARKDOWN, reply_markup=keyboard)

async def ban(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if not context.args and (not update.message.reply_to_message):
        await update.message.reply_text(stylize_text('📖 Use: `/ban [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    target_id = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
    else:
        try:
            target_id = int(context.args[0])
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid user ID.'))
            return
    create_user(target_id)
    cursor.execute('UPDATE users SET is_banned = 1, ban_until = NULL WHERE user_id = ?', (target_id,))
    conn.commit()
    # Sync ban to wallet bot (permanent ban)
    try:
        wallet_ban_user(target_id, None)
    except Exception:
        pass
    await update.message.reply_text(stylize_text(f'⛔ User `{target_id}` permanently banned.'), parse_mode=ParseMode.MARKDOWN)

async def unban(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not is_owner(user.id):
        await update.message.reply_text(stylize_text('❌ Only the owner can use this command.'))
        return
    if not context.args and (not update.message.reply_to_message):
        await update.message.reply_text(stylize_text('📖 Use: `/unban [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    target_id = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
    else:
        try:
            target_id = int(context.args[0])
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid user ID.'))
            return
    cursor.execute('UPDATE users SET is_banned = 0, ban_until = NULL WHERE user_id = ?', (target_id,))
    conn.commit()
    # Sync to wallet bot
    try:
        wallet_unban_user(target_id)
    except Exception:
        pass
    await update.message.reply_text(stylize_text(f'✅ User `{target_id}` unbanned.'), parse_mode=ParseMode.MARKDOWN)

async def timeout(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return
    if len(context.args) < 1 and (not update.message.reply_to_message):
        await update.message.reply_text(stylize_text('📖 Use: `/timeout [minutes] [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
        return
    target_id = None
    minutes = 0
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
        try:
            minutes = int(context.args[0]) if context.args else 60
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid minutes.'))
            return
    else:
        try:
            minutes = int(context.args[0])
            target_id = int(context.args[1]) if len(context.args) > 1 else None
        except (ValueError, IndexError):
            await update.message.reply_text(stylize_text('❌ Invalid arguments.'))
            return
    if target_id is None:
        await update.message.reply_text(stylize_text('❌ Please specify a user.'))
        return
    ban_until = time.time() + minutes * 60
    create_user(target_id)
    cursor.execute('UPDATE users SET is_banned = 1, ban_until = ? WHERE user_id = ?', (ban_until, target_id))
    conn.commit()
    # Sync temporary ban to wallet bot
    try:
        wallet_ban_user(target_id, ban_until)
    except Exception:
        pass
    td = timedelta(minutes=minutes)
    await update.message.reply_text(stylize_text(f'⏳ User `{target_id}` timed out for {td}.'), parse_mode=ParseMode.MARKDOWN)

async def untime(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Cancel a user's timeout (clear ban_until and unban if within timeout).
    Admins and owner only."""
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return
    target_id = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
    else:
        if not context.args:
            await update.message.reply_text(stylize_text('📖 Use: `/untime [user_id]` or reply to a user.'), parse_mode=ParseMode.MARKDOWN)
            return
        try:
            target_id = int(context.args[0])
        except ValueError:
            await update.message.reply_text(stylize_text('❌ Invalid user ID.'))
            return
    create_user(target_id)
    cursor.execute('UPDATE users SET is_banned = 0, ban_until = NULL WHERE user_id = ?', (target_id,))
    conn.commit()
    await update.message.reply_text(stylize_text(f'✅ Timeout cancelled for user `{target_id}`.'), parse_mode=ParseMode.MARKDOWN)

async def fixcardcount(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return
    recalculate_card_counts()
    await update.message.reply_text(stylize_text('✅ Card counts recalculated for all users!'))

async def deletecard(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return
    if not context.args:
        await update.message.reply_text(stylize_text('📖 Use: `/deletecard [card_id]`'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found.'))
        return
    card_name = card[1]
    cursor.execute('PRAGMA foreign_keys = OFF')
    try:
        cursor.execute('SELECT card_id FROM cards WHERE card_id > ? ORDER BY card_id', (card_id,))
        higher_cards = cursor.fetchall()
        cursor.execute('DELETE FROM user_cards WHERE card_id = ?', (card_id,))
        cursor.execute('DELETE FROM spawns WHERE card_id = ?', (card_id,))
        cursor.execute('DELETE FROM trades WHERE offer_card_id = ? OR want_card_id = ?', (card_id, card_id))
        cursor.execute('DELETE FROM cards WHERE card_id = ?', (card_id,))
        for higher_id, in higher_cards:
            new_id = higher_id - 1
            cursor.execute('UPDATE cards SET card_id = ? WHERE card_id = ?', (new_id, higher_id))
            cursor.execute('UPDATE user_cards SET card_id = ? WHERE card_id = ?', (new_id, higher_id))
            cursor.execute('UPDATE spawns SET card_id = ? WHERE card_id = ?', (new_id, higher_id))
            cursor.execute('UPDATE trades SET offer_card_id = ? WHERE offer_card_id = ?', (new_id, higher_id))
            cursor.execute('UPDATE trades SET want_card_id = ? WHERE want_card_id = ?', (new_id, higher_id))
        conn.commit()
    finally:
        cursor.execute('PRAGMA foreign_keys = ON')
    recalculate_card_counts()
    await update.message.reply_text(stylize_text(f'🗑️ Card `#{card_id} – {card_name}` deleted! Card IDs have been reorganized.'), parse_mode=ParseMode.MARKDOWN)

async def del_card(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return

async def sell(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not context.args or len(context.args) < 2:
        await update.message.reply_text(stylize_text('📖 Use: `/sell [card_id] [price]`'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
        price = int(context.args[1])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Invalid arguments. Card ID and price must be numbers.'))
        return
    # Check seller owns the card
    uc = get_user_card(user.id, card_id)
    if not uc or uc[2] < 1:
        await update.message.reply_text(stylize_text('❌ You do not own this card.'), parse_mode=ParseMode.MARKDOWN)
        return
    # Ensure user doesn't already have an active sale for this card
    cursor.execute('SELECT sale_id FROM market_sales WHERE seller_id = ? AND card_id = ? AND active = 1', (user.id, card_id))
    if cursor.fetchone():
        await update.message.reply_text(stylize_text('❌ You already have this card on sale.'), parse_mode=ParseMode.MARKDOWN)
        return
    # Reserve one copy: decrement quantity or remove row
    if uc[2] > 1:
        cursor.execute('UPDATE user_cards SET quantity = quantity - 1 WHERE user_id = ? AND card_id = ?', (user.id, card_id))
    else:
        cursor.execute('DELETE FROM user_cards WHERE user_id = ? AND card_id = ?', (user.id, card_id))
    conn.commit()
    # Create sale record
    now = time.time()
    cursor.execute('INSERT INTO market_sales (seller_id, card_id, price, channel_id, channel_message_id, active, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)', (user.id, card_id, price, MARKET_CHANNEL, None, now))
    sale_id = cursor.lastrowid
    conn.commit()
    # Post to market channel
    card = get_card(card_id)
    if not card:
        await update.message.reply_text(stylize_text('❌ Card not found.'), parse_mode=ParseMode.MARKDOWN)
        return
    char_name = card[1]
    anime_name = card[2]
    rank = card[3]
    file_id = card[4]
    file_type = card[5]
    seller_display = f"@{user.username}" if user.username else (user.first_name or 'Seller')
    caption = stylize_text(
        '✨ Character Listed! New Arrival! ✨\n'
        '━━━━━━━━━━━━━━━━━━━━\n'
        f'🎴 Character: {char_name}\n'
        f'🆔 ID:{card_id}\n'
        f'🏷 Series: {anime_name}\n'
        f'{RANK_EMOJI.get(rank, "") if rank else ""} Rarity: {rank.capitalize()}\n'
        f'👤 Seller: {seller_display}\n'
        f'💲 Price: {price} pt'
    )
    keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('Buy', callback_data=f'buy_sale:{sale_id}')]])
    try:
        if file_type == 'photo':
            sent = await context.bot.send_photo(chat_id=MARKET_CHANNEL, photo=file_id, caption=caption, reply_markup=keyboard)
        else:
            sent = await context.bot.send_video(chat_id=MARKET_CHANNEL, video=file_id, caption=caption, reply_markup=keyboard)
        # Update sale with message id
        cursor.execute('UPDATE market_sales SET channel_message_id = ? WHERE sale_id = ?', (sent.message_id, sale_id))
        conn.commit()
    except Exception:
        # If posting failed, refund the card to seller
        cursor.execute('INSERT OR REPLACE INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, COALESCE((SELECT quantity FROM user_cards WHERE user_id = ? AND card_id = ?),0)+1, ?)', (user.id, card_id, user.id, card_id, now))
        cursor.execute('UPDATE market_sales SET active = 0 WHERE sale_id = ?', (sale_id,))
        conn.commit()
        await update.message.reply_text(stylize_text('❌ Failed to post to market channel. Sale cancelled and card returned.'), parse_mode=ParseMode.MARKDOWN)
        return
    await update.message.reply_text(stylize_text(f'✅ Your card has been listed in the market (Sale ID: {sale_id}).'), parse_mode=ParseMode.MARKDOWN)


async def offsell(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not context.args:
        await update.message.reply_text(stylize_text('📖 Use: `/offsell [card_id]`'), parse_mode=ParseMode.MARKDOWN)
        return
    try:
        card_id = int(context.args[0])
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Invalid card ID.'), parse_mode=ParseMode.MARKDOWN)
        return
    cursor.execute('SELECT sale_id, channel_id, channel_message_id FROM market_sales WHERE seller_id = ? AND card_id = ? AND active = 1', (user.id, card_id))
    row = cursor.fetchone()
    if not row:
        await update.message.reply_text(stylize_text('❌ No active sale found for that card.'), parse_mode=ParseMode.MARKDOWN)
        return
    sale_id, channel_id, channel_message_id = row
    # Mark sale inactive and return card to seller
    cursor.execute('UPDATE market_sales SET active = 0 WHERE sale_id = ?', (sale_id,))
    # Return card to user_cards
    now = time.time()
    cursor.execute('INSERT OR REPLACE INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, COALESCE((SELECT quantity FROM user_cards WHERE user_id = ? AND card_id = ?),0)+1, ?)', (user.id, card_id, user.id, card_id, now))
    conn.commit()
    # Delete market message completely
    try:
        if channel_id and channel_message_id:
            await context.bot.delete_message(chat_id=channel_id, message_id=channel_message_id)
    except Exception:
        logger.exception('Failed to delete market message for cancelled sale')
    await update.message.reply_text(stylize_text('✅ Sale removed and card returned to your harem.'), parse_mode=ParseMode.MARKDOWN)


async def market_buy_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    data = query.data
    user = query.from_user
    if not data.startswith('buy_sale:'):
        return
    try:
        sale_id = int(data.split(':')[1])
    except Exception:
        return
    cursor.execute('SELECT seller_id, card_id, price, channel_id, channel_message_id, active FROM market_sales WHERE sale_id = ?', (sale_id,))
    row = cursor.fetchone()
    if not row:
        await query.edit_message_text(stylize_text('❌ Sale not found.'), parse_mode=ParseMode.MARKDOWN)
        return
    seller_id, card_id, price, channel_id, channel_message_id, active = row
    if not active:
        await query.edit_message_text(stylize_text('❌ This sale is no longer active.'), parse_mode=ParseMode.MARKDOWN)
        return
    # Check buyer points
    buyer_points = wallet_get_points(user.id)
    if buyer_points < price:
        await query.answer(stylize_text('❌ You do not have enough points.'), show_alert=True)
        return
    # Perform transfer
    ok = wallet_transfer(user.id, seller_id, price)
    if not ok:
        await query.answer(stylize_text('❌ Transfer failed.'), show_alert=True)
        return
    # Mark sale inactive
    cursor.execute('UPDATE market_sales SET active = 0 WHERE sale_id = ?', (sale_id,))
    conn.commit()
    # Give card to buyer
    now = time.time()
    cursor.execute('INSERT OR REPLACE INTO user_cards (user_id, card_id, quantity, caught_at) VALUES (?, ?, COALESCE((SELECT quantity FROM user_cards WHERE user_id = ? AND card_id = ?),0)+1, ?)', (user.id, card_id, user.id, card_id, now))
    conn.commit()
    # Edit market message to remove buy button and mark SOLD
    try:
        # Ensure we have card details for the caption
        card = get_card(card_id)
        if card:
            char_name = card[1]
            rank = card[3]
        else:
            char_name = 'Unknown'
            rank = 'unknown'
        buyer_display = f"@{user.username}" if user.username else (user.first_name or str(user.id))
        seller_display = f"@{get_user(seller_id)[1]}" if get_user(seller_id) and get_user(seller_id)[1] else (get_user(seller_id)[2] if get_user(seller_id) else str(seller_id))
        caption = stylize_text(
            '✅ SOLD!\n'
            '━━━━━━━━━━━━━━━━━━━━\n'
            f'🎴 Character: {char_name}\n'
            f'🆔 ID:{card_id}\n'
            f'{RANK_EMOJI.get(rank, "") if rank else ""} Rarity: {rank.capitalize()}\n'
            '━━━━━━━━━━━━━━━━━━━━\n'
            f'👤 Buyer: {buyer_display}\n'
            f'👤 Seller: {seller_display}\n'
            f'💲 Price: {price} pt'
        )
        if channel_id and channel_message_id:
            # Prefer the identifiers from the callback message (more reliable),
            # but fall back to stored channel values if needed.
            try:
                target_chat = query.message.chat.id if query and query.message else channel_id
                target_message_id = query.message.message_id if query and query.message else channel_message_id
            except Exception:
                target_chat = channel_id
                target_message_id = channel_message_id
            # First try to remove the inline keyboard, then update caption.
            try:
                await context.bot.edit_message_reply_markup(chat_id=target_chat, message_id=target_message_id, reply_markup=None)
            except Exception:
                logger.exception('Failed to remove reply markup from market message')
            try:
                await context.bot.edit_message_caption(chat_id=target_chat, message_id=target_message_id, caption=caption, reply_markup=None)
            except Exception:
                logger.exception('Failed to edit market message caption')
    except Exception:
        logger.exception('Error while finalizing market purchase')
    # Notify the buyer via callback alert (no public channel message).
    try:
        await query.answer(text=stylize_text(f'🎉 Purchase complete! You bought card `{card_id}` for {price} points.'), show_alert=True)
    except Exception:
        # Fallback: send a private message to the buyer
        try:
            await context.bot.send_message(chat_id=user.id, text=stylize_text(f'🎉 Purchase complete! You bought card `{card_id}` for {price} points.'), parse_mode=ParseMode.MARKDOWN)
        except Exception:
            logger.exception('Failed to notify buyer about purchase')


async def del_card(update: Update, context: ContextTypes.DEFAULT_TYPE):
    user = update.effective_user
    if not (is_admin(user.id) or is_owner(user.id)):
        await update.message.reply_text(stylize_text('❌ Only admins or the owner can use this command.'))
        return

    target_id = None
    card_arg = None
    if update.message.reply_to_message:
        target_id = update.message.reply_to_message.from_user.id
        if not context.args:
            await update.message.reply_text(stylize_text('📖 Use: `/del [card_id]` while replying to a user or `/del @username [card_id]`.'), parse_mode=ParseMode.MARKDOWN)
            return
        card_arg = context.args[0]
    else:
        if len(context.args) < 2:
            await update.message.reply_text(stylize_text('📖 Use: `/del @username [card_id]` or reply to a user with `/del [card_id]`.'), parse_mode=ParseMode.MARKDOWN)
            return
        target_arg = context.args[0]
        card_arg = context.args[1]
        if target_arg.startswith('@') or not target_arg.isdigit():
            target_user = find_user_by_username(target_arg)
            if not target_user:
                await update.message.reply_text(stylize_text('❌ User not found. Make sure the username is correct.'))
                return
            target_id = target_user[0]
        else:
            try:
                target_id = int(target_arg)
            except ValueError:
                await update.message.reply_text(stylize_text('❌ Invalid target identifier.'))
                return
    try:
        card_id = int(card_arg)
    except ValueError:
        await update.message.reply_text(stylize_text('❌ Card ID must be a number.'))
        return
    create_user(target_id)
    user_card = get_user_card(target_id, card_id)
    if not user_card:
        await update.message.reply_text(stylize_text('❌ That player does not have this card in their harem.'))
        return
    quantity = user_card[2]
    if quantity > 1:
        cursor.execute('UPDATE user_cards SET quantity = quantity - 1 WHERE user_id = ? AND card_id = ?', (target_id, card_id))
    else:
        cursor.execute('DELETE FROM user_cards WHERE user_id = ? AND card_id = ?', (target_id, card_id))
    conn.commit()
    sync_user_card_count(target_id)
    refresh_favorite_card(target_id)
    card = get_card(card_id)
    card_name = card[1] if card else 'Unknown'
    await update.message.reply_text(stylize_text(f"✅ Removed one copy of `#{card_id} – {card_name}` from the player's harem."), parse_mode=ParseMode.MARKDOWN)

async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    if chat_id > 0:
        return
    record_chat(chat_id, update.effective_chat.type or '')
    message_date = update.message.date if getattr(update, 'message', None) and getattr(update.message, 'date', None) else datetime.fromtimestamp(time.time())
    if is_prebot_message(chat_id, message_date):
        return
    user = update.effective_user
    # Ignore admins/owner (don't count their messages)
    if is_admin(user.id) or is_owner(user.id):
        logger.debug('Skipping anti-spam for admin/owner %s in chat %s', user.id, chat_id)
        return

    # Don't process messages from currently timed-out/banned users
    if is_banned(user.id):
        return

    # Anti-spam: record timestamp and message id, then check threshold
    now = time.time()
    msg_id = update.message.message_id if getattr(update, 'message', None) else None
    times = user_message_times[chat_id][user.id]
    times.append((now, msg_id))
    # prune old timestamps
    cutoff = now - SPAM_WINDOW_SECONDS
    while times and times[0][0] < cutoff:
        times.pop(0)
    recent = len(times)
    logger.debug('handle_message: chat=%s user=%s msg_id=%s recent=%s total_chat_count=%s', chat_id, user.id, msg_id, recent, chat_message_count.get(chat_id,0))
    if recent >= SPAM_THRESHOLD and not (is_admin(user.id) or is_owner(user.id)):
        # Timeout the user for spamming
        ban_until = time.time() + SPAM_TIMEOUT_MINUTES * 60
        create_user(user.id, user.username or '', user.first_name or '')
        cursor.execute('UPDATE users SET is_banned = 1, ban_until = ? WHERE user_id = ?', (ban_until, user.id))
        conn.commit()
        try:
            wallet_ban_user(user.id, ban_until)
        except Exception:
            pass
        # Announce in chat that the user was spamming and got timed out
        try:
            display = f"@{user.username}" if user.username else (user.first_name or str(user.id))
            await context.bot.send_message(chat_id=chat_id, text=stylize_text(f'🚫 {display} is spamming and has been timed out for {SPAM_TIMEOUT_MINUTES} minutes.'))
        except Exception:
            logger.exception('Failed to announce auto-timeout')
        # Attempt to delete recent spam messages from this user
        try:
            for ts, mid in list(times):
                if mid:
                    try:
                        await context.bot.delete_message(chat_id=chat_id, message_id=mid)
                    except Exception:
                        pass
        except Exception:
            logger.exception('Failed deleting spam messages')
        # Remove recent contributions made by this user from chat counter
        chat_message_count[chat_id] = max(0, chat_message_count.get(chat_id, 0) - recent)
        # clear timestamps
        user_message_times[chat_id][user.id] = []
        return

    # Count message towards spawn trigger
    chat_message_count[chat_id] += 1
    spawn_threshold = spawn_rate_settings[chat_id]
    if chat_message_count[chat_id] >= spawn_threshold:
        chat_message_count[chat_id] = 0
        card_id = spawn_card(chat_id)
        if card_id is None:
            return
        card = get_card(card_id)
        if not card:
            return
        anime, rank, file_id, file_type = (card[2], card[3], card[4], card[5])
        rank_display = f'{rank.capitalize()} {RANK_EMOJI.get(rank, "")}'.strip()
        caption = (
            f'🌟 Look ! A [{rank_display}] card has appeared! 🌟\n\n\n'
            'Guess the character name and use /catch [name] to catch it!\n'
            '─────────────────\n'
            '⚡️ Galaxy Bot'
        )
        # Send without Markdown parsing to avoid entity errors
        if file_type == 'photo':
            await context.bot.send_photo(chat_id, photo=file_id, caption=caption)
        else:
            await context.bot.send_video(chat_id, video=file_id, caption=caption)

async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
    logger.error('Update %s caused error %s', update, context.error)

def main():
    application = Application.builder().token(TOKEN).build()
    application.add_handler(CommandHandler('start', start))
    application.add_handler(CommandHandler('help', help_command))
    application.add_handler(CommandHandler('catch', catch))
    application.add_handler(CommandHandler('harem', harem))
    application.add_handler(CommandHandler('filter', filter_cmd))
    application.add_handler(CommandHandler('trade', trade))
    application.add_handler(CommandHandler('gift', gift))
    application.add_handler(CommandHandler('giftc', giftc))
    application.add_handler(CommandHandler('profile', profile))
    application.add_handler(CommandHandler('leaderboard', leaderboard))
    application.add_handler(CommandHandler('card', card_details))
    application.add_handler(CommandHandler('search', search))
    application.add_handler(CommandHandler('favorite', favorite))
    application.add_handler(CommandHandler('upload', upload))
    application.add_handler(CommandHandler('editcard', editcard))
    application.add_handler(CommandHandler('setadmin', setadmin))
    application.add_handler(CommandHandler('sell', sell))
    application.add_handler(CommandHandler('offsell', offsell))
    application.add_handler(CommandHandler('setuploader', setuploader))
    application.add_handler(CommandHandler('unset', unset))
    application.add_handler(CommandHandler('spawnset', spawnset))
    application.add_handler(CommandHandler('rankset', rankset))
    application.add_handler(CommandHandler('grankset', grankset))
    application.add_handler(CommandHandler('ban', ban))
    application.add_handler(CommandHandler('unban', unban))
    application.add_handler(CommandHandler('timeout', timeout))
    application.add_handler(CommandHandler('untime', untime))
    application.add_handler(CommandHandler('fixcardcount', fixcardcount))
    application.add_handler(CommandHandler('del', del_card))
    application.add_handler(CommandHandler('deletecard', deletecard))
    application.add_handler(CommandHandler('admin', admin_commands))
    application.add_handler(CommandHandler('owner', owner_commands))
    application.add_handler(CallbackQueryHandler(harem_callback, pattern='^harem_'))
    application.add_handler(CallbackQueryHandler(filter_callback, pattern='^filter_'))
    application.add_handler(CallbackQueryHandler(admin_callback, pattern='^admin_'))
    application.add_handler(CallbackQueryHandler(rankset_callback, pattern='^rankset_'))
    application.add_handler(CallbackQueryHandler(grankset_callback, pattern='^grankset_'))
    application.add_handler(CallbackQueryHandler(market_buy_callback, pattern='^buy_sale:'))
    application.add_handler(CallbackQueryHandler(tutorial_callback, pattern='^tutorial_'))
    application.add_handler(CallbackQueryHandler(back_menu_callback, pattern='^back_'))
    application.add_handler(CallbackQueryHandler(trade_callback, pattern='^trade_'))
    application.add_handler(CallbackQueryHandler(gift_callback, pattern='^gift_(accept|decline):'))
    # Listen to all message updates so spam (including commands) is detected
    application.add_handler(MessageHandler(filters.ALL, handle_message))
    application.add_error_handler(error_handler)
    
    # Register player-facing commands for the command menu
    commands = [
        BotCommand('start', 'Welcome to Galaxy Bot'),
        BotCommand('help', 'See all commands'),
        BotCommand('catch', 'Catch a spawned card'),
        BotCommand('harem', 'View your card collection'),
        BotCommand('filter', 'Filter/sort your harem'),
        BotCommand('trade', 'Trade cards with others'),
        BotCommand('gift', 'Gift a card to someone'),
        BotCommand('profile', 'View your personal stats'),
        BotCommand('leaderboard', 'See top collectors'),
        BotCommand('card', 'View card details'),
        BotCommand('search', 'Search cards by name'),
        BotCommand('favorite', 'Set a favourite card'),
        BotCommand('sell', 'List a card on the market'),
        BotCommand('offsell', 'Remove a market listing'),
    ]
    
    # Set up post_init to register commands on startup
    async def post_init(application):
        # Set commands for private chats
        await application.bot.set_my_commands(commands, scope=BotCommandScopeAllPrivateChats())
        # Set commands for group chats
        await application.bot.set_my_commands(commands, scope=BotCommandScopeAllGroupChats())
    
    application.post_init = post_init
    
    logger.info('Galaxy Bot started successfully.')
    application.run_polling()
if __name__ == '__main__':
    main()
