#!/usr/bin/env python3
"""
Telegram bot for video generation from Wildberries URLs.
"""
import json
import os
import sys
import logging
import asyncio
import signal
from pathlib import Path
from datetime import datetime

# Load .env from parent directory (~/dev/.env or ~/prod/.env)
try:
    from dotenv import load_dotenv
    script_dir = Path(__file__).resolve().parent
    env_path = script_dir.parent / ".env"
    if env_path.exists():
        load_dotenv(env_path)
except ImportError:
    pass

from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, BotCommand
from telegram.error import TimedOut, NetworkError
from telegram.ext import (
    Application,
    CommandHandler,
    MessageHandler,
    CallbackQueryHandler,
    ContextTypes,
    PicklePersistence,
    filters,
)

# Use V2 pipeline with multi-stage prompting
from full_pipeline_v2 import run_pipeline_v2, generate_task_id, retry_veo3_only, get_build_info
from wb_to_video_prompts import extract_product_id
from url_detector import detect_url_source, is_supported_url, extract_url
from laozhang_proxy import transcribe_audio_laozhang
from feedback_parser import save_product_feedback
from generation_modes import GENERATION_MODES, DEFAULT_MODE, get_mode_config
from creative_styles import (
    get_style_config, get_kind_config,
    get_style_emoji, get_kind_emoji,
    format_style_for_caption
)

# Payment and token system
from database import (
    init_db, get_or_create_user, init_showcase_db, get_random_showcase_videos,
    get_accepted_terms_version, accept_terms, find_stale_generations,
    find_undelivered_generations, get_user_generations, get_user_generations_count
)
from token_manager import (
    get_balance, has_tokens, is_blocked,
    deduct_token, refund_token, check_and_grant_giveaway,
    check_and_grant_gift_pack, check_and_redeem_gift_pack
)
from database import (
    expire_gift_packs, get_expiring_packs, mark_pack_notified,
    get_user_active_packs,
)
from payment_handlers import get_payment_handlers
import survey_handler

# Logging setup
logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    level=logging.INFO
)
logger = logging.getLogger(__name__)

# Configuration from environment
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")

NFS_BASE = Path(os.environ.get("NFS_BASE", "/mnt/video"))
LAOZHANG_KEY = os.environ.get("LAOZHANG_KEY", "")
POLLO_KEY = os.environ.get("POLLO_KEY", "") or None
PROXY_HOST = os.environ.get("PROXY_HOST", "")
PROXY_PORT = int(os.environ.get("PROXY_PORT", "1080"))
TG_PROXY = os.environ.get("TG_PROXY", "")

# Admin notifications via viewer bot
NOTIFY_BOT_TOKEN = os.environ.get("NOTIFY_BOT_TOKEN", "")
NOTIFY_CHAT_ID = os.environ.get("NOTIFY_CHAT_ID", "")

# User whitelist (comma-separated user IDs)
ALLOWED_USERS_STR = os.environ.get("ALLOWED_USERS", "")
ALLOWED_USERS = set(
    int(uid.strip()) for uid in ALLOWED_USERS_STR.split(",") if uid.strip()
)

# Graceful shutdown state
_draining = False
_active_generations: set = set()  # Set of asyncio.Task objects


def _summarize_error_with_claude(task_dir: str) -> str:
    """Call Claude CLI to summarize what went wrong with a generation."""
    import subprocess
    pipeline_log = Path(task_dir) / "pipeline.log"
    if not pipeline_log.exists():
        return ""
    try:
        result = subprocess.run(
            [
                "/home/videobot/.local/bin/claude",
                "-p", f"Read {pipeline_log} and summarize what went wrong in 2-3 sentences in Russian. "
                      "Focus on: which stage failed, what error, was there a fallback. Be concise.",
                "--max-turns", "5",
            ],
            capture_output=True, text=True, timeout=120,
            cwd=str(Path(task_dir)),
        )
        if result.returncode == 0 and result.stdout.strip():
            return result.stdout.strip()[:4000]
    except Exception as e:
        logger.warning(f"Claude summary failed: {e}")
    return ""


async def notify_admin(error_text: str, task_id: str = "", user_id: int = 0,
                       product_id: str = "", task_dir: str = "", is_send_failure: bool = False):
    """Send error notification to admin via viewer bot."""
    if not NOTIFY_BOT_TOKEN or not NOTIFY_CHAT_ID:
        return
    try:
        import httpx
        build_info = get_build_info()
        env = build_info["source"].upper()

        # Build notification message
        lines = [f"{'📤' if is_send_failure else '❌'} [{env}] Generation error"]
        if user_id:
            lines.append(f"👤 User: {user_id}")
        if product_id:
            lines.append(f"📦 Product: {product_id}")
        if task_id:
            lines.append(f"🔖 Task: {task_id}")
        lines.append(f"\n⚠️ {error_text[:300]}")

        # Get Claude summary in background
        if task_dir:
            loop = asyncio.get_event_loop()
            summary = await loop.run_in_executor(None, _summarize_error_with_claude, task_dir)
            if summary:
                lines.append(f"\n🤖 {summary}")

        text = "\n".join(lines)
        if len(text) > 4090:
            text = text[:4087] + "..."

        for attempt in range(3):
            try:
                async with httpx.AsyncClient() as client:
                    await client.post(
                        f"https://api.telegram.org/bot{NOTIFY_BOT_TOKEN}/sendMessage",
                        json={"chat_id": NOTIFY_CHAT_ID, "text": text},
                        timeout=10,
                    )
                break
            except Exception:
                if attempt < 2:
                    await asyncio.sleep(2)
                    continue
                raise
    except Exception as e:
        logger.warning(f"Failed to send admin notification: {e}")


async def _send_video_via_proxy(chat_id: int, video_path: str, caption: str = "",
                                reply_markup=None, parse_mode: str = None,
                                max_retries: int = 3, retry_delay: int = 5) -> bool:
    """Send video to Telegram via SOCKS5 proxy. Retries on failure."""
    if not TG_PROXY:
        return False
    import httpx
    url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendVideo"
    data = {"chat_id": str(chat_id)}
    if caption:
        data["caption"] = caption
    if parse_mode:
        data["parse_mode"] = parse_mode
    if reply_markup:
        data["reply_markup"] = json.dumps(reply_markup.to_dict())

    for attempt in range(1, max_retries + 1):
        try:
            with open(video_path, "rb") as f:
                files = {"video": (Path(video_path).name, f, "video/mp4")}
                async with httpx.AsyncClient(proxy=TG_PROXY, timeout=600) as client:
                    resp = await client.post(url, data=data, files=files)

            if resp.status_code == 200:
                logger.info(f"Proxy send OK: {video_path}" + (f" (attempt {attempt})" if attempt > 1 else ""))
                return True
            else:
                logger.warning(f"Proxy send failed (attempt {attempt}/{max_retries}): HTTP {resp.status_code} - {resp.text[:200]}")
        except Exception as e:
            logger.warning(f"Proxy send error (attempt {attempt}/{max_retries}): {e}")

        if attempt < max_retries:
            await asyncio.sleep(retry_delay)

    return False


async def _graceful_shutdown(app):
    """Drain active generations before shutting down."""
    global _draining
    if _draining:
        return
    _draining = True

    count = len(_active_generations)
    logger.info(f"Graceful shutdown: {count} active generation(s)")

    if count > 0:
        try:
            await asyncio.wait_for(
                asyncio.gather(*list(_active_generations), return_exceptions=True),
                timeout=900,  # 15 min — generations can take 9-12 min
            )
            logger.info("All generations completed, shutting down")
        except asyncio.TimeoutError:
            logger.warning(f"Timeout, {len(_active_generations)} generation(s) still running")
    else:
        logger.info("No active generations, shutting down immediately")

    app.stop_running()


# Webhook configuration (if set, uses webhooks instead of polling)
# Example: WEBHOOK_URL=https://your-server.example.com/webhook/dev
WEBHOOK_URL = os.environ.get("WEBHOOK_URL", "")
WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8443"))
WEBHOOK_PATH = os.environ.get("WEBHOOK_PATH", "/webhook/dev")

# Terms of Service
# Increment TERMS_VERSION when terms change to require re-acceptance
# v1 is auto-accepted for all users (implicit acceptance)
TERMS_VERSION = 1
TERMS_SHORT = (
    "📋 *Условия:* Вы несёте ответственность за использование видео. "
    "Мы можем использовать видео в портфолио бота. Подробнее: /terms"
)
TERMS_TEXT = """📋 *Условия использования*

*1. Ответственность за использование*
Видео создаётся на основе материалов со страницы товара Wildberries. Используя сгенерированные видео в маркетинге, рекламе или распространяя их иным способом, вы принимаете на себя полную ответственность за соблюдение авторских прав и прав на изображения.

*2. Использование в портфолио*
Мы оставляем за собой право использовать сгенерированные видео в портфолио бота для демонстрации возможностей сервиса.

_Продолжая использование сервиса, вы принимаете эти условия._"""

# Support bot link
SUPPORT_LINK = "🆘 Поддержка: @your\\_support\\_bot"


def needs_terms_acceptance(user_id: int) -> bool:
    """Check if user needs to accept (or re-accept) terms.

    v1 is auto-accepted (implicit). Only future versions require explicit acceptance.
    """
    accepted_version = get_accepted_terms_version(user_id)
    # Auto-accept v1 for everyone
    if TERMS_VERSION == 1:
        return False
    # For v2+, check if user accepted previous version
    return accepted_version < TERMS_VERSION


def is_user_allowed(user_id: int) -> bool:
    """Check if user is in whitelist. Empty whitelist = allow all."""
    if not ALLOWED_USERS:
        return True  # No whitelist configured = allow all
    return user_id in ALLOWED_USERS


def plural_generations(n: int) -> str:
    """Return correct Russian plural form for 'генерация'."""
    if n % 10 == 1 and n % 100 != 11:
        return "генерация"
    elif n % 10 in [2, 3, 4] and n % 100 not in [12, 13, 14]:
        return "генерации"
    else:
        return "генераций"


def plural_days(n: int) -> str:
    """Return correct Russian plural form for 'день'."""
    if n % 10 == 1 and n % 100 != 11:
        return "день"
    elif n % 10 in [2, 3, 4] and n % 100 not in [12, 13, 14]:
        return "дня"
    else:
        return "дней"


# Fun generation status messages
GENERATION_PHRASES = [
    "Генерирую видео",
    "Креативлю",
    "Создаю шедевр",
    "Придумываю сюжет",
    "Колдую над видео",
    "Снимаю кино",
    "Творю магию",
    "Запускаю нейросети",
    "Рисую кадры",
    "Монтирую ролик",
]


def get_generation_phrase() -> str:
    """Get a random fun phrase for generation status."""
    import random
    return random.choice(GENERATION_PHRASES)


# Content policy error patterns (based on real errors from logs)
CONTENT_POLICY_PATTERNS = [
    "PUBLIC_ERROR_SEXUAL",
    "PUBLIC_ERROR_IP_INPUT_IMAGE",
    "PUBLIC_ERROR_PROMINENT_PEOPLE_FILTER_FAILED",  # Famous/historical figures
    "图片中包含未成年人",  # Image contains minors
    "content policy",
    "NSFW",
]


def classify_error(error_text: str) -> tuple[str, str]:
    """
    Classify error into category and return user-friendly message.

    Based on real errors from logs:
    - Content policy: PUBLIC_ERROR_SEXUAL, minors, IP_INPUT_IMAGE
    - Claude API: quota, API failures, Stage 2/3 failures
    - Technical: SSL, timeout, 503, no video URL, etc.

    Returns:
        (category, user_message) where category is 'content_policy', 'claude_api', or 'technical'
    """
    # Check for prominent people filter (specific message)
    if "PROMINENT_PEOPLE_FILTER_FAILED" in error_text:
        return (
            "content_policy",
            "Видео не удалось создать: нельзя использовать известных людей "
            "(политиков, знаменитостей, исторических личностей).\n"
            f"Попробуйте без упоминания конкретных персон.\n\n{SUPPORT_LINK}"
        )

    # Check for other content policy errors
    for pattern in CONTENT_POLICY_PATTERNS:
        if pattern in error_text:
            return (
                "content_policy",
                f"Видео не удалось создать из-за ограничений контента.\n"
                f"Попробуйте другой товар.\n\n{SUPPORT_LINK}"
            )

    # Check for Claude API quota/failures
    if any(pattern in error_text for pattern in [
        "Stage 2 failed: Claude could not generate",
        "Stage 3 failed: Claude could not generate",
        "quota",
        "API quota",
        "check API quota"
    ]):
        return (
            "claude_api",
            f"Временные проблемы с AI-сервисом.\n"
            f"Попробуйте через 1-2 минуты — токен возвращён.\n\n{SUPPORT_LINK}"
        )

    # Everything else is technical error
    return (
        "technical",
        f"К сожалению, произошла техническая ошибка.\n"
        f"Пожалуйста, попробуйте позже.\n\n{SUPPORT_LINK}"
    )


def can_generate(user_id: int) -> tuple[bool, str]:
    """
    Check if user can generate a video.

    Returns:
        (allowed, reason) - allowed is True if user can generate,
        reason is error message if not allowed.
    """
    # Check whitelist first (for dev bot)
    if not is_user_allowed(user_id):
        return False, "whitelist"

    # Check if user is blocked
    if is_blocked(user_id):
        return False, "blocked"

    # Check token balance
    if not has_tokens(user_id, 1):
        return False, "no_tokens"

    return True, ""


def get_product_dir(product_id: str, source: str = None) -> Path:
    """Get product directory path. If source is None, finds existing directory."""
    if source:
        return NFS_BASE / f"{source}_{product_id}"
    # Auto-detect: find existing directory
    for pfx in ("wb", "avito", "ozon"):
        candidate = NFS_BASE / f"{pfx}_{product_id}"
        if candidate.is_dir():
            return candidate
    # Default to wb if nothing exists yet
    return NFS_BASE / f"wb_{product_id}"


def log_activity(product_id: str, user_id: int, action: str, details: str = "", source: str = "wb"):
    """Log activity to product directory."""
    log_path = get_product_dir(product_id, source) / "activity.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"{timestamp} | user:{user_id} | {action} | {details}\n"
    with open(log_path, "a") as f:
        f.write(line)
    logger.info(f"Activity: {product_id} - {action} - {details}")


def get_product_feedback(product_id: str, source: str = "wb") -> dict:
    """
    Parse activity.log for a product and extract user feedback.

    Returns dict with:
        - has_previous: bool - whether there were previous generations
        - generations: list - info about each generation
        - ratings: list - all ratings with comments
        - summary: str - human-readable summary for Claude
    """
    log_path = get_product_dir(product_id, source) / "activity.log"

    if not log_path.exists():
        return {"has_previous": False, "generations": [], "ratings": [], "summary": ""}

    generations = []
    ratings = []
    current_gen = None

    with open(log_path, "r") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue

            try:
                parts = line.split(" | ")
                if len(parts) < 3:
                    continue

                timestamp = parts[0]
                action = parts[2]
                details = parts[3] if len(parts) > 3 else ""

                if action == "START":
                    task_id = parts[3] if len(parts) > 3 else ""
                    current_gen = {"task_id": task_id, "timestamp": timestamp, "rating": None, "comments": []}
                    generations.append(current_gen)

                elif action == "RATE":
                    # Format: timestamp | user | RATE | task_id | rating
                    rating = parts[4] if len(parts) > 4 else ""
                    task_id = parts[3] if len(parts) > 3 else ""
                    if rating:
                        ratings.append({"task_id": task_id, "rating": rating, "comments": []})
                        # Update current_gen if it matches
                        for gen in generations:
                            if gen["task_id"] == task_id:
                                gen["rating"] = rating
                                current_gen = gen
                                break

                elif action == "COMMENT" and ratings:
                    # Format: timestamp | user | COMMENT | task_id | comment text
                    comment = parts[4] if len(parts) > 4 else ""
                    task_id = parts[3] if len(parts) > 3 else ""
                    if comment:
                        # Find matching rating entry
                        for r in ratings:
                            if r["task_id"] == task_id:
                                r["comments"].append(comment)
                                break
                        # Update generation too
                        for gen in generations:
                            if gen["task_id"] == task_id:
                                gen["comments"].append(comment)
                                break

            except Exception as e:
                logger.warning(f"Failed to parse activity line: {line} - {e}")
                continue

    # Build summary for Claude
    summary_parts = []
    if generations:
        summary_parts.append(f"This product has {len(generations)} previous generation(s).")

        for r in ratings:
            rating_str = f"- Generation rated '{r['rating']}'"
            if r["comments"]:
                rating_str += f": {'; '.join(r['comments'])}"
            summary_parts.append(rating_str)

    # Collect metadata.json paths for previous generations
    metadata_files = []
    product_dir = get_product_dir(product_id, source)
    for gen in generations:
        meta_path = product_dir / gen["task_id"] / "metadata.json"
        if meta_path.exists():
            metadata_files.append(str(meta_path))

    return {
        "has_previous": len(generations) > 0,
        "generations": generations,
        "ratings": ratings,
        "metadata_files": metadata_files,
        "summary": "\n".join(summary_parts)
    }


async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /start command."""
    user_id = update.effective_user.id

    if not is_user_allowed(user_id):
        await update.message.reply_text(
            f"Доступ запрещён.\n\nВаш ID: {user_id}\n\n{SUPPORT_LINK}"
        )
        logger.warning(f"Unauthorized access attempt: user_id={user_id}")
        return

    # Extract referral source from deep link (?start=source_tag)
    ref_source = context.args[0] if context.args else None

    # Track if user is new
    from database import get_user
    existing_user = get_user(user_id)
    is_new = existing_user is None

    # Create or update user (ref_source only saved on first create)
    user = get_or_create_user(
        user_id,
        username=update.effective_user.username,
        first_name=update.effective_user.first_name,
        last_name=update.effective_user.last_name,
        ref_source=ref_source,
    )

    # Check and grant giveaway tokens for new users
    giveaway_result = check_and_grant_giveaway(user_id, is_new)

    # Check and grant gift pack for new users / deeplink campaigns
    gift_pack_result = check_and_grant_gift_pack(user_id, is_new, ref_source=ref_source)

    current_mode = context.user_data.get("generation_mode", DEFAULT_MODE)
    mode_config = get_mode_config(current_mode)

    keyboard = [
        [
            InlineKeyboardButton("🎬 Портфолио", callback_data="portfolio"),
            InlineKeyboardButton("💎 Купить", callback_data="buy"),
        ],
    ]

    # Customize welcome message if giveaway tokens or gift pack were granted
    welcome_text = "🎬 *VideoBot — AI-видео для карточек Wildberries*\n\n"

    if gift_pack_result.get("granted"):
        welcome_text = (
            f"🎁 *Добро пожаловать!*\n\n"
            f"Вам подарок! *{gift_pack_result['tokens']} бесплатных {plural_generations(gift_pack_result['tokens'])}* на "
            f"*{gift_pack_result['expiration_days']} {plural_days(gift_pack_result['expiration_days'])}*.\n"
            f"Отправьте ссылку на товар WB, чтобы попробовать!\n\n"
            "🎬 *VideoBot — AI-видео для карточек Wildberries*\n\n"
        )
    elif giveaway_result["granted"]:
        welcome_text = (
            f"🎁 *Добро пожаловать!*\n\n"
            f"Вы получили *{giveaway_result['tokens']} бесплатных {plural_generations(giveaway_result['tokens'])}*!\n\n"
            "🎬 *VideoBot — AI-видео для карточек Wildberries*\n\n"
        )

    welcome_text += (
        "*Что это*\n"
        "Бот создаёт промо-видео для товаров WB за 3-5 минут. "
        "Отправьте ссылку — получите готовый ролик.\n\n"
        "*Возможности*\n"
        "• Генерация 8 секундного видео по ссылке на товар\n"
        "• Корректировка по вашим пожеланиям (текст/голос)\n"
        "• Формат 3:4 — сразу в карточку WB\n\n"
        "*Результат*\n"
        "• Рост конверсии карточки на 15-40%\n"
        "• Экономия 5-15 тысяч рублей на каждом ролике\n"
        "• Уникальный контент, не шаблоны\n\n"
        f"{TERMS_SHORT}\n\n"
        f"{SUPPORT_LINK}\n\n"
        "👇 Отправьте ссылку на товар WB чтобы начать"
    )

    await update.message.reply_text(
        welcome_text,
        reply_markup=InlineKeyboardMarkup(keyboard),
        parse_mode="Markdown"
    )


async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /status command."""
    current_task = context.user_data.get("current_task")
    if current_task:
        await update.message.reply_text(
            f"Current generation:\n"
            f"Product: {current_task.get('product_id', 'unknown')}\n"
            f"Task: {current_task.get('task_id', 'unknown')}\n"
            f"Status: In progress..."
        )
    else:
        await update.message.reply_text("No active generation. Send a WB URL to start.")


async def skip_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /skip command - skip feedback and auto-regenerate."""
    pending = context.user_data.pop("pending_comment", None)
    if pending:
        # Auto-regenerate
        url = pending.get("url")
        if url:
            await update.message.reply_text("⏳ Генерирую новое видео...")
            chat_id = update.effective_chat.id
            await run_video_pipeline(context, chat_id, url)
        else:
            await update.message.reply_text("Не могу найти ссылку. Отправьте URL товара заново.")
    else:
        await update.message.reply_text("Нечего пропускать. Отправьте ссылку на товар WB.")


async def handle_skip_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle 'skip' callback button - skip feedback and auto-regenerate."""
    query = update.callback_query
    await query.answer()

    pending = context.user_data.pop("pending_comment", None)
    if pending:
        url = pending.get("url")
        if url:
            # Edit the message to show we're proceeding
            await query.edit_message_text("⏳ Генерирую новое видео...")
            chat_id = query.message.chat_id
            await run_video_pipeline(context, chat_id, url)
        else:
            await query.edit_message_text("Не могу найти ссылку. Отправьте URL товара заново.")
    else:
        await query.edit_message_text("Нечего пропускать. Отправьте ссылку на товар WB.")


async def mode_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /mode command - show current mode and menu to change."""
    user_id = update.effective_user.id

    if not is_user_allowed(user_id):
        await update.message.reply_text("Доступ запрещён.")
        return

    current_mode = context.user_data.get("generation_mode", DEFAULT_MODE)
    mode_config = get_mode_config(current_mode)

    keyboard = [
        [InlineKeyboardButton("📱 Карточка WB (3:4)", callback_data="setmode:wbcard")],
        [InlineKeyboardButton("📲 Вертикаль (9:16)", callback_data="setmode:vertical")],
        [InlineKeyboardButton("🎬 Кино (16:9)", callback_data="setmode:cinematic")],
    ]

    await update.message.reply_text(
        f"Текущий формат: {mode_config['name']}\n\n"
        "Выберите формат видео:",
        reply_markup=InlineKeyboardMarkup(keyboard)
    )


async def terms_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /terms command - show terms of service."""
    user_id = update.effective_user.id

    # Check if user has accepted current terms
    accepted_version = get_accepted_terms_version(user_id)

    if accepted_version >= TERMS_VERSION:
        status = f"\n\n✅ Вы приняли условия (версия {accepted_version})"
        keyboard = None
    else:
        if accepted_version > 0:
            status = f"\n\n⚠️ Условия обновились. Вы приняли версию {accepted_version}, текущая версия {TERMS_VERSION}."
        else:
            status = "\n\n⚠️ Вы ещё не приняли условия использования."
        keyboard = InlineKeyboardMarkup([[
            InlineKeyboardButton("✅ Принимаю условия", callback_data="accept_terms")
        ]])

    await update.message.reply_text(
        TERMS_TEXT + status,
        parse_mode="Markdown",
        reply_markup=keyboard
    )


async def handle_accept_terms(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle terms acceptance callback."""
    query = update.callback_query
    user = query.from_user
    user_id = user.id

    # Track if user is new
    from database import get_user
    existing_user = get_user(user_id)
    is_new = existing_user is None

    # Ensure user exists in database before recording acceptance
    get_or_create_user(
        user_id,
        username=user.username,
        first_name=user.first_name,
        last_name=user.last_name
    )

    # Check and grant giveaway tokens silently (no message here)
    check_and_grant_giveaway(user_id, is_new)

    # Check and grant gift pack silently
    check_and_grant_gift_pack(user_id, is_new)

    # Record acceptance
    accept_terms(user_id, TERMS_VERSION)

    await query.answer("✅ Условия приняты!")
    await query.edit_message_text(
        TERMS_TEXT + f"\n\n✅ Вы приняли условия (версия {TERMS_VERSION})",
        parse_mode="Markdown"
    )

    # Check if there's a pending action (URL or buy)
    pending_url = context.user_data.pop("pending_terms_url", None)
    if pending_url:
        await query.message.reply_text("Продолжаем генерацию...")
        await run_video_pipeline(context, query.message.chat_id, pending_url, user_id)


async def handle_set_mode(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle mode selection callback."""
    query = update.callback_query
    await query.answer()

    try:
        _, mode = query.data.split(":")
    except ValueError:
        return

    if mode not in GENERATION_MODES:
        return

    context.user_data["generation_mode"] = mode
    mode_config = get_mode_config(mode)

    await query.edit_message_text(
        f"✅ Формат: {mode_config['name']}\n\n"
        f"Соотношение сторон: {mode_config['aspect_ratio']}\n"
        "Отправьте ссылку на товар WB."
    )


async def handle_portfolio_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle portfolio button callback."""
    query = update.callback_query
    await query.answer()
    # Create a fake message object to reuse portfolio_command logic
    await query.message.reply_text("📺 Загружаю примеры работ...")
    videos = get_random_showcase_videos(count=5, exclude_artifacts=True)
    if not videos:
        await query.message.reply_text(
            "Примеры пока не добавлены.\n\n"
            "Отправьте ссылку на товар Wildberries для генерации своего видео."
        )
        return
    sent = 0
    for video in videos:
        video_path = Path(video["video_path"])
        if video_path.exists():
            try:
                if TG_PROXY:
                    ok = await _send_video_via_proxy(query.message.chat_id, str(video_path))
                    if not ok:
                        raise Exception("Proxy send failed")
                else:
                    with open(video_path, "rb") as f:
                        await query.message.reply_video(video=f, read_timeout=300, write_timeout=300)
                sent += 1
            except Exception as e:
                logger.warning(f"Failed to send showcase video: {e}")
    await query.message.reply_text("Отправьте ссылку на товар WB для генерации своего видео.")


async def handle_buy_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle buy button callback."""
    query = update.callback_query
    await query.answer()
    from payment_handlers import buy_command
    await buy_command(update, context)


async def portfolio_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /portfolio command - show random example videos."""
    user_id = update.effective_user.id

    # No whitelist check - portfolio is available to everyone
    await update.message.reply_text("📺 Загружаю примеры работ...")

    # Get 5 random showcase videos (excluding those with artifacts)
    videos = get_random_showcase_videos(count=5, exclude_artifacts=True)

    if not videos:
        await update.message.reply_text(
            "Примеры пока не добавлены.\n\n"
            "Отправьте ссылку на товар Wildberries для генерации своего видео."
        )
        return

    # Send each video
    sent_count = 0
    for video in videos:
        video_path = video.get("video_path")
        if not video_path or not os.path.exists(video_path):
            continue

        # Build caption with rating stars and product name
        rating = video.get("rating", 0)
        stars = "⭐" * rating if rating > 0 else ""

        # Try to get product name from metadata.json
        product_name = None
        try:
            # video_path: /mnt/video/wb_XXX/task_id/videos/video.mp4
            # metadata.json: /mnt/video/wb_XXX/task_id/metadata.json
            gen_json_path = Path(video_path).parent.parent / "metadata.json"
            if gen_json_path.exists():
                with open(gen_json_path, "r", encoding="utf-8") as f:
                    gen_data = json.load(f)
                    product_name = gen_data.get("product_name")
        except Exception:
            pass

        caption = "🎬 Пример генерации"
        if stars:
            caption += f" {stars}"
        if product_name:
            caption += f"\n📦 {product_name}"

        try:
            file_size_mb = os.path.getsize(video_path) / (1024 * 1024)
            logger.info(f"Sending portfolio video: {video_path} ({file_size_mb:.1f}MB)")
            if TG_PROXY:
                ok = await _send_video_via_proxy(update.effective_chat.id, video_path, caption=caption)
                if not ok:
                    raise Exception("Proxy send failed")
            else:
                with open(video_path, "rb") as video_file:
                    await context.bot.send_video(
                        update.effective_chat.id,
                        video=video_file,
                        caption=caption,
                        connect_timeout=60,
                        read_timeout=300,
                        write_timeout=300,
                    )
            logger.info(f"Portfolio video sent OK: {video_path}")
            sent_count += 1
        except Exception as e:
            logger.warning(f"Failed to send portfolio video {video_path} ({file_size_mb:.1f}MB): {e}")

    if sent_count > 0:
        await update.message.reply_text(
            f"Показано {sent_count} примеров.\n\n"
            "/portfolio — ещё 5 случайных примеров\n"
            "Отправьте ссылку на товар WB для генерации своего видео."
        )
    else:
        await update.message.reply_text(
            "Не удалось загрузить примеры.\n\n"
            "Отправьте ссылку на товар Wildberries для генерации своего видео."
        )


async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle /history command - show user's generation history."""
    user_id = update.effective_user.id

    # Get page from args
    page = 1
    if context.args:
        try:
            page = int(context.args[0])
        except ValueError:
            pass

    limit = 5
    offset = (page - 1) * limit

    # Get user's generations
    generations = get_user_generations(user_id, limit=limit, offset=offset)
    total = get_user_generations_count(user_id)
    total_pages = (total + limit - 1) // limit if total > 0 else 1

    if not generations:
        await update.message.reply_text(
            "У вас пока нет генераций.\n\n"
            "Отправьте ссылку на товар Wildberries для создания видео."
        )
        return

    text = f"📜 *Ваши генерации* (стр. {page}/{total_pages})\n\n"

    keyboard = []
    for gen in generations:
        task_id = gen.get("task_id", "?")
        product_id = gen.get("product_id", "?")
        has_video = gen.get("has_video", False)

        # Status icon
        status = "✅" if has_video else "❌"

        # Try to get catchy name and product name from metadata.json
        catchy_name = None
        product_name = None
        if task_id and product_id and product_id != "?":
            try:
                gen_json_path = get_product_dir(product_id) / task_id / "metadata.json"
                if gen_json_path.exists():
                    with open(gen_json_path, "r", encoding="utf-8") as f:
                        gen_data = json.load(f)
                        # Try catchy_name from story_vision first
                        story_vision = gen_data.get("story_vision", {})
                        if isinstance(story_vision, dict):
                            catchy_name = story_vision.get("catchy_name")
                        # Fallback to product_name
                        if gen_data.get("product_name"):
                            product_name = gen_data["product_name"][:40]
                            if len(gen_data.get("product_name", "")) > 40:
                                product_name += "..."
            except Exception:
                pass

        # Display: "Catchy name — Product name" or just product name or product id
        if catchy_name and product_name:
            display_name = f"{catchy_name} — {product_name}"
        else:
            display_name = catchy_name or product_name or f"Товар {product_id}"

        text += f"{status} {display_name}\n"

        # Button: catchy name or product name
        if has_video:
            btn_name = catchy_name or product_name or f"Товар {product_id}"
            keyboard.append([
                InlineKeyboardButton(
                    f"📥 {btn_name[:20]}",
                    callback_data=f"resend:{task_id}"
                )
            ])

    # Navigation buttons
    nav_row = []
    if page > 1:
        nav_row.append(InlineKeyboardButton("⬅️", callback_data=f"history_page:{page-1}"))
    if total_pages > 1:
        nav_row.append(InlineKeyboardButton(f"{page}/{total_pages}", callback_data="noop"))
    if page < total_pages:
        nav_row.append(InlineKeyboardButton("➡️", callback_data=f"history_page:{page+1}"))
    if nav_row:
        keyboard.append(nav_row)

    await update.message.reply_text(
        text,
        parse_mode="Markdown",
        reply_markup=InlineKeyboardMarkup(keyboard) if keyboard else None
    )


async def handle_history_page(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle history pagination callback."""
    query = update.callback_query
    user_id = query.from_user.id

    page = int(query.data.split(":")[1])
    limit = 5
    offset = (page - 1) * limit

    generations = get_user_generations(user_id, limit=limit, offset=offset)
    total = get_user_generations_count(user_id)
    total_pages = (total + limit - 1) // limit if total > 0 else 1

    text = f"📜 *Ваши генерации* (стр. {page}/{total_pages})\n\n"

    keyboard = []
    for gen in generations:
        task_id = gen.get("task_id", "?")
        product_id = gen.get("product_id", "?")
        has_video = gen.get("has_video", False)

        status = "✅" if has_video else "❌"

        # Try to get catchy name and product name from metadata.json
        catchy_name = None
        product_name = None
        if task_id and product_id and product_id != "?":
            try:
                gen_json_path = get_product_dir(product_id) / task_id / "metadata.json"
                if gen_json_path.exists():
                    with open(gen_json_path, "r", encoding="utf-8") as f:
                        gen_data = json.load(f)
                        # Try catchy_name from story_vision first
                        story_vision = gen_data.get("story_vision", {})
                        if isinstance(story_vision, dict):
                            catchy_name = story_vision.get("catchy_name")
                        # Fallback to product_name
                        if gen_data.get("product_name"):
                            product_name = gen_data["product_name"][:40]
                            if len(gen_data.get("product_name", "")) > 40:
                                product_name += "..."
            except Exception:
                pass

        # Display: "Catchy name — Product name" or just product name or product id
        if catchy_name and product_name:
            display_name = f"{catchy_name} — {product_name}"
        else:
            display_name = catchy_name or product_name or f"Товар {product_id}"

        text += f"{status} {display_name}\n"

        if has_video:
            btn_name = catchy_name or product_name or f"Товар {product_id}"
            keyboard.append([
                InlineKeyboardButton(
                    f"📥 {btn_name[:20]}",
                    callback_data=f"resend:{task_id}"
                )
            ])

    nav_row = []
    if page > 1:
        nav_row.append(InlineKeyboardButton("⬅️", callback_data=f"history_page:{page-1}"))
    if total_pages > 1:
        nav_row.append(InlineKeyboardButton(f"{page}/{total_pages}", callback_data="noop"))
    if page < total_pages:
        nav_row.append(InlineKeyboardButton("➡️", callback_data=f"history_page:{page+1}"))
    if nav_row:
        keyboard.append(nav_row)

    await query.message.edit_text(
        text,
        parse_mode="Markdown",
        reply_markup=InlineKeyboardMarkup(keyboard) if keyboard else None
    )
    await query.answer()


async def handle_resend_video(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle video resend callback - send video to user."""
    query = update.callback_query
    user_id = query.from_user.id
    task_id = query.data.split(":")[1]

    await query.answer("📥 Загружаю видео...")

    # Verify this is user's own video by checking transactions
    generations = get_user_generations(user_id, limit=100)
    user_task_ids = [g.get("task_id") for g in generations]

    if task_id not in user_task_ids:
        await query.message.reply_text("❌ Видео не найдено или недоступно.")
        return

    # Find the video
    video_gen = next((g for g in generations if g.get("task_id") == task_id), None)
    if not video_gen or not video_gen.get("has_video") or not video_gen.get("video_path"):
        await query.message.reply_text("❌ Видео не найдено.")
        return

    video_path = video_gen["video_path"]

    # Get product name for caption
    caption = f"🎬 {task_id}"
    try:
        gen_json_path = Path(video_path).parent.parent / "metadata.json"
        if gen_json_path.exists():
            with open(gen_json_path, "r", encoding="utf-8") as f:
                gen_data = json.load(f)
                if gen_data.get("product_name"):
                    caption = f"🎬 {gen_data['product_name']}"
    except Exception:
        pass

    # Send video
    try:
        if TG_PROXY:
            ok = await _send_video_via_proxy(query.message.chat_id, str(video_path), caption=caption)
            if not ok:
                raise Exception("Proxy send failed")
        else:
            with open(video_path, "rb") as video_file:
                await context.bot.send_video(
                    query.message.chat_id,
                    video=video_file,
                    caption=caption,
                    supports_streaming=True,
                    read_timeout=300,
                    write_timeout=300,
                )
    except Exception as e:
        logger.error(f"Failed to resend video {task_id}: {e}")
        await query.message.reply_text(f"❌ Не удалось отправить видео: {e}")
        await notify_admin(str(e), task_id=task_id, user_id=user_id,
                           is_send_failure=True)


async def run_video_pipeline(context: ContextTypes.DEFAULT_TYPE, chat_id: int, url: str, user_id: int = None):
    """
    Core video pipeline logic. Used by both handle_url and callback handlers.

    Args:
        context: Telegram context
        chat_id: Chat ID to send messages to
        url: Wildberries product URL
        user_id: Optional user ID for logging (defaults to chat_id)
    """
    if user_id is None:
        user_id = chat_id

    # Check if user has accepted current terms
    if needs_terms_acceptance(user_id):
        context.user_data["pending_terms_url"] = url
        keyboard = InlineKeyboardMarkup([[
            InlineKeyboardButton("✅ Принимаю условия", callback_data="accept_terms")
        ]])
        await context.bot.send_message(
            chat_id,
            TERMS_TEXT + "\n\n⚠️ Для продолжения примите условия использования.",
            parse_mode="Markdown",
            reply_markup=keyboard
        )
        return

    # Extract product ID (supports WB, Avito, Ozon)
    url_source, product_id = detect_url_source(url)
    if not url_source or not product_id:
        # Fallback to WB-only extraction
        product_id = extract_product_id(url)
        url_source = "wb" if product_id else None

    if not product_id:
        await context.bot.send_message(
            chat_id,
            "Не удалось определить ID товара из ссылки.\n"
            "Отправьте ссылку на товар Wildberries или Ozon."
        )
        return

    # Check if already processing
    if context.user_data.get("current_task"):
        await context.bot.send_message(
            chat_id,
            "⏳ Видео уже генерируется. Пожалуйста, дождитесь завершения."
        )
        return

    # Generate task ID and store state
    task_id = generate_task_id()
    context.user_data["current_task"] = {
        "product_id": product_id,
        "task_id": task_id,
        "url": url,
        "source": url_source
    }

    # Auto-unpack gift pack before deducting (gift tokens used before purchased)
    pack_result = check_and_redeem_gift_pack(user_id)
    if pack_result["redeemed"]:
        await context.bot.send_message(
            chat_id,
            f"🎁 Подарок активирован! +{pack_result['tokens']} {plural_generations(pack_result['tokens'])} на балансе"
        )

    # Deduct token before starting generation
    txn_id = deduct_token(user_id, task_id, product_id)
    if txn_id is None:
        context.user_data.pop("current_task", None)
        # Save pending generation for retry after purchase
        context.user_data["pending_generation"] = {
            "url": url,
            "product_id": product_id,
        }
        await context.bot.send_message(
            chat_id,
            f"❌ Недостаточно генераций.\n\n"
            f"📺 /portfolio — посмотреть примеры работ\n"
            f"💳 /buy — купить генерации\n\n{SUPPORT_LINK}"
        )
        return

    context.user_data["current_task"]["txn_id"] = txn_id

    # Reject if bot is draining (shutting down)
    if _draining:
        refund_token(user_id, task_id, "Bot is restarting")
        balance = get_balance(user_id)
        await context.bot.send_message(
            chat_id,
            "⏳ Бот обновляется. Попробуйте через несколько минут.\n"
            f"↩️ Генерация возвращена (баланс: {balance})"
        )
        context.user_data.pop("current_task", None)
        return

    balance = get_balance(user_id)

    # Get user's generation mode
    generation_mode = context.user_data.get("generation_mode", DEFAULT_MODE)
    mode_config = get_mode_config(generation_mode)

    current_task = asyncio.current_task()
    if current_task:
        _active_generations.add(current_task)

    try:
        await context.bot.send_message(
            chat_id,
            f"🎬 {get_generation_phrase()}...\n"
            f"Это займёт несколько минут.\n\n"
            f"💰 Списана 1 генерация (осталось: {balance})"
        )

        log_activity(product_id, user_id, "START", f"{task_id} | mode:{generation_mode}", source=url_source)
        # Run V2 pipeline in executor to not block
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(
            None,
            lambda: run_pipeline_v2(
                product_url=url,
                laozhang_key=LAOZHANG_KEY,
                output_base=str(NFS_BASE),
                task_id=task_id,
                mode=generation_mode,
                pollo_key=POLLO_KEY,
            )
        )

        # Find generated video
        output_dir = get_product_dir(product_id, url_source) / task_id
        video_path = output_dir / "videos" / "video.mp4"

        if video_path.exists():
            log_activity(product_id, user_id, "DONE", f"{task_id} | {video_path.name}", source=url_source)

            # Store last URL for quick regenerate
            context.user_data["last_url"] = url
            context.user_data["last_product_id"] = product_id
            context.user_data["last_task_id"] = task_id
            context.user_data["last_source"] = url_source

            # Get generation details from result
            story_vision = result.get("story_vision", {})

            # Get catchy name and hints from story vision
            catchy_name = story_vision.get("catchy_name", "Видео")
            hint_dig_in = story_vision.get("hint_dig_in", "")
            hint_explore = story_vision.get("hint_explore", "")

            # Build caption with hints as examples (italic)
            caption = f"🎬 «{catchy_name}»\n\n"
            caption += "✏️ Поправить"
            if hint_dig_in:
                caption += f" — _например, {hint_dig_in.lower()}_"
            caption += "\n"
            caption += "🔄 Другой вариант"
            if hint_explore:
                caption += f" — _например, {hint_explore.lower()}_"
            caption += "\n\n"
            # caption += "💬 Можете уточнить пожелания или сообщить об артефактах\n"
            caption += "🔗 Или отправьте новую ссылку WB для другого товара"

            # Russian direction buttons (internally stored as good/bad for compatibility)
            keyboard = [
                [
                    InlineKeyboardButton(
                        "✏️ Поправить", callback_data=f"rate:{product_id}:{task_id}:good"
                    ),
                    InlineKeyboardButton(
                        "🔄 Другой вариант", callback_data=f"rate:{product_id}:{task_id}:bad"
                    ),
                ],
                # Optional star rating row - DISABLED (confusing and clunky)
                # [
                #     InlineKeyboardButton("☆", callback_data=f"stars:{product_id}:{task_id}:1"),
                #     InlineKeyboardButton("☆", callback_data=f"stars:{product_id}:{task_id}:2"),
                #     InlineKeyboardButton("☆", callback_data=f"stars:{product_id}:{task_id}:3"),
                #     InlineKeyboardButton("☆", callback_data=f"stars:{product_id}:{task_id}:4"),
                #     InlineKeyboardButton("☆", callback_data=f"stars:{product_id}:{task_id}:5"),
                # ],
            ]
            reply_markup = InlineKeyboardMarkup(keyboard)

            # Send video to Telegram with timing
            import time as time_module
            file_size_mb = video_path.stat().st_size / (1024 * 1024)
            send_start = time_module.time()
            if TG_PROXY:
                ok = await _send_video_via_proxy(
                    chat_id, str(video_path), caption=caption,
                    reply_markup=reply_markup, parse_mode="Markdown")
                if not ok:
                    raise Exception("Proxy send failed")
            else:
                with open(video_path, "rb") as video_file:
                    await context.bot.send_video(
                        chat_id,
                        video=video_file,
                        caption=caption,
                        reply_markup=reply_markup,
                        parse_mode="Markdown",
                        read_timeout=300,
                        write_timeout=300,
                    )
            send_time = time_module.time() - send_start
            logger.info(f"Telegram send: {file_size_mb:.1f}MB in {send_time:.1f}s (task {task_id})")

            # Mark as delivered (for undelivered recovery on restart)
            try:
                (output_dir / ".delivered").write_text(datetime.now().isoformat())
            except Exception:
                pass

            # Notify admin if fallback providers were used
            fallback_providers = result.get("fallback_providers", [])
            if fallback_providers:
                fallback_text = ", ".join(fallback_providers)
                try:
                    import httpx
                    build_info = get_build_info()
                    env = build_info["source"].upper()
                    warn_text = (
                        f"⚠️ [{env}] Fallback providers used\n"
                        f"📦 Product: {product_id}\n"
                        f"🔖 Task: {task_id}\n\n"
                        f"🔀 {fallback_text}"
                    )
                    for _attempt in range(3):
                        try:
                            async with httpx.AsyncClient() as client:
                                await client.post(
                                    f"https://api.telegram.org/bot{NOTIFY_BOT_TOKEN}/sendMessage",
                                    json={"chat_id": NOTIFY_CHAT_ID, "text": warn_text},
                                    timeout=10,
                                )
                            break
                        except Exception:
                            if _attempt < 2:
                                await asyncio.sleep(2)
                except Exception:
                    pass
        else:
            errors = result.get("errors", [])
            error_text = "\n".join(errors) if errors else "Unknown error"
            log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text}", source=url_source)

            # Classify error and get user-friendly message
            error_category, user_message = classify_error(error_text)

            # Refund token on generation failure
            refund_token(user_id, task_id, f"Generation failed: {error_text[:100]}")
            balance = get_balance(user_id)

            await notify_admin(error_text, task_id=task_id, user_id=user_id,
                               product_id=product_id, task_dir=str(output_dir))

            # Store last URL and task info for retry
            context.user_data["last_url"] = url
            context.user_data["last_product_id"] = product_id
            context.user_data["last_task_id"] = task_id

            # Show error with single retry button (starts at retry 0)
            keyboard = [
                [
                    InlineKeyboardButton(
                        "🔄 Повторить", callback_data=f"smartretry:{product_id}:{task_id}:0"
                    ),
                ],
            ]
            reply_markup = InlineKeyboardMarkup(keyboard)

            await context.bot.send_message(
                chat_id,
                f"❌ {user_message}\n\n"
                f"↩️ Генерация возвращена (баланс: {balance})\n"
                f"📎 ID: `{task_id}`",
                reply_markup=reply_markup,
                parse_mode="Markdown"
            )

    except Exception as e:
        logger.exception("Pipeline error")
        error_text = str(e)
        log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text}", source=url_source)

        # Classify error and get user-friendly message
        error_category, user_message = classify_error(error_text)

        # Refund token on exception
        refund_token(user_id, task_id, f"Exception: {error_text[:100]}")
        balance = get_balance(user_id)

        output_dir = get_product_dir(product_id, url_source) / task_id
        is_send = "Timed out" in error_text or "send_video" in error_text
        await notify_admin(error_text, task_id=task_id, user_id=user_id,
                           product_id=product_id, task_dir=str(output_dir),
                           is_send_failure=is_send)

        # Store last URL and task info for retry
        context.user_data["last_url"] = url
        context.user_data["last_product_id"] = product_id
        context.user_data["last_task_id"] = task_id

        # Show error with single retry button (starts at retry 0)
        keyboard = [
            [
                InlineKeyboardButton(
                    "🔄 Повторить", callback_data=f"smartretry:{product_id}:{task_id}:0"
                ),
            ],
        ]
        reply_markup = InlineKeyboardMarkup(keyboard)

        await context.bot.send_message(
            chat_id,
            f"❌ {user_message}\n\n"
            f"↩️ Генерация возвращена (баланс: {balance})\n"
            f"📎 ID: `{task_id}`",
            reply_markup=reply_markup,
            parse_mode="Markdown"
        )

    finally:
        if current_task:
            _active_generations.discard(current_task)
        context.user_data.pop("current_task", None)


async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle Wildberries URL from user."""
    # Extract URL from message (handles cases where user pastes product name + URL)
    raw_text = update.message.text.strip()
    url = extract_url(raw_text) or raw_text
    user = update.effective_user
    user_id = user.id

    # Check whitelist
    if not is_user_allowed(user_id):
        await update.message.reply_text(
            f"Доступ запрещён.\n\nВаш ID: {user_id}\n\n{SUPPORT_LINK}"
        )
        logger.warning(f"Unauthorized access attempt: user_id={user_id}")
        return

    # Track if user is new
    from database import get_user
    existing_user = get_user(user_id)
    is_new = existing_user is None

    # Ensure user exists in database
    get_or_create_user(
        user_id,
        username=user.username,
        first_name=user.first_name,
        last_name=user.last_name
    )

    # Check and grant giveaway tokens silently
    check_and_grant_giveaway(user_id, is_new)

    # Check and grant gift pack silently (ref_source from user record)
    from database import get_user as db_get_user
    user_record = db_get_user(user_id)
    check_and_grant_gift_pack(user_id, is_new, ref_source=user_record.get('ref_source') if user_record else None)

    # Run the pipeline
    chat_id = update.message.chat_id
    await run_video_pipeline(context, chat_id, url, user_id)


async def handle_product_id(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle numeric product ID from user - construct WB URL."""
    product_id = update.message.text.strip()
    user = update.effective_user
    user_id = user.id

    # Check whitelist
    if not is_user_allowed(user_id):
        await update.message.reply_text(
            f"Доступ запрещён.\n\nВаш ID: {user_id}\n\n{SUPPORT_LINK}"
        )
        logger.warning(f"Unauthorized access attempt: user_id={user_id}")
        return

    # Track if user is new
    from database import get_user
    existing_user = get_user(user_id)
    is_new = existing_user is None

    # Ensure user exists in database
    get_or_create_user(
        user_id,
        username=user.username,
        first_name=user.first_name,
        last_name=user.last_name
    )

    # Check and grant giveaway tokens silently
    check_and_grant_giveaway(user_id, is_new)

    # Construct WB URL
    url = f"https://www.wildberries.ru/catalog/{product_id}/detail.aspx"

    # Show user what we're trying
    await update.message.reply_text(f"🔗 {url}")

    # Run the pipeline
    chat_id = update.message.chat_id
    await run_video_pipeline(context, chat_id, url, user_id)


async def handle_rating(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle rating callback."""
    query = update.callback_query
    await query.answer()

    try:
        _, product_id, task_id, rating = query.data.split(":")
    except ValueError:
        return

    user_id = update.effective_user.id
    source = context.user_data.get("last_source", "wb")
    log_activity(product_id, user_id, "RATE", f"{task_id} | {rating}", source=source)

    # Update product feedback JSON for v2 pipeline
    product_dir = get_product_dir(product_id, source)
    try:
        save_product_feedback(str(product_dir))
    except Exception as e:
        logger.warning(f"Failed to save product feedback: {e}")

    # Store for potential comment (include URL for regenerate)
    context.user_data["pending_comment"] = {
        "product_id": product_id,
        "task_id": task_id,
        "rating": rating,
        "url": context.user_data.get("last_url"),
        "source": source,
    }

    # Update original message - mark selection and remove buttons
    direction_text = "✏️ Поправить" if rating == "good" else "🔄 Другой вариант"
    original_caption = query.message.caption or ""

    # Add selection marker to caption
    if "Что дальше?" in original_caption:
        new_caption = original_caption.replace("Что дальше?", f"✅ {direction_text}")
    elif "What's next?" in original_caption:
        new_caption = original_caption.replace("What's next?", f"✅ {direction_text}")
    else:
        new_caption = f"{original_caption}\n\n✅ {direction_text}"

    await query.edit_message_caption(caption=new_caption, reply_markup=None)

    # Send NEW message with instructions and "без комментариев" button
    skip_keyboard = InlineKeyboardMarkup([
        [InlineKeyboardButton("⏭ Без комментариев", callback_data=f"skip:{product_id}:{task_id}")]
    ])

    await context.bot.send_message(
        chat_id=query.message.chat_id,
        text=(
            "💬 Добавьте пожелания (текстом или голосовым сообщением) — "
            "рекомендуем подробно описать артефакты, что нравится и что не нравится, "
            " что  хотите добавить в сгенерированном видео.\n\n"
            "Или нажмите кнопку «Без комментариев» чтобы просто создать другой вариант видео."
        ),
        reply_markup=skip_keyboard,
    )


async def handle_star_rating(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle optional star rating (1-5) - no workflow interruption."""
    query = update.callback_query

    try:
        _, product_id, task_id, stars = query.data.split(":")
        stars = int(stars)
    except ValueError:
        await query.answer("Ошибка")
        return

    user_id = update.effective_user.id

    # Log rating to activity.log
    log_activity(product_id, user_id, "STARS", f"{task_id} | {stars}")

    # Show brief confirmation
    await query.answer(f"{'★' * stars}{'☆' * (5 - stars)}")

    # Update buttons to show selection (keep existing direction buttons)
    old_keyboard = query.message.reply_markup.inline_keyboard
    direction_row = old_keyboard[0]  # Keep ✏️/🔄 buttons

    # Build new star row with filled stars
    star_row = [
        InlineKeyboardButton(
            "★" if i <= stars else "☆",
            callback_data=f"stars:{product_id}:{task_id}:{i}"
        )
        for i in range(1, 6)
    ]

    new_keyboard = InlineKeyboardMarkup([direction_row, star_row])
    await query.edit_message_reply_markup(reply_markup=new_keyboard)


async def handle_retry(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle retry callback - full regenerate video for same product."""
    query = update.callback_query
    await query.answer()

    try:
        _, product_id = query.data.split(":")
    except ValueError:
        return

    # Get last URL from context or reconstruct
    last_url = context.user_data.get("last_url")
    if not last_url:
        last_url = f"https://www.wildberries.ru/catalog/{product_id}/detail.aspx"

    await query.edit_message_text(
        f"🔁 Regenerating video for {product_id}...\n"
        "Creating new images and prompts."
    )

    # Run pipeline using chat_id for sending messages
    chat_id = query.message.chat_id
    await run_video_pipeline(context, chat_id, last_url)


async def handle_try_again(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle try again callback - retry only Veo3 with existing frames."""
    query = update.callback_query
    await query.answer()

    try:
        _, product_id, task_id = query.data.split(":")
    except ValueError:
        return

    user_id = update.effective_user.id
    chat_id = query.message.chat_id
    source = context.user_data.get("last_source", "wb")

    # Check if already processing
    if context.user_data.get("current_task"):
        await query.edit_message_text(
            "Already processing a video. Please wait for it to finish."
        )
        return

    # Reject if bot is draining
    if _draining:
        await query.edit_message_text(
            "⏳ Бот обновляется. Попробуйте через несколько минут."
        )
        return

    # Build task directory path (auto-detect source prefix)
    task_dir = get_product_dir(product_id, source) / task_id
    if not task_dir.exists():
        task_dir = get_product_dir(product_id) / task_id

    # Check if frames exist
    processed_dir = task_dir / "processed"
    act1_exists = (processed_dir / "frame_act1.jpg").exists() or (processed_dir / "frame_act1.png").exists()

    if not act1_exists:
        await query.edit_message_text(
            "No existing frames found. Use 🔁 Regenerate instead."
        )
        return

    await query.edit_message_text(
        f"🔄 Retrying Veo3 for {product_id}...\n"
        f"Task: {task_id}\n"
        "Using existing frames and prompts."
    )

    log_activity(product_id, user_id, "RETRY_VEO3", task_id, source=source)

    # Mark as processing
    context.user_data["current_task"] = {
        "product_id": product_id,
        "task_id": task_id,
        "url": context.user_data.get("last_url", "")
    }

    current_task = asyncio.current_task()
    if current_task:
        _active_generations.add(current_task)

    try:
        # Run Veo3 retry in executor
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(
            None,
            lambda: retry_veo3_only(
                output_dir=str(task_dir),
                laozhang_key=LAOZHANG_KEY,
                pollo_key=POLLO_KEY,
            )
        )

        if result.get("success") and result.get("video_path"):
            video_path = Path(result["video_path"])
            log_activity(product_id, user_id, "DONE", f"{task_id} | {video_path.name} (retry)")

            # Store last URL for quick regenerate
            context.user_data["last_url"] = context.user_data.get("last_url", f"https://www.wildberries.ru/catalog/{product_id}/detail.aspx")
            context.user_data["last_product_id"] = product_id
            context.user_data["last_task_id"] = task_id

            # Load metadata for simple caption
            metadata_path = task_dir / "metadata.json"
            catchy_name = "Видео"
            hint_dig_in = ""
            hint_explore = ""

            if metadata_path.exists():
                import json
                with open(metadata_path, 'r', encoding='utf-8') as f:
                    metadata = json.load(f)
                story_vision = metadata.get("story_vision", {})
                catchy_name = story_vision.get("catchy_name", "Видео")
                hint_dig_in = story_vision.get("hint_dig_in", "")
                hint_explore = story_vision.get("hint_explore", "")

            # Build caption with hints as examples (italic)
            caption = f"🎬 «{catchy_name}»\n\n"
            caption += "✏️ Поправить"
            if hint_dig_in:
                caption += f" — _например, {hint_dig_in.lower()}_"
            caption += "\n"
            caption += "🔄 Другой вариант"
            if hint_explore:
                caption += f" — _например, {hint_explore.lower()}_"
            caption += "\n\n"
            caption += "🔗 Или отправьте новую ссылку WB для другого товара"

            # Russian direction buttons
            keyboard = [
                [
                    InlineKeyboardButton(
                        "✏️ Поправить", callback_data=f"rate:{product_id}:{task_id}:good"
                    ),
                    InlineKeyboardButton(
                        "🔄 Другой вариант", callback_data=f"rate:{product_id}:{task_id}:bad"
                    ),
                ],
            ]
            reply_markup = InlineKeyboardMarkup(keyboard)

            if TG_PROXY:
                ok = await _send_video_via_proxy(
                    chat_id, str(video_path), caption=caption,
                    reply_markup=reply_markup, parse_mode="Markdown")
                if not ok:
                    raise Exception("Proxy send failed")
            else:
                with open(video_path, "rb") as video_file:
                    await context.bot.send_video(
                        chat_id,
                        video=video_file,
                        caption=caption,
                        reply_markup=reply_markup,
                        parse_mode="Markdown",
                        read_timeout=300,
                        write_timeout=300,
                    )

            # Mark as delivered
            try:
                (task_dir / ".delivered").write_text(datetime.now().isoformat())
            except Exception:
                pass
        else:
            errors = result.get("errors", [])
            error_text = "\n".join(errors) if errors else "Unknown error"
            log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text} (retry)")

            # Classify error and get user-friendly message
            error_category, user_message = classify_error(error_text)

            await notify_admin(f"[retry] {error_text}", task_id=task_id, user_id=user_id,
                               product_id=product_id, task_dir=str(task_dir))

            # Show error with retry options
            keyboard = [
                [
                    InlineKeyboardButton(
                        "🔄 Попробовать снова", callback_data=f"tryagain:{product_id}:{task_id}"
                    ),
                    InlineKeyboardButton(
                        "🔁 Заново", callback_data=f"retry:{product_id}"
                    ),
                ],
            ]
            reply_markup = InlineKeyboardMarkup(keyboard)

            await context.bot.send_message(
                chat_id,
                f"❌ {user_message}\n\n📎 ID: `{task_id}`",
                reply_markup=reply_markup,
                parse_mode="Markdown"
            )

    except Exception as e:
        logger.exception("Veo3 retry error")
        error_text = str(e)
        log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text} (retry)")

        # Classify error and get user-friendly message
        error_category, user_message = classify_error(error_text)

        await notify_admin(f"[retry exception] {error_text}", task_id=task_id, user_id=user_id,
                           product_id=product_id, task_dir=str(task_dir))

        keyboard = [
            [
                InlineKeyboardButton(
                    "🔄 Попробовать снова", callback_data=f"tryagain:{product_id}:{task_id}"
                ),
                InlineKeyboardButton(
                    "🔁 Заново", callback_data=f"retry:{product_id}"
                ),
            ],
        ]
        reply_markup = InlineKeyboardMarkup(keyboard)

        await context.bot.send_message(
            chat_id,
            f"❌ {user_message}\n\n📎 ID: `{task_id}`",
            reply_markup=reply_markup,
            parse_mode="Markdown"
        )

    finally:
        if current_task:
            _active_generations.discard(current_task)
        context.user_data.pop("current_task", None)


async def handle_smart_retry(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """
    Smart retry: Veo3-only for first 2 attempts, then full regeneration.

    Callback format: smartretry:{product_id}:{task_id}:{retry_count}
    """
    query = update.callback_query
    await query.answer()

    try:
        _, product_id, task_id, retry_count_str = query.data.split(":")
        retry_count = int(retry_count_str)
    except ValueError:
        await query.edit_message_text("Ошибка обработки запроса")
        return

    user_id = update.effective_user.id
    chat_id = query.message.chat_id
    source = context.user_data.get("last_source", "wb")

    if context.user_data.get("current_task"):
        await query.edit_message_text("Уже обрабатывается видео. Подождите.")
        return

    # Reject if bot is draining
    if _draining:
        await query.edit_message_text(
            "⏳ Бот обновляется. Попробуйте через несколько минут."
        )
        return

    attempt_num = retry_count + 1

    # After 2 Veo3 retries, do full regeneration
    if retry_count >= 2:
        raw_url = context.user_data.get("last_url")
        url = extract_url(raw_url) if raw_url else None
        if not url:
            await query.edit_message_text(
                "Отправьте ссылку на товар заново."
            )
            return

        await query.edit_message_text("🔄 Повторяю генерацию видео...")
        log_activity(product_id, user_id, "REGENERATE", f"smartretry attempt {attempt_num}, full regen after 2 veo3 failures", source=source)
        await run_video_pipeline(context, chat_id, url, user_id)
        return

    # Check for existing frames for Veo3-only retry
    task_dir = get_product_dir(product_id, source) / task_id
    if not task_dir.exists():
        task_dir = get_product_dir(product_id) / task_id
    processed_dir = task_dir / "processed"
    act1_exists = (processed_dir / "frame_act1.jpg").exists() or (processed_dir / "frame_act1.png").exists()

    if not act1_exists:
        raw_url = context.user_data.get("last_url")
        url = extract_url(raw_url) if raw_url else None
        if not url:
            await query.edit_message_text("Отправьте ссылку на товар заново.")
            return

        await query.edit_message_text("🔄 Повторяю генерацию видео...")
        log_activity(product_id, user_id, "REGENERATE", f"smartretry attempt {attempt_num}, no frames found - full regen", source=source)
        await run_video_pipeline(context, chat_id, url, user_id)
        return

    await query.edit_message_text("🔄 Повторяю генерацию видео...")
    log_activity(product_id, user_id, "RETRY_VEO3", f"{task_id} smartretry attempt {attempt_num}, veo3-only", source=source)

    context.user_data["current_task"] = {
        "product_id": product_id,
        "task_id": task_id,
        "url": context.user_data.get("last_url", "")
    }

    current_task = asyncio.current_task()
    if current_task:
        _active_generations.add(current_task)

    try:
        loop = asyncio.get_event_loop()
        result = await loop.run_in_executor(
            None,
            lambda: retry_veo3_only(
                output_dir=str(task_dir),
                laozhang_key=LAOZHANG_KEY,
                pollo_key=POLLO_KEY,
            )
        )

        if result.get("success") and result.get("video_path"):
            video_path = Path(result["video_path"])
            log_activity(product_id, user_id, "DONE", f"{task_id} | {video_path.name} (smartretry {attempt_num})")

            metadata_path = task_dir / "metadata.json"
            catchy_name = "Видео"
            hint_dig_in = ""
            hint_explore = ""

            if metadata_path.exists():
                import json
                with open(metadata_path, 'r', encoding='utf-8') as f:
                    metadata = json.load(f)
                story_vision = metadata.get("story_vision", {})
                catchy_name = story_vision.get("catchy_name", "Видео")
                hint_dig_in = story_vision.get("hint_dig_in", "")
                hint_explore = story_vision.get("hint_explore", "")

            caption = f"🎬 «{catchy_name}»\n\n"
            caption += "✏️ Поправить"
            if hint_dig_in:
                caption += f" — _например, {hint_dig_in.lower()}_"
            caption += "\n"
            caption += "🔄 Другой вариант"
            if hint_explore:
                caption += f" — _например, {hint_explore.lower()}_"
            caption += "\n\n"
            caption += "💬 Можете уточнить пожелания или сообщить об артефактах\n🔗 Или отправьте новую ссылку WB для другого товара"

            keyboard = [
                [
                    InlineKeyboardButton("✏️ Поправить", callback_data=f"rate:{product_id}:{task_id}:good"),
                    InlineKeyboardButton("🔄 Другой вариант", callback_data=f"rate:{product_id}:{task_id}:bad"),
                ],
            ]
            reply_markup = InlineKeyboardMarkup(keyboard)

            if TG_PROXY:
                ok = await _send_video_via_proxy(
                    chat_id, str(video_path), caption=caption,
                    reply_markup=reply_markup, parse_mode="Markdown")
                if not ok:
                    raise Exception("Proxy send failed")
            else:
                with open(video_path, "rb") as video_file:
                    await context.bot.send_video(
                        chat_id,
                        video=video_file,
                        caption=caption,
                        reply_markup=reply_markup,
                        parse_mode="Markdown",
                        read_timeout=300,
                        write_timeout=300,
                    )

            # Mark as delivered
            try:
                (task_dir / ".delivered").write_text(datetime.now().isoformat())
            except Exception:
                pass
        else:
            errors = result.get("errors", [])
            error_text = "\n".join(errors) if errors else "Unknown error"
            log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text} (smartretry {attempt_num})")

            # UNSAFE_GENERATION means Veo3 rejected the images - silently do full rebuild
            if "UNSAFE_GENERATION" in error_text:
                url = context.user_data.get("last_url")
                if url:
                    context.user_data.pop("current_task", None)
                    log_activity(product_id, user_id, "REGENERATE", f"UNSAFE_GENERATION - full rebuild")
                    await run_video_pipeline(context, chat_id, url, user_id)
                    return

            error_category, user_message = classify_error(error_text)
            next_retry = retry_count + 1

            await notify_admin(f"[smartretry {attempt_num}] {error_text}", task_id=task_id,
                               user_id=user_id, product_id=product_id, task_dir=str(task_dir))

            keyboard = [[
                InlineKeyboardButton("🔄 Повторить", callback_data=f"smartretry:{product_id}:{task_id}:{next_retry}")
            ]]
            reply_markup = InlineKeyboardMarkup(keyboard)

            await context.bot.send_message(
                chat_id,
                f"❌ {user_message}\n\n📎 ID: `{task_id}`",
                reply_markup=reply_markup,
                parse_mode="Markdown"
            )

    except Exception as e:
        logger.exception("Smart retry error")
        error_text = str(e)
        log_activity(product_id, user_id, "ERROR", f"{task_id} | {error_text} (smartretry {attempt_num})")

        # UNSAFE_GENERATION means Veo3 rejected the images - silently do full rebuild
        if "UNSAFE_GENERATION" in error_text:
            url = context.user_data.get("last_url")
            if url:
                context.user_data.pop("current_task", None)
                log_activity(product_id, user_id, "REGENERATE", f"UNSAFE_GENERATION - full rebuild")
                await run_video_pipeline(context, chat_id, url, user_id)
                return

        error_category, user_message = classify_error(error_text)
        next_retry = retry_count + 1

        await notify_admin(f"[smartretry {attempt_num} exception] {error_text}", task_id=task_id,
                           user_id=user_id, product_id=product_id, task_dir=str(task_dir))

        keyboard = [[
            InlineKeyboardButton("🔄 Повторить", callback_data=f"smartretry:{product_id}:{task_id}:{next_retry}")
        ]]
        reply_markup = InlineKeyboardMarkup(keyboard)

        await context.bot.send_message(
            chat_id,
            f"❌ {user_message}\n\n📎 ID: `{task_id}`",
            reply_markup=reply_markup,
            parse_mode="Markdown"
        )

    finally:
        if current_task:
            _active_generations.discard(current_task)
        context.user_data.pop("current_task", None)


async def handle_feedback_request(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle feedback button callback - prompt user to give feedback."""
    query = update.callback_query
    await query.answer()

    try:
        _, product_id, task_id = query.data.split(":")
    except ValueError:
        return

    # Store for pending comment
    context.user_data["pending_comment"] = {
        "product_id": product_id,
        "task_id": task_id,
        "rating": "error",  # Mark as error feedback
    }

    await query.edit_message_text(
        f"💬 Send feedback for failed generation.\n\n"
        "Describe what went wrong - text or 🎤 voice message.\n"
        "Or /skip to skip feedback."
    )


async def handle_regenerate(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle regenerate callback - resend last URL after feedback."""
    query = update.callback_query
    await query.answer()

    last_url = context.user_data.get("last_url")
    if not last_url:
        await query.edit_message_text("No previous URL found. Send a new WB URL.")
        return

    await query.edit_message_text(
        f"🔄 Regenerating with feedback applied..."
    )

    # Run pipeline using chat_id for sending messages
    chat_id = query.message.chat_id
    await run_video_pipeline(context, chat_id, last_url)


async def handle_comment(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle text that might be a comment."""
    # Check if user is answering a survey text question
    if await survey_handler.handle_survey_text(update, context):
        return

    pending = context.user_data.get("pending_comment")
    text = update.message.text.strip()

    # If it's a supported marketplace URL, clear pending comment and let URL handler process it
    if "wildberries.ru/catalog/" in text or "avito.ru/" in text or "ozon.ru/product/" in text:
        context.user_data.pop("pending_comment", None)
        await handle_url(update, context)
        return

    if not pending:
        # Not waiting for comment - give helpful response
        await update.message.reply_text(
            "❓ Не понял сообщение.\n\n"
            "Для генерации видео отправьте ссылку на товар:\n"
            "• Wildberries: https://www.wildberries.ru/catalog/XXXXXXX/detail.aspx\n"
            "• Ozon: https://www.ozon.ru/product/XXXXXXX/"
        )
        return

    comment = text
    user_id = update.effective_user.id
    source = pending.get("source", "wb")

    log_activity(
        pending["product_id"],
        user_id,
        "COMMENT",
        f"{pending['task_id']} | {comment}",
        source=source,
    )

    # Update product feedback JSON for v2 pipeline
    product_dir = get_product_dir(pending['product_id'], source)
    try:
        save_product_feedback(str(product_dir))
    except Exception as e:
        logger.warning(f"Failed to save product feedback: {e}")

    # Get URL before clearing pending
    url = pending.get("url") or context.user_data.get("last_url")
    context.user_data.pop("pending_comment", None)

    # Auto-regenerate with feedback
    if url:
        await update.message.reply_text("✅ Принято! Генерирую новое видео...")
        chat_id = update.effective_chat.id
        await run_video_pipeline(context, chat_id, url)
    else:
        await update.message.reply_text("✅ Принято! Отправьте ссылку для новой генерации.")


async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
    """Handle voice message as feedback."""
    pending = context.user_data.get("pending_comment")
    if not pending:
        await update.message.reply_text(
            "🎤 Голосовые сообщения поддерживаются для обратной связи.\n"
            "Сначала выберите направление под видео, затем отправьте голосовое сообщение."
        )
        return

    user_id = update.effective_user.id

    # Check whitelist
    if not is_user_allowed(user_id):
        return

    await update.message.reply_text("🎤 Распознаю голосовое сообщение...")

    try:
        # Download voice file
        voice = update.message.voice
        voice_file = await voice.get_file()

        # Save to temp file
        import tempfile
        with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
            tmp_path = tmp.name
            await voice_file.download_to_drive(tmp_path)

        # Transcribe using LaoZhang Whisper API
        loop = asyncio.get_event_loop()
        transcript = await loop.run_in_executor(
            None,
            lambda: transcribe_audio_laozhang(
                audio_path=tmp_path,
                api_key=LAOZHANG_KEY,
                model="whisper-1",
                language="ru",  # Default to Russian
                proxy_host=PROXY_HOST,
                proxy_port=PROXY_PORT
            )
        )

        # Clean up temp file
        import os
        os.unlink(tmp_path)

        if not transcript.strip():
            await update.message.reply_text("Не удалось распознать сообщение. Попробуйте ещё раз или напишите текстом.")
            return

        # Log the transcribed comment
        source = pending.get("source", "wb")
        log_activity(
            pending["product_id"],
            user_id,
            "COMMENT",
            f"{pending['task_id']} | 🎤 {transcript}",
            source=source,
        )

        # Update product feedback JSON for v2 pipeline
        product_dir = get_product_dir(pending['product_id'], source)
        try:
            save_product_feedback(str(product_dir))
        except Exception as e:
            logger.warning(f"Failed to save product feedback: {e}")

        # Get URL before clearing pending
        url = pending.get("url") or context.user_data.get("last_url")
        context.user_data.pop("pending_comment", None)

        # Show transcription and auto-regenerate
        await update.message.reply_text(
            f"✅ Принято!\n\n"
            f"📝 «{transcript}»\n\n"
            "⏳ Генерирую новое видео..."
        )

        if url:
            chat_id = update.effective_chat.id
            await run_video_pipeline(context, chat_id, url)
        else:
            await update.message.reply_text("Отправьте ссылку на товар для новой генерации.")

    except Exception as e:
        logger.exception("Voice transcription error")
        await update.message.reply_text(
            f"Не удалось распознать голос: {e}\n"
            "Пожалуйста, напишите текстом."
        )


async def check_gift_pack_expiry(context: ContextTypes.DEFAULT_TYPE):
    """Periodic job: expire overdue packs and notify users about soon-expiring ones."""
    try:
        # 1. Bulk-expire overdue packs
        expired_count = expire_gift_packs()
        if expired_count:
            logger.info(f"Expired {expired_count} overdue gift pack(s)")

        # 2. Find soon-expiring packs and notify users
        expiring = get_expiring_packs(hours_ahead=24)
        for pack in expiring:
            try:
                await context.bot.send_message(
                    pack["user_id"],
                    f"⏰ Ваш подарок ({pack['tokens']} {plural_generations(pack['tokens'])}) истекает через 24 часа!\n"
                    f"Отправьте ссылку на товар WB, чтобы использовать."
                )
                mark_pack_notified(pack["id"])
            except Exception as e:
                logger.warning(f"Failed to notify user {pack['user_id']} about expiring pack: {e}")
            await asyncio.sleep(0.05)  # Rate limit
    except Exception as e:
        logger.error(f"Error in gift pack expiry check: {e}")


async def post_init(application):
    """Set bot commands menu and clear stale tasks after initialization."""
    # Register graceful shutdown signal handlers
    loop = asyncio.get_running_loop()
    for sig in (signal.SIGTERM, signal.SIGINT):
        loop.add_signal_handler(
            sig,
            lambda: asyncio.create_task(_graceful_shutdown(application)),
        )

    # Set bot commands (Russian)
    commands = [
        BotCommand("start", "Начало работы"),
        BotCommand("help", "Справка"),
        BotCommand("portfolio", "Примеры видео"),
        BotCommand("history", "Мои генерации"),
        BotCommand("buy", "Купить генерации"),
        BotCommand("balance", "Баланс генераций"),
        BotCommand("mode", "Формат видео (16:9 / 9:16)"),
        BotCommand("terms", "Условия использования"),
    ]
    await application.bot.set_my_commands(commands)

    # Schedule periodic gift pack expiration check (every 6 hours)
    application.job_queue.run_repeating(
        check_gift_pack_expiry, interval=6*3600, first=120
    )

    # Clear stale current_task from all users (bot was restarted mid-generation)
    if application.user_data:
        cleared = 0
        for user_id, data in application.user_data.items():
            if data.get("current_task"):
                logger.info(f"Clearing stale current_task for user {user_id}: {data['current_task']}")
                data.pop("current_task", None)
                cleared += 1
        if cleared:
            logger.info(f"Cleared {cleared} stale task(s) from previous session")

    # Recover stale generations (token deducted but video not generated)
    # This happens when bot restarts during video generation
    try:
        stale = find_stale_generations(since_hours=24)
        if stale:
            logger.info(f"Found {len(stale)} stale generation(s) to recover")
            for gen in stale:
                user_id = gen.get("user_id")
                task_id = gen.get("task_id")
                txn_id = gen.get("id")

                if not user_id or not task_id:
                    continue

                # Refund the token
                refund_txn_id = refund_token(
                    user_id,
                    task_id=task_id,
                    reason="Автоматический возврат: генерация прервана перезапуском бота"
                )

                if refund_txn_id:
                    logger.info(f"Refunded token for stale generation {task_id} (user {user_id})")

                    # Notify user
                    try:
                        await application.bot.send_message(
                            user_id,
                            "⚠️ *Извините за неудобства!*\n\n"
                            "Бот был перезапущен во время генерации вашего видео. "
                            "Генерация не была завершена и возвращена на ваш баланс.\n\n"
                            "Отправьте ссылку на товар ещё раз для повторной генерации.",
                            parse_mode="Markdown"
                        )
                        logger.info(f"Notified user {user_id} about stale generation recovery")
                    except Exception as notify_err:
                        logger.warning(f"Failed to notify user {user_id}: {notify_err}")
                else:
                    logger.warning(f"Failed to refund token for stale generation {task_id}")
    except Exception as e:
        logger.error(f"Error during stale generation recovery: {e}")

    # Notify users about possibly undelivered videos
    # Edge case: pipeline finished but bot died before send_video
    try:
        undelivered = find_undelivered_generations(since_hours=24)
        if undelivered:
            logger.info(f"Found {len(undelivered)} undelivered video(s)")
            # Group by user
            by_user: dict[int, list] = {}
            for gen in undelivered:
                user_id = gen.get("user_id")
                if user_id:
                    by_user.setdefault(user_id, []).append(gen)

            for user_id, gens in by_user.items():
                count = len(gens)
                try:
                    await application.bot.send_message(
                        user_id,
                        f"📬 {count} {plural_generations(count)} возможно не "
                        f"{'было' if count == 1 else 'были'} доставлено.\n"
                        f"Посмотрите готовые видео: /history",
                    )
                    logger.info(f"Notified user {user_id} about {count} possibly undelivered video(s)")
                except Exception as notify_err:
                    logger.warning(f"Failed to notify user {user_id}: {notify_err}")
                # Mark all as delivered after notifying the user
                for gen in gens:
                    try:
                        Path(gen["task_dir"]).joinpath(".delivered").write_text(datetime.now().isoformat())
                    except Exception:
                        pass
    except Exception as e:
        logger.error(f"Error during undelivered video recovery: {e}")


def main():
    """Run the bot."""
    if not BOT_TOKEN:
        print("ERROR: TELEGRAM_BOT_TOKEN environment variable not set")
        sys.exit(1)

    if not LAOZHANG_KEY:
        print("WARNING: LAOZHANG_KEY not set, video generation will fail")

    # Ensure NFS directory exists
    NFS_BASE.mkdir(parents=True, exist_ok=True)

    # Initialize token database
    init_db()

    # Initialize showcase database (shared between dev/prod)
    init_showcase_db()

    # Persistence for user data (mode, etc.)
    # Use build_info source to separate dev/prod pickle files
    build_info = get_build_info()
    persistence_path = NFS_BASE / f"bot_data_{build_info['source']}.pickle"
    persistence = PicklePersistence(filepath=persistence_path)

    from telegram.request import HTTPXRequest

    class RetryHTTPXRequest(HTTPXRequest):
        """HTTPXRequest with retry on ConnectError (SOCKS5 proxy hiccups)."""
        MAX_RETRIES = 3
        RETRY_DELAY = 2  # seconds

        async def do_request(self, *args, **kwargs):
            last_exc = None
            for attempt in range(self.MAX_RETRIES):
                try:
                    return await super().do_request(*args, **kwargs)
                except NetworkError as e:
                    if "ConnectError" in str(e):
                        last_exc = e
                        if attempt < self.MAX_RETRIES - 1:
                            logger.warning(f"Telegram ConnectError (attempt {attempt + 1}/{self.MAX_RETRIES}), retrying in {self.RETRY_DELAY}s...")
                            await asyncio.sleep(self.RETRY_DELAY)
                            continue
                    raise
            raise last_exc

    request = RetryHTTPXRequest(
        connect_timeout=30.0,
        read_timeout=60.0,
        write_timeout=60.0,
        pool_timeout=10.0,
        proxy=TG_PROXY if TG_PROXY else None,
    )
    # Separate request for polling getUpdates (needs proxy too)
    get_updates_request = RetryHTTPXRequest(
        connect_timeout=30.0,
        read_timeout=60.0,
        write_timeout=60.0,
        pool_timeout=10.0,
        proxy=TG_PROXY if TG_PROXY else None,
    )
    app = (
        Application.builder()
        .token(BOT_TOKEN)
        .persistence(persistence)
        .concurrent_updates(True)  # Allow concurrent handler execution for multi-user support
        .post_init(post_init)
        .request(request)
        .get_updates_request(get_updates_request)
        .build()
    )

    # Command handlers
    app.add_handler(CommandHandler("start", start_command))
    app.add_handler(CommandHandler("help", start_command))
    app.add_handler(CommandHandler("status", status_command))
    app.add_handler(CommandHandler("skip", skip_command))
    app.add_handler(CommandHandler("mode", mode_command))
    app.add_handler(CommandHandler("portfolio", portfolio_command))
    app.add_handler(CommandHandler("terms", terms_command))
    app.add_handler(CommandHandler("history", history_command))
    app.add_handler(CommandHandler("myid", lambda u, c: u.message.reply_text(f"`{u.effective_user.id}`", parse_mode="Markdown")))

    # Callback handlers
    app.add_handler(CallbackQueryHandler(handle_accept_terms, pattern="^accept_terms$"))
    app.add_handler(CallbackQueryHandler(handle_portfolio_button, pattern="^portfolio$"))
    app.add_handler(CallbackQueryHandler(handle_buy_button, pattern="^buy$"))
    app.add_handler(CallbackQueryHandler(handle_set_mode, pattern="^setmode:"))
    app.add_handler(CallbackQueryHandler(handle_history_page, pattern="^history_page:"))
    app.add_handler(CallbackQueryHandler(handle_resend_video, pattern="^resend:"))
    app.add_handler(CallbackQueryHandler(handle_rating, pattern="^rate:"))
    app.add_handler(CallbackQueryHandler(handle_skip_button, pattern="^skip:"))
    app.add_handler(CallbackQueryHandler(handle_star_rating, pattern="^stars:"))
    app.add_handler(CallbackQueryHandler(handle_retry, pattern="^retry:"))
    app.add_handler(CallbackQueryHandler(handle_try_again, pattern="^tryagain:"))
    app.add_handler(CallbackQueryHandler(handle_smart_retry, pattern="^smartretry:"))
    app.add_handler(CallbackQueryHandler(handle_feedback_request, pattern="^feedback:"))
    app.add_handler(CallbackQueryHandler(handle_regenerate, pattern="^regenerate"))

    # URL handler - matches Wildberries, Avito and Ozon URLs
    app.add_handler(
        MessageHandler(filters.Regex(r"(wildberries\.ru/catalog/\d+|avito\.ru/.*_\d+|ozon\.ru/product/)"), handle_url)
    )

    # Numeric product ID handler - matches standalone numbers (5-12 digits)
    app.add_handler(
        MessageHandler(filters.Regex(r"^\d{5,12}$"), handle_product_id)
    )

    # Comment handler - any text when waiting for comment
    app.add_handler(
        MessageHandler(filters.TEXT & ~filters.COMMAND, handle_comment)
    )

    # Voice message handler for voice feedback
    app.add_handler(MessageHandler(filters.VOICE, handle_voice))

    # Payment handlers
    for handler in get_payment_handlers():
        app.add_handler(handler)

    # Survey handlers
    survey_handler.register_handlers(app)

    # Log startup info
    build_info = get_build_info()
    logger.info("=" * 60)
    logger.info("BOT STARTING")
    logger.info(f"  PID: {os.getpid()}")
    logger.info(f"  Started: {datetime.now().isoformat()}")
    logger.info(f"  Source: {build_info['source']}")
    logger.info(f"  Git commit: {build_info['git_commit']}")
    logger.info(f"  Git branch: {build_info['git_branch']}")
    logger.info(f"  Git dirty: {build_info['git_dirty']}")
    logger.info(f"  Working dir: {build_info['working_dir']}")
    logger.info(f"  ALLOWED_USERS: {ALLOWED_USERS}")
    logger.info(f"  WEBHOOK_URL: {WEBHOOK_URL or 'not set (using polling)'}")
    logger.info("=" * 60)

    if WEBHOOK_URL:
        # Use webhooks for instant update delivery (better for payments)
        # Note: url_path="" because nginx strips path when proxying
        logger.info(f"Starting webhook server on port {WEBHOOK_PORT}")
        app.run_webhook(
            listen="127.0.0.1",
            port=WEBHOOK_PORT,
            url_path="",  # Listen on root, nginx handles /webhook/dev path
            webhook_url=WEBHOOK_URL,
            allowed_updates=Update.ALL_TYPES,
            stop_signals=None,  # We handle signals ourselves for graceful drain
        )
    else:
        # Fall back to polling
        app.run_polling(allowed_updates=Update.ALL_TYPES, stop_signals=None)


if __name__ == "__main__":
    main()
