# Системные импорты
import asyncio, json, logging, os, re, random, shutil, threading
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from io import BytesIO
from pathlib import Path
from zoneinfo import ZoneInfo

# Стороние импорты
import requests
from PIL import Image, ImageDraw, ImageFont
from aiogram import Bot, Dispatcher, F, types
from aiogram.filters import Command, CommandStart
from aiogram.enums import ParseMode
from aiogram.types import (
    BufferedInputFile, Message, BotCommand, FSInputFile,
    BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
    WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton
)
from aiogram.client.default import DefaultBotProperties

# Локальные импорты
from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message
from games.casino import play_casino
from games.fortune import generate_fortune
from games.uwu import handle_uwu_cmd
from games.nude import handle_nude_cmd
from games.betting import (
    clear_user_sport_context,
    debug_sports,
    describe_match_outcomes,
    get_formatted_matches,
    get_match_by_index,
    get_user_bet_history,
    get_user_bets,
    get_user_sport_context,
    place_bet,
    remember_user_sport_context,
    resolve_match_outcome,
    settle_bets,
)
from games.economy import economy
from zparser import get_military_data
from downloader import download_video, COOKIES_PATH
import config

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
_polychaetsi_lock = threading.Lock()
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
_no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE)


def _is_supported_image_message(message: Message) -> bool:
    return bool(
        message.photo
        or (
            message.document
            and (message.document.mime_type or "").startswith("image/")
        )
    )

def _is_watch_image_message(message: Message) -> bool:
    if not _is_supported_image_message(message):
        return False
    caption = (message.caption or "").strip()
    return bool(re.match(r"^/watch(?:@[A-Za-z0-9_]+)?(?:\s|$)", caption, flags=re.IGNORECASE))


async def handle_unmatched_message_debug(message: Message):
    logger.warning(
        "Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s",
        getattr(message, "content_type", None),
        message.chat.id if message.chat else None,
        bool(message.text),
        bool(message.photo),
        bool(message.document),
        bool(message.caption),
    )

_weekday_names = {
    0: "Понедельник",
    1: "Вторник",
    2: "Среда",
    3: "Четверг",
    4: "Пятница",
    5: "Суббота",
    6: "Воскресенье",
}

_weekday_aliases = {
    "mon": 0,
    "monday": 0,
    "пн": 0,
    "пон": 0,
    "понедельник": 0,
    "tue": 1,
    "tues": 1,
    "tuesday": 1,
    "вт": 1,
    "втр": 1,
    "вторник": 1,
    "wed": 2,
    "wednesday": 2,
    "ср": 2,
    "срд": 2,
    "среда": 2,
    "thu": 3,
    "thur": 3,
    "thurs": 3,
    "thursday": 3,
    "чт": 3,
    "чет": 3,
    "четверг": 3,
    "fri": 4,
    "friday": 4,
    "пт": 4,
    "пят": 4,
    "пятница": 4,
    "sat": 5,
    "saturday": 5,
    "сб": 5,
    "суб": 5,
    "суббота": 5,
    "sun": 6,
    "sunday": 6,
    "вс": 6,
    "вск": 6,
    "воскресенье": 6,
}


@dataclass(frozen=True)
class LessonSlot:
    pair_number: int | None
    title: str
    start: time | None
    end: time | None
    is_free: bool

# --- Инициализация и вспомогательные функции ---

def init_polychaetsi_storage() -> None:
    """Инициализация json-хранилища статистики слова 'получается'."""
    path = config.POLYCHAETSI_STATS_PATH
    path.parent.mkdir(parents=True, exist_ok=True)
    if not path.exists():
        path.write_text('{"users": {}}', encoding="utf-8")

def templates_list_text() -> str:
    """Список макетов для подсказки."""
    parts = []
    for tid in sorted(config.TEMPLATES.keys()):
        label = config.TEMPLATES[tid].get("label", "")
        parts.append(f"{tid} — {label}" if label else tid)
    return ", ".join(parts) if parts else "нет макетов"

def _format_delta_ms(delta: timedelta) -> str:
    """HH:MM:SS.mmm, не допускаем отрицательных значений."""
    total_ms = int(max(delta.total_seconds(), 0) * 1000)
    hours, rem = divmod(total_ms, 3_600_000)
    minutes, rem = divmod(rem, 60_000)
    seconds, milliseconds = divmod(rem, 1_000)
    return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{milliseconds:03d}"


def _normalize_now(now: datetime | None = None) -> datetime:
    if now is None:
        return datetime.now(ZoneInfo(config.DEFAULT_TZ))
    if now.tzinfo is None:
        return now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
    return now


def _current_week_key(now: datetime) -> str:
    week_number = now.isocalendar().week
    is_upper = (week_number % 2 == 1) == config.UPPER_WEEK_IS_ODD
    return "upper" if is_upper else "lower"


def _resolve_schedule_date(now: datetime, requested_weekday: int | None = None) -> datetime:
    if requested_weekday is None:
        return now
    delta_days = (requested_weekday - now.weekday()) % 7
    return now + timedelta(days=delta_days)


def _parse_weekday_arg(value: str) -> int | None:
    normalized = re.sub(r"[^a-zа-яё]", "", value.lower())
    if not normalized:
        return None
    return _weekday_aliases.get(normalized)


def _build_lesson_slot(raw_entry: dict) -> LessonSlot:
    pair_number = raw_entry.get("pair")
    start_t, end_t = config.PAIR_TIMES.get(pair_number, (None, None))
    title = str(raw_entry.get("title", "")).strip() or "Пара не указана"
    return LessonSlot(
        pair_number=pair_number,
        title=title,
        start=start_t,
        end=end_t,
        is_free=bool(_no_lesson_re.search(title)),
    )


def get_lessons_for_day(now: datetime | None = None) -> tuple[str, list[LessonSlot]]:
    now = _normalize_now(now)
    week_key = _current_week_key(now)
    raw_day = config.LESSON_SCHEDULE.get(week_key, {}).get(now.weekday(), [])
    lessons = [_build_lesson_slot(item) for item in raw_day]
    return week_key, lessons


def _lesson_bounds(now: datetime, lesson: LessonSlot) -> tuple[datetime | None, datetime | None]:
    if lesson.start is None or lesson.end is None:
        return None, None
    start_dt = now.replace(hour=lesson.start.hour, minute=lesson.start.minute, second=0, microsecond=0)
    end_dt = now.replace(hour=lesson.end.hour, minute=lesson.end.minute, second=0, microsecond=0)
    return start_dt, end_dt


def _find_current_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None:
    for lesson in lessons:
        start_dt, end_dt = _lesson_bounds(now, lesson)
        if start_dt and end_dt and start_dt <= now < end_dt:
            return lesson
    return None


def _find_next_lesson(now: datetime, lessons: list[LessonSlot]) -> LessonSlot | None:
    for lesson in lessons:
        start_dt, _ = _lesson_bounds(now, lesson)
        if start_dt and start_dt > now and not lesson.is_free:
            return lesson
    return None


def _format_lesson_brief(lesson: LessonSlot) -> str:
    prefix = f"{lesson.pair_number} пара" if lesson.pair_number is not None else "Пара"
    if lesson.start and lesson.end:
        return f"{prefix} ({lesson.start:%H:%M}-{lesson.end:%H:%M}) — {lesson.title}"
    return f"{prefix} — {lesson.title}"


def _format_lesson_line(lesson: LessonSlot) -> str:
    if lesson.pair_number is None:
        return f"• {lesson.title}"
    if lesson.start and lesson.end:
        return f"{lesson.pair_number}. {lesson.start:%H:%M}-{lesson.end:%H:%M} — {lesson.title}"
    return f"{lesson.pair_number}. {lesson.title}"


def next_lesson_text(now: datetime | None = None) -> str:
    now = _normalize_now(now)
    _, lessons = get_lessons_for_day(now)
    next_lesson = _find_next_lesson(now, lessons)
    if not next_lesson:
        return "Следующей пары сегодня нет."
    return f"Следующая пара: {_format_lesson_brief(next_lesson)}"


def build_schedule_text(now: datetime | None = None, requested_weekday: int | None = None) -> str:
    now = _normalize_now(now)
    target_now = _resolve_schedule_date(now, requested_weekday)
    week_key, lessons = get_lessons_for_day(target_now)
    show_live_status = target_now.date() == now.date()
    current_lesson = _find_current_lesson(now, lessons) if show_live_status else None
    next_lesson = _find_next_lesson(now, lessons) if show_live_status else None
    week_label = "Верхняя" if week_key == "upper" else "Нижняя"
    day_label = _weekday_names.get(target_now.weekday(), "Сегодня")
    if not show_live_status:
        day_label = f"{day_label}, {target_now:%d.%m.%Y}"

    lines = [f"{week_label} неделя", day_label]
    if lessons:
        lines.append("")
        lines.extend(_format_lesson_line(lesson) for lesson in lessons)
    else:
        lines.append("")
        lines.append("На этот день расписание пустое.")

    if show_live_status:
        lines.append("")
        if current_lesson:
            if current_lesson.is_free:
                lines.append(f"Сейчас: {current_lesson.pair_number} пара свободна")
            else:
                lines.append(f"Сейчас: {_format_lesson_brief(current_lesson)}")
        else:
            lines.append("Сейчас: пары нет")

        if next_lesson:
            lines.append(f"Следующая: {_format_lesson_brief(next_lesson)}")
        else:
            lines.append("Следующая: сегодня нет")

    return "\n".join(lines)

def minutes_until_break(now: datetime | None = None) -> str:
    """Логика расписания перекуров."""
    now = _normalize_now(now)
    
    day = now.weekday()
    today_schedule = config.SCHEDULE.get(day, [])
    if not today_schedule:
        return "Сегодня кайфуем дома, перекур не нужен."

    def dt_for(t: time) -> datetime:
        return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)

    for start_t, end_t, br_minutes in today_schedule:
        start_dt = dt_for(start_t)
        end_dt = dt_for(end_t)

        if now < start_dt or (start_dt <= now < end_dt):
            return f"{_format_delta_ms(end_dt - now)} до перекура"

        if br_minutes:
            break_end = end_dt + timedelta(minutes=br_minutes)
            if end_dt <= now < break_end:
                return f"Идёт перекур, осталось {_format_delta_ms(break_end - now)}"

    return "Пары закончились, перекур только завтра."


def next_break_window(now: datetime | None = None) -> tuple[datetime | None, datetime | None]:
    """Возвращает (начало, конец) ближайшего перекура или (None, None)."""
    now = _normalize_now(now)

    day = now.weekday()
    today_schedule = config.SCHEDULE.get(day, [])
    if not today_schedule:
        return None, None

    def dt_for(t: time) -> datetime:
        return now.replace(hour=t.hour, minute=t.minute, second=0, microsecond=0)

    for start_t, end_t, br_minutes in today_schedule:
        if not br_minutes:
            continue
        end_dt = dt_for(end_t)
        br_start = end_dt
        br_end = end_dt + timedelta(minutes=br_minutes)
        # Ближайший будущий или текущий перекур
        if now < br_end:
            return br_start, br_end

    return None, None


# --- Авто-напоминание о перекуре ---
_break_tasks: dict[int, tuple[datetime, asyncio.Task]] = {}
_break_notified: dict[int, datetime] = {}


async def _send_break_photo(chat_id: int, bot: Bot, start_dt: datetime) -> None:
    last = _break_notified.get(chat_id)
    if last and last == start_dt:
        return  # Уже кидали уведомление для этого слота

    photo_path = config.BASE_DIR / "maket" / "vape.jpg"
    if not photo_path.exists():
        logger.warning("vape.jpg not found, skip break photo")
        return

    try:
        await bot.send_photo(chat_id=chat_id, photo=FSInputFile(photo_path), caption="идем курить")
        _break_notified[chat_id] = start_dt
    except Exception:
        logger.exception("Failed to send break photo")


async def _break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None:
    try:
        now = datetime.now(start_dt.tzinfo)
        delay = (start_dt - now).total_seconds()
        if delay > 0:
            await asyncio.sleep(delay)
        await _send_break_photo(chat_id, bot, start_dt)
    except asyncio.CancelledError:
        pass
    finally:
        stored = _break_tasks.get(chat_id)
        if stored and stored[0] == start_dt:
            _break_tasks.pop(chat_id, None)


def schedule_break_notification(chat_id: int, bot: Bot, start_dt: datetime) -> None:
    existing = _break_tasks.get(chat_id)
    if existing:
        scheduled_start, task = existing
        if scheduled_start == start_dt and not task.done():
            return  # Уже ждём этот же слот
        if not task.done():
            task.cancel()

    task = asyncio.create_task(_break_notification(chat_id, bot, start_dt))
    _break_tasks[chat_id] = (start_dt, task)

# --- Работа с изображениями (Макеты и Мемы) ---

def load_font(size: int) -> ImageFont.FreeTypeFont:
    if not config.FONT_PATH.exists():
        raise FileNotFoundError(f"Шрифт не найден: {config.FONT_PATH}")
    return ImageFont.truetype(str(config.FONT_PATH), size=size)

def wrap_text_to_width(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw, stroke_width: int) -> list[str]:
    words = text.split()
    lines, current = [], []
    for word in words:
        tentative = " ".join(current + [word]) if current else word
        bbox = draw.textbbox((0, 0), tentative, font=font, stroke_width=stroke_width)
        if bbox[2] - bbox[0] <= max_width:
            current.append(word)
        else:
            if current: lines.append(" ".join(current))
            current = [word]
    if current: lines.append(" ".join(current))
    return lines

def parse_up_down(text: str) -> tuple[str, str]:
    """Парсит формат /gen_mem Up:текст Down:текст"""
    up = ""
    down = ""
    # Очищаем текст от самой команды
    text_normalized = re.sub(r'^/\w+\s*', '', text, flags=re.IGNORECASE).replace("\n", " ")
    
    up_match = re.search(r"(?is)up:(.*?)(?=\s+down:|$)", text_normalized)
    down_match = re.search(r"(?is)down:(.*)$", text_normalized)
    
    if up_match:
        up = up_match.group(1).strip()
    if down_match:
        down = down_match.group(1).strip()
        
    # Если меток нет, по старинке: первое слово вверх, остальное вниз
    if not up and not down:
        parts = text_normalized.split(None, 1)
        if parts:
            up = parts[0].strip()
            if len(parts) > 1:
                down = parts[1].strip()
    return up, down

def fit_text_in_box(text: str, max_width: int, max_height: int, start_size: int = 72, min_size: int = 14):
    draw_dummy = ImageDraw.Draw(Image.new("RGB", (1, 1)))
    for size in range(start_size, min_size - 1, -2):
        font = load_font(size)
        stroke_width = max(2, size // 12)
        lines = wrap_text_to_width(text, font, max_width, draw_dummy, stroke_width)
        if not lines: continue
        
        line_heights = [draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[3] - 
                        draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)[1] for ln in lines]
        total_height = sum(line_heights) + max(0, len(lines) - 1) * max(6, size // 6)
        if total_height <= max_height:
            return font, lines, stroke_width, max(6, size // 6)
    
    font = load_font(min_size)
    return font, [text], max(1, min_size // 12), max(4, min_size // 6)

def draw_multiline(draw: ImageDraw.ImageDraw, lines: list[str], position: tuple[int, int], font: ImageFont.FreeTypeFont,
                   stroke_width: int, line_spacing: int, align_center: bool = False, max_width: int | None = None):
    x, y = position
    for line in lines:
        bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width)
        line_width = bbox[2] - bbox[0]
        draw_x = x
        if align_center and max_width is not None:
            draw_x = x + (max_width - line_width) // 2
        draw.text((draw_x, y), line, font=font, fill="white", stroke_width=stroke_width, stroke_fill="black")
        y += (bbox[3] - bbox[1]) + line_spacing

def calculate_font_for_width(text: str, image_width: int, max_ratio: float = 0.09, min_size: int = 18):
    start_size = max(int(image_width * max_ratio), min_size)
    draw_dummy = ImageDraw.Draw(Image.new("RGB", (image_width, image_width)))
    for size in range(start_size, min_size - 1, -2):
        font = load_font(size)
        stroke_width = max(2, size // 12)
        wrapped = wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width)
        if wrapped:
            height = 0
            for ln in wrapped:
                bbox = draw_dummy.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
                height += bbox[3] - bbox[1]
            height += max(0, len(wrapped) - 1) * max(6, size // 6)
            if height <= image_width * 0.35:
                return font, wrapped, stroke_width, max(6, size // 6)

    font = load_font(min_size)
    stroke_width = max(1, min_size // 12)
    wrapped = wrap_text_to_width(text, font, int(image_width * 0.95), draw_dummy, stroke_width)
    return font, wrapped or [text], stroke_width, max(4, min_size // 6)

def generate_maket(text: str, template_id: str = "1") -> BytesIO:
    tpl = config.TEMPLATES.get(template_id)
    if not tpl or not tpl["path"].exists():
        raise FileNotFoundError(f"Шаблон {template_id} не найден")

    with Image.open(tpl["path"]).convert("RGB") as img:
        anchor = tpl["anchor"]
        if "box_size" in tpl:
            max_w, max_h = tpl["box_size"]
        else:
            padding = tpl.get("padding", 0)
            max_w, max_h = img.width - anchor[0] - padding, img.height - anchor[1] - padding
        
        font, lines, stroke, spacing = fit_text_in_box(text, max_w, max_h)
        draw = ImageDraw.Draw(img)
        
        y_offset = anchor[1]
        for line in lines:
            draw.text((anchor[0], y_offset), line, font=font, fill="white", stroke_width=stroke, stroke_fill="black")
            bbox = draw.textbbox((0, 0), line, font=font, stroke_width=stroke)
            y_offset += (bbox[3] - bbox[1]) + spacing

        output = BytesIO()
        img.save(output, format="PNG")
        output.seek(0)
        return output

def generate_meme(img: Image.Image, top_text: str, bottom_text: str) -> BytesIO:
    img = img.convert("RGB")
    draw = ImageDraw.Draw(img)
    width, height = img.size
    margin_y = int(height * 0.04)

    if top_text:
        font, lines, stroke_width, spacing = calculate_font_for_width(top_text, width)
        draw_multiline(
            draw,
            lines,
            (int(width * 0.025), margin_y),
            font,
            stroke_width,
            spacing,
            align_center=True,
            max_width=int(width * 0.95),
        )

    if bottom_text:
        font, lines, stroke_width, spacing = calculate_font_for_width(bottom_text, width)
        block_height = 0
        for ln in lines:
            bbox = draw.textbbox((0, 0), ln, font=font, stroke_width=stroke_width)
            block_height += bbox[3] - bbox[1]
        block_height += max(0, len(lines) - 1) * spacing
        start_y = height - margin_y - block_height
        draw_multiline(
            draw,
            lines,
            (int(width * 0.025), start_y),
            font,
            stroke_width,
            spacing,
            align_center=True,
            max_width=int(width * 0.95),
        )

    output = BytesIO()
    img.save(output, format="JPEG", quality=95)
    output.seek(0)
    return output



def minutes_until_next_lesson(now: datetime | None = None) -> str:
    if now is None:
        now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
    elif now.tzinfo is None:
        now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))

    lesson_days = [1, 3]  # вторник и четверг
    lesson_start = time(9, 50)
    lesson_end = time(11, 25)
    day = now.weekday()

    def lesson_start_for(day_offset: int) -> datetime:
        target = now + timedelta(days=day_offset)
        return target.replace(hour=lesson_start.hour, minute=lesson_start.minute, second=0, microsecond=0)

    def lesson_end_today() -> datetime:
        return now.replace(hour=lesson_end.hour, minute=lesson_end.minute, second=0, microsecond=0)

    if day in lesson_days:
        start_today = lesson_start_for(0)
        end_today = lesson_end_today()
        if start_today <= now < end_today:
            next_dt = None
            for i in range(1, 8):
                next_day = (day + i) % 7
                if next_day in lesson_days:
                    next_dt = lesson_start_for(i)
                    break
            if next_dt is None:
                return "Следующий урок не найден."
            delta = next_dt - now
            total_seconds = int(delta.total_seconds())
            days = total_seconds // 86400
            hours = (total_seconds % 86400) // 3600
            minutes = (total_seconds % 3600) // 60
            percent = random.randint(0, 100)
            return (
                f"Сегодня твой мозг сгнил на {percent}%\n"
                f"Мозг начнет догнивать через {days} дн {hours} ч {minutes} мин"
            )

    delta = None
    for i in range(0, 8):
        next_day = (day + i) % 7
        if next_day in lesson_days:
            candidate = lesson_start_for(i)
            if candidate > now:
                delta = candidate - now
                break

    if delta is None:
        return "Ближайший урок не найден, кайфуй."

    total_seconds = int(delta.total_seconds())
    days = total_seconds // 86400
    hours = (total_seconds % 86400) // 3600
    minutes = (total_seconds % 3600) // 60
    return f"Твой мозг окончательно сгниет, а очко порвется через: {days} дн {hours} ч {minutes} мин"


def _prepare_magnit_query(value: str) -> str:
    value = re.sub(r"(?<=[a-zа-яё])(?=[A-ZА-ЯЁ])", " ", value)
    value = re.sub(r"[_-]+", " ", value)
    return re.sub(r"\s+", " ", value).strip()

def _normalize_magnit_product(entry) -> dict[str, str]:
    if isinstance(entry, str):
        raw_query = entry.strip()
        if not raw_query:
            raise ValueError("Пустой query в MAGNIT_PRODUCTS.")
        return {
            "query": _prepare_magnit_query(raw_query),
            "label": raw_query,
            "store_code": config.MAGNIT_STORE_CODE,
        }

    if isinstance(entry, dict):
        raw_query = str(entry.get("query", "")).strip()
        if not raw_query:
            raise ValueError("У товара в MAGNIT_PRODUCTS отсутствует query.")
        return {
            "query": _prepare_magnit_query(raw_query),
            "label": str(entry.get("label") or raw_query).strip() or raw_query,
            "store_code": str(entry.get("store_code") or config.MAGNIT_STORE_CODE).strip(),
        }

    raise ValueError("MAGNIT_PRODUCTS должен содержать строки или словари.")

def _pick_magnit_items(data: dict) -> list[dict]:
    items = data.get("items")
    if isinstance(items, list):
        return [item for item in items if isinstance(item, dict)]

    best_match = data.get("best_match")
    if isinstance(best_match, dict):
        return [best_match]

    return []

def _format_money(value) -> str | None:
    if value is None:
        return None
    amount = f"{float(value):.2f}".rstrip("0").rstrip(".")
    return f"{amount} ₽"

def _format_magnit_price(item: dict) -> str:
    new_price = _format_money(item.get("price"))
    old_price = _format_money(item.get("old_price"))
    if old_price and new_price and item.get("old_price") and item.get("old_price") != item.get("price"):
        return f"{old_price} -> {new_price}"
    return new_price or "нет цены"

def _format_magnit_quantity(value) -> str:
    if value is None:
        return "нет данных"
    return f"{value} шт."

async def _send_lines_in_chunks(message: Message, lines: list[str], limit: int = 4000) -> None:
    if not lines:
        await message.answer("Товары не найдены.")
        return

    chunk: list[str] = []
    chunk_len = 0
    for line in lines:
        extra_len = len(line) + (1 if chunk else 0)
        if chunk and chunk_len + extra_len > limit:
            await message.answer("\n".join(chunk))
            chunk = [line]
            chunk_len = len(line)
            continue
        chunk.append(line)
        chunk_len += extra_len

    if chunk:
        await message.answer("\n".join(chunk))

# --- Игровая логика (/penis) ---

def play_penis(user_id: int, user_name: str | None = None, now: datetime | None = None) -> tuple[bool, str]:
    if now is None:
        now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
    elif now.tzinfo is None:
        now = now.replace(tzinfo=ZoneInfo(config.DEFAULT_TZ))
    now_ts = int(now.timestamp())
    display_name = user_name.strip() if user_name and user_name.strip() else f"user_{user_id}"

    try:
        result = economy.play_penis(
            user_id, display_name, now_ts,
            config.PENIS_START_LENGTH, config.PENIS_COOLDOWN_SECONDS,
            config.PENIS_MIN_DELTA, config.PENIS_MAX_DELTA,
        )
    except Exception:
        logger.exception("Failed to process /penis")
        return False, "Ошибка БД. Попробуй позже."

    if not result['allowed']:
        left = result['left_seconds']
        hours = left // 3600
        minutes = (left % 3600) // 60
        return False, f"Сегодня уже кидал. Попробуй через {hours} ч {minutes} мин."

    sign = result['sign']
    delta = result['delta']
    new_balance = result['new_balance']
    sign_text = "+" if sign > 0 else "-"
    return (
        True,
        f"📏 Изменение: {sign_text}{delta:.1f} см\n"
        f"Текущая длина: {new_balance:.1f} см\n"
        "Следующая попытка через 24 часа.",
    )

def format_penis_user_name(user: types.User) -> str:
    if user.username:
        return f"@{user.username}"
    if user.full_name:
        return user.full_name
    return f"user_{user.id}"

def build_penis_top(limit: int = config.PENIS_TOP_LIMIT) -> str:
    try:
        rows = economy.get_penis_top(limit)
    except Exception:
        logger.exception("Failed to build /top_penis")
        return "Не удалось загрузить топ. Ошибка БД."

    if not rows:
        return "🏆 Топ пуст. Сначала пусть кто-то крутнет /penis."

    medals = {1: "🥇", 2: "🥈", 3: "🥉"}
    lines = ["🏆 Топ по длине:"]
    for index, row in enumerate(rows, start=1):
        user_id = int(row["user_id"])
        balance = float(row["balance"])
        name = str(row["display_name"]).strip() or f"user_{user_id}"
        prefix = medals.get(index, f"{index}.")
        lines.append(f"{prefix} {name} — {balance:.1f} см")
    return "\n".join(lines)

def _load_polychaetsi_stats() -> dict:
    path = config.POLYCHAETSI_STATS_PATH
    if not path.exists():
        return {"users": {}}

    try:
        with path.open("r", encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError):
        logger.exception("Failed to load polychaetsi stats")
        return {"users": {}}

    if not isinstance(data, dict):
        return {"users": {}}

    users = data.get("users")
    if not isinstance(users, dict):
        data["users"] = {}
    return data

def _save_polychaetsi_stats(data: dict) -> None:
    path = config.POLYCHAETSI_STATS_PATH
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp_path = path.with_name(f"{path.name}.tmp")
    with tmp_path.open("w", encoding="utf-8") as fh:
        json.dump(data, fh, ensure_ascii=False, indent=2)
    tmp_path.replace(path)

def _damerau_levenshtein_distance(source: str, target: str, max_distance: int = 2) -> int:
    if source == target:
        return 0
    if abs(len(source) - len(target)) > max_distance:
        return max_distance + 1

    previous_previous: list[int] | None = None
    previous = list(range(len(target) + 1))

    for i, source_char in enumerate(source, start=1):
        current = [i] + [0] * len(target)
        row_min = current[0]

        for j, target_char in enumerate(target, start=1):
            cost = 0 if source_char == target_char else 1
            current[j] = min(
                previous[j] + 1,
                current[j - 1] + 1,
                previous[j - 1] + cost,
            )
            if (
                previous_previous is not None
                and i > 1
                and j > 1
                and source_char == target[j - 2]
                and source[i - 2] == target_char
            ):
                current[j] = min(current[j], previous_previous[j - 2] + 1)

            row_min = min(row_min, current[j])

        if row_min > max_distance:
            return max_distance + 1

        previous_previous, previous = previous, current

    return previous[-1]

def _normalize_polychaetsi_word(word: str) -> str:
    normalized = word.lower().replace("ё", "е")
    # Подтягиваем самые частые ошибки в возвратном окончании.
    return re.sub(r"(ть?ся|ть?са|цца|ца)$", "тся", normalized)

def is_polychaetsi_variant(word: str) -> bool:
    normalized = _normalize_polychaetsi_word(word)
    target = config.POLYCHAETSI_TARGET

    if normalized == target:
        return True
    if not normalized or normalized[0] != "п":
        return False
    if len(normalized) < len(target) - 1 or len(normalized) > len(target) + 1:
        return False
    if "ч" not in normalized or "а" not in normalized or "е" not in normalized:
        return False
    if not normalized.endswith(("я", "а")):
        return False

    return _damerau_levenshtein_distance(normalized, target, max_distance=2) <= 2

def count_polychaetsi_mentions(text: str) -> int:
    return sum(1 for word in _polychaetsi_word_re.findall(text) if is_polychaetsi_variant(word))

def register_polychaetsi_hits(user: types.User, hits: int) -> int:
    display_name = format_penis_user_name(user)
    with _polychaetsi_lock:
        data = _load_polychaetsi_stats()
        users = data.setdefault("users", {})

        user_key = str(user.id)
        entry = users.get(user_key)
        if not isinstance(entry, dict):
            entry = {"display_name": display_name, "count": 0}

        stored_count = entry.get("count", 0)
        try:
            count = int(stored_count)
        except (TypeError, ValueError):
            count = 0

        count += hits
        entry["display_name"] = display_name
        entry["count"] = count
        users[user_key] = entry
        _save_polychaetsi_stats(data)
        return count

def build_polychaetsi_top(limit: int = config.POLYCHAETSI_TOP_LIMIT) -> str:
    with _polychaetsi_lock:
        data = _load_polychaetsi_stats()

    users = data.get("users", {})
    if not isinstance(users, dict):
        return "ТОП получается:\nпока пусто."

    entries: list[tuple[int, str, int]] = []
    for user_key, raw_entry in users.items():
        if not isinstance(raw_entry, dict):
            continue

        try:
            count = int(raw_entry.get("count", 0))
        except (TypeError, ValueError):
            continue

        if count <= 0:
            continue

        try:
            numeric_user_id = int(user_key)
        except (TypeError, ValueError):
            numeric_user_id = 0

        name = str(raw_entry.get("display_name") or f"user_{user_key}").strip() or f"user_{user_key}"
        entries.append((count, name, numeric_user_id))

    if not entries:
        return "ТОП получается:\nпока пусто."

    entries.sort(key=lambda item: (-item[0], item[1].lower(), item[2]))
    medals = {1: "🥇", 2: "🥈", 3: "🥉"}
    lines = ["ТОП получается:"]
    for index, (count, name, _) in enumerate(entries[:limit], start=1):
        prefix = medals.get(index, f"{index}.")
        lines.append(f"{prefix} {name} — {count}")
    return "\n".join(lines)

# --- Хендлеры aiogram ---

async def handle_help(message: Message):
    await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None)

async def handle_start(message: Message):
    webapp_url = os.getenv("WEBAPP_URL", "")
    if webapp_url:
        if message.chat.type == "private":
            kb = InlineKeyboardMarkup(inline_keyboard=[
                [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
            ])
            await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb)
        else:
            await message.answer("Йо, кент! Mini App доступно только в личных сообщениях. Пиши мне в личку!")
    else:
        await message.answer("Соси")

async def handle_app_cmd(message: Message):
    """Открыть Mini App."""
    webapp_url = os.getenv("WEBAPP_URL", "")
    if not webapp_url:
        await message.answer("Mini App не настроен.")
        return
        
    if message.chat.type == "private":
        kb = InlineKeyboardMarkup(inline_keyboard=[
            [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
        ])
        await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb)
    else:
        await message.answer("Братуха, встроенная кнопка Mini App пока работает только в личке (ограничение Telegram). Перейди в личные сообщения с ботом или используй прямую ссылку: https://t.me/FenyaBotTest_bot/app (если она настроена).")

async def handle_schedule_cmd(message: Message):
    now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
    raw_text = message.text or ""
    parts = raw_text.split(maxsplit=1)
    requested_weekday = None
    if len(parts) > 1:
        requested_weekday = _parse_weekday_arg(parts[1].split(maxsplit=1)[0])
        if requested_weekday is None:
            await message.answer(
                "Не понял день недели. Примеры: /schedule mon, /schedule fri, /schedule пн, /schedule чт."
            )
            return

    await message.answer(build_schedule_text(now, requested_weekday))

async def handle_break_cmd(message: Message):
    now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
    await message.answer(f"{minutes_until_break(now)}\n{next_lesson_text(now)}")

    # Ставим будильник на ближайший перекур для этого чата
    start_dt, _ = next_break_window(now)
    if start_dt:
        schedule_break_notification(message.chat.id, message.bot, start_dt)

async def handle_penis_cmd(message: Message):
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    display_name = format_penis_user_name(message.from_user)
    await message.answer(play_penis(message.from_user.id, display_name)[1])

async def handle_top_penis_cmd(message: Message):
    await message.answer(build_penis_top())

async def handle_polychaetsi_cmd(message: Message):
    await message.answer(build_polychaetsi_top())

async def handle_size_cmd(message: Message):
    size = round(random.uniform(0.1, 35.0), 1)
    if size < 5:
        status = "СВОи"
    elif size < 10:
        status = "Неплохой"
    elif size < 18:
        status = "Шлюхи ахуевают при виде тебя"
    elif size < 25:
        status = "Хуек с коробок"
    elif size < 32:
        status = "Пиздабол"
    else:
        status = "Говноед"
    await message.answer(f"📏 Твой хуек: {size} см\nТы: {status}")

async def handle_zvetok_cmd(message: Message):
    await message.answer(minutes_until_next_lesson())

async def handle_prices_cmd(message: Message):
    try:
        products = [_normalize_magnit_product(entry) for entry in config.MAGNIT_PRODUCTS]
    except ValueError as exc:
        await message.answer(f"Проверь MAGNIT_PRODUCTS в config.py: {exc}")
        return

    if not products:
        await message.answer("Список MAGNIT_PRODUCTS пуст. Добавь товары в config.py.")
        return

    async def fetch_product(product_cfg: dict[str, str]) -> list[str]:
        def fetch():
            return requests.get(
                f"{config.MAGNIT_API_BASE_URL.rstrip('/')}/price",
                params={
                    "store_code": product_cfg["store_code"],
                    "query": product_cfg["query"],
                },
                timeout=config.MAGNIT_REQUEST_TIMEOUT,
            )

        label = product_cfg["label"] or product_cfg["query"] or "товар"
        try:
            resp = await asyncio.to_thread(fetch)
            if resp.status_code != 200:
                logger.warning("Magnit API returned %s for %s", resp.status_code, product_cfg["query"])
                return [f"{label}, ошибка API ({resp.status_code}), -"]

            data = resp.json()
            if not isinstance(data, dict):
                return [f"{label}, битый ответ API, -"]

            items = _pick_magnit_items(data)
            if not items:
                return [f"{label}, товар не найден, -"]

            lines = []
            for item in items:
                name = str(item.get("name") or label).strip() or label
                price = _format_magnit_price(item)
                quantity = _format_magnit_quantity(item.get("quantity"))
                lines.append(f"{name}, {price}, {quantity}")
            return lines
        except requests.RequestException:
            logger.exception("Failed to fetch Magnit product: %s", product_cfg["query"])
            return [f"{label}, ошибка запроса, -"]
        except ValueError:
            logger.exception("Magnit API returned invalid JSON for %s", product_cfg["query"])
            return [f"{label}, битый JSON, -"]

    result_groups = await asyncio.gather(*(fetch_product(product) for product in products))
    lines = [line for group in result_groups for line in group]
    await _send_lines_in_chunks(message, lines)

async def handle_fetch_cmd(message: Message):
    await handle_prices_cmd(message)

async def handle_svodka_cmd(message: Message):
    data = await asyncio.to_thread(get_military_data, "creamy_caprice", 5)
    if data["status"] == "success":
        response = (
            f"в период с {data['dates']} хохлы в среднем проебывали по {data['pace']} км² в сутки, "
            f"а за день ВС РФ лишала чубатых в среднем по {data['total']} км²"
        )
        await message.answer(response)
    else:
        await message.answer(f"Хохлы ответят за это...: {data['message']}")

async def handle_ebalnik_cmd(message: Message):
    chat_id = message.chat.id
    if chat_id in _autoreply_disabled_chats:
        _autoreply_disabled_chats.remove(chat_id)
        await message.reply("Автоответы вернул, бот снова влезает в чат.")
    else:
        _autoreply_disabled_chats.add(chat_id)
        await message.reply("Автоответы срезал, бот теперь молчит в этом чате.")

async def handle_maket_cmd(message: Message):
    parts = message.text.split(maxsplit=2)
    if len(parts) == 1:
        await message.reply(f"Доступные макеты: {templates_list_text()}\nФормат: /maket «id» «текст»")
        return
    if len(parts) < 3:
        await message.reply(
            f"Формат: /maket «id» «текст»\nДоступные макеты: {templates_list_text()}\n\n"
            + config.get_hint_text(templates_list_text())
        )
        return
    template_id = parts[1]
    if template_id not in config.TEMPLATES:
        await message.reply(f"Такого макета нет. Доступные: {templates_list_text()}")
        return
    text = parts[2].strip()
    if not text:
        await message.reply("Нужен текст после номера макета.")
        return
    try:
        output = await asyncio.to_thread(generate_maket, text, template_id)
        await message.reply_photo(BufferedInputFile(output.getvalue(), filename="maket.png"))
    except Exception as exc:
        logger.exception("Failed to generate maket")
        await message.reply(f"Не получилось: {exc}")

async def handle_gen_mem(message: Message, bot: Bot):
    photo_msg = message if message.photo else (message.reply_to_message if message.reply_to_message and message.reply_to_message.photo else None)
    if not photo_msg:
        await message.reply("Еблан, /gen_mem Up: Down:")
        return
    full_text = message.text or ""
    top_text, bottom_text = parse_up_down(full_text)
    if not top_text and not bottom_text:
        await message.reply("Еблан, /gen_mem Up: Down:")
        return
    try:
        file = await bot.get_file(photo_msg.photo[-1].file_id)
        buf = BytesIO()
        await bot.download_file(file.file_path, destination=buf)
        buf.seek(0)
        img = Image.open(buf)
        output = await asyncio.to_thread(generate_meme, img, top_text, bottom_text)
    except Exception as exc:
        logger.exception("Failed to render meme")
        await message.reply(f"Не получилось сделать мем: {exc}")
        return
    await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))

_autoreply_disabled_chats: set[int] = set()

async def handle_keywords(message: Message):
    if not message.text or message.text.startswith('/'):
        return
    
    # Считаем сообщение для экономической системы
    if message.from_user:
        await asyncio.to_thread(economy.count_message, message.from_user.id)
    
    text_lower = message.text.lower()
    chat_id = message.chat.id
    user_name = (message.from_user.first_name or "кент") if message.from_user else "кент"

    # /ebalnik должен глушить все авто-реакции в чате, включая медиа/фото-ветки.
    if chat_id in _autoreply_disabled_chats:
        return

    if any(re.search(rf'\b{word}\b', text_lower) for word in config.KEYWORDS):
        audio_path = config.BASE_DIR / "maket" / "Radiohead - Creep.mp3"
        if audio_path.exists():
            await message.answer_audio(
                audio=FSInputFile(audio_path),
                caption="I'm a creep, I'm a weirdo...",
            )
        return

    polychaetsi_hits = count_polychaetsi_mentions(text_lower)
    if polychaetsi_hits > 0 and message.from_user:
        total_hits = await asyncio.to_thread(register_polychaetsi_hits, message.from_user, polychaetsi_hits)
        photo_path = config.POLYCHAETSI_IMAGE_PATH
        if photo_path.exists():
            await message.answer_photo(
                photo=FSInputFile(photo_path),
                caption=f"x{total_hits}",
            )
        else:
            logger.warning("cvetok.jpg not found, fallback to text response")
            await message.answer(f"x{total_hits}")
        return

    # Записываем каждое обычное сообщение в историю чата
    await asyncio.to_thread(push_message, chat_id, "user", user_name, message.text)

    await handle_chat_message(message, store_message=False, allow_autonomous=True)

async def handle_penis_casino_cmd(message: Message):
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    parts = message.text.split(maxsplit=1)
    if len(parts) < 2:
        await message.reply("Формат: /penis_casino [ставка]\nПример: /penis_casino 0.5", parse_mode=None)
        return
    try:
        bet = round(float(parts[1].replace(",", ".")), 1)
    except ValueError:
        await message.reply("Ставка должна быть числом, фраер. Пример: /penis_casino 0.5")
        return
    result = await asyncio.to_thread(play_casino, message.from_user.id, bet)
    await message.reply(result, parse_mode=None)

# --- Экономические команды ---

async def handle_balance_cmd(message: Message):
    """Показать баланс пользователя"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    balance = economy.get_user_balance(message.from_user.id)
    stats = await asyncio.to_thread(economy.get_user_stats, message.from_user.id)
    cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
    
    text = f"""💰 **Твой баланс:** {balance:.1f} см

📊 **Статистика:**
• Активные вклады: {stats['active_deposits_count']} шт. ({stats['active_deposits_sum']:.1f} см)
• Активные кредиты: {stats['active_loans_count']} шт. ({stats['active_loans_sum']:.1f} см)
• Сообщений сегодня: {stats['today_messages']} шт.
• Сообщений вчера: {stats['yesterday_messages']} шт.
• Ожидаемый доход: {stats['yesterday_messages'] * 0.01:.2f} см

🏛️ **Центральный Банк:**
• Капитал ЦБ: {cb_stats['capital']:.1f} см
• Собрано налогов: {cb_stats['total_taxes_collected']:.1f} см

💡 **Команды экономики:**
/balance - показать баланс
/deposit [сумма] [дни] - открыть вклад
/loans - показать кредиты
/loan [сумма] [дни] - взять кредит
/repay [ID] - погасить кредит
/transfer @username [сумма] - перевести деньги
/cb_stats - статистика ЦБ
/vanomasa - полное обнуление (для админов)

💬 **Заработок на сообщениях:**
0.01 см за каждое сообщение
Выплата в 00:00 по МСК
"""
    await message.answer(text, parse_mode=None)

async def handle_deposit_cmd(message: Message):
    """Открыть вклад"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    parts = message.text.split(maxsplit=2)
    if len(parts) < 3:
        await message.reply("Формат: /deposit [сумма] [дни]\nПример: /deposit 50 30", parse_mode=None)
        return
    
    try:
        amount = float(parts[1].replace(",", "."))
        days = int(parts[2])
    except ValueError:
        await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
        return
    
    if amount <= 0 or days <= 0:
        await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
        return
    
    success = await asyncio.to_thread(economy.create_deposit, message.from_user.id, amount, days)
    if success:
        rate = economy.get_setting('central_bank_rate')
        await message.reply(f"✅ Вклад открыт!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
    else:
        await message.reply("❌ Не удалось открыть вклад. Проверь баланс и минимальную сумму.", parse_mode=None)

async def handle_loans_cmd(message: Message):
    """Показать кредиты"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    # Здесь можно добавить логику показа кредитов пользователя
    await message.reply("📋 Твои кредиты:\n\n(функция в разработке)", parse_mode=None)

async def handle_loan_cmd(message: Message):
    """Взять кредит"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    parts = message.text.split(maxsplit=2)
    if len(parts) < 3:
        await message.reply("Формат: /loan [сумма] [дни]\nПример: /loan 50 30", parse_mode=None)
        return
    
    try:
        amount = float(parts[1].replace(",", "."))
        days = int(parts[2])
    except ValueError:
        await message.reply("Сумма должна быть числом, дни - целым числом.", parse_mode=None)
        return
    
    if amount <= 0 or days <= 0:
        await message.reply("Сумма и дни должны быть положительными.", parse_mode=None)
        return
    
    success = await asyncio.to_thread(economy.create_loan, message.from_user.id, amount, days)
    if success:
        rate = economy.get_setting('loan_interest_rate')
        await message.reply(f"✅ Кредит выдан!\n💰 Сумма: {amount:.1f} см\n📅 Срок: {days} дней\n📈 Ставка: {rate}% годовых", parse_mode=None)
    else:
        await message.reply("❌ Не удалось взять кредит. Проверь лимиты.", parse_mode=None)

async def handle_transfer_cmd(message: Message):
    """Перевод денег другому пользователю"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return

    parts = message.text.split(maxsplit=2)
    if len(parts) < 3:
        await message.reply("Формат: /transfer @username [сумма]\nПример: /transfer @user123 10.5", parse_mode=None)
        return

    username = parts[1].lstrip('@')
    try:
        amount = round(float(parts[2].replace(",", ".")), 1)
    except ValueError:
        await message.reply("Сумма должна быть числом.", parse_mode=None)
        return

    if amount <= 0:
        await message.reply("Сумма должна быть положительной.", parse_mode=None)
        return

    to_user_id = await asyncio.to_thread(economy.find_user_id_by_username, username)
    if not to_user_id:
        await message.reply(f"Пользователь @{username} не найден. Он должен хотя бы раз сыграть в /penis.", parse_mode=None)
        return

    if to_user_id == message.from_user.id:
        await message.reply("Себе переводить нельзя.", parse_mode=None)
        return

    success = await asyncio.to_thread(
        economy.transfer_money, message.from_user.id, to_user_id, amount, f"transfer to @{username}"
    )
    if success:
        my_balance = economy.get_user_balance(message.from_user.id)
        await message.reply(
            f"✅ Переведено {amount:.1f} см → @{username}\nТвой баланс: {my_balance:.1f} см",
            parse_mode=None,
        )
    else:
        my_balance = economy.get_user_balance(message.from_user.id)
        limit = round(my_balance * 0.2, 1)
        await message.reply(
            f"❌ Не удалось перевести. Баланс: {my_balance:.1f} см, лимит одного перевода: {limit:.1f} см (20%).",
            parse_mode=None,
        )

async def handle_vanomasa_cmd(message: Message):
    """Полное обнуление (только для админов)"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    # Проверка на админа (простая реализация)
    if message.from_user.id not in [123456789]:  # Заменить на реальные ID админов
        await message.reply("❌ Команда доступна только администраторам!", parse_mode=None)
        return
    
    success = await asyncio.to_thread(economy.vanomasa)
    if success:
        await message.reply("💥 **ВАНОМАСА!** 💥\n\nВсе балансы обнулены!", parse_mode=None)
    else:
        await message.reply("❌ Ваномаса еще не доступна. Подождите.", parse_mode=None)

async def handle_central_bank_cmd(message: Message):
    """Статистика Центрального Банка"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
    
    text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)

💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Статистика операций:**
• Всего собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
• Регулирование инфляции: {cb_stats['total_inflation_adjustments']:.2f} см
• Эмиссия денег: {cb_stats['total_emissions']:.2f} см

🤖 **Автоматическое регулирование:**
• ЦБ работает по алгоритмам без вмешательства
• Налоги автоматически поступают в ЦБ
• Инфляция регулируется автоматически
• Эмиссия при дефляции, скупка при инфляции

💡 **Команды ЦБ:**
/central_bank - детальная статистика
/cb_stats - краткая статистика
/cb_rules - правила работы ЦБ
"""
    await message.answer(text, parse_mode=None)

async def handle_cb_stats_cmd(message: Message):
    """Статистика ЦБ для всех пользователей"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    cb_stats = await asyncio.to_thread(economy.get_central_bank_stats)
    
    text = f"""🏛️ **Центральный Банк ZOV OS** (децентрализованный)

💰 **Капитал ЦБ:** {cb_stats['capital']:.2f} см
📊 **Операции ЦБ:**
• Собрано налогов: {cb_stats['total_taxes_collected']:.2f} см
• Антиинфляция: {cb_stats['total_inflation_adjustments']:.2f} см
• Эмиссия: {cb_stats['total_emissions']:.2f} см

🔹 ЦБ работает автоматически по алгоритмам
🔹 Учредительный капитал: 1000 см
🔹 Все налоги поступают в ЦБ
🔹 Никакого ручного управления
"""
    await message.answer(text, parse_mode=None)

async def handle_cb_rules_cmd(message: Message):
    """Правила работы Центрального Банка"""
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    
    text = f"""📖 **Правила Центрального Банка ZOV OS**

🤖 **Децентрализованная система:**
• ЦБ работает полностью автоматически
• Никакого ручного вмешательства
• Все решения принимаются по алгоритмам

� **Сбор налогов:**
• 13% налог автоматически поступает в ЦБ
• Налоги собираются каждый час
• Капитал ЦБ растет за счет налогов

📈 **Борьба с инфляцией:**
• Если инфляция > 10%: ЦБ скупает деньги
• Увеличивает налоги до 20% максимум
• Скупает до 5% от массы денег

� **Стимуляция экономики:**
• Если инфляция < 2%: ЦБ проводит эмиссию
• Уменьшает налоги до 10% минимум
• Эмиссия до 5% от капитала ЦБ

⚖️ **Баланс системы:**
• ЦБ не может обанкротиться (учредительный капитал)
• Автоматическая стабилизация экономики
• Прозрачная статистика для всех
"""
    await message.answer(text, parse_mode=None)

async def handle_gadanie_cmd(message: Message):
    parts = message.text.split(maxsplit=1)
    if len(parts) < 2 or not parts[1].strip():
        await message.reply("Напиши на что гадать, кент. Пример: /gadanie любовь")
        return
    topic = parts[1].strip()
    await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
    try:
        fortune_text, up_text, down_text = await generate_fortune(topic)
        await message.reply(f"🔮 Гадание на «{topic}»:\n\n{fortune_text}", parse_mode=None)
        photo_path = config.BASE_DIR / "maket" / "vape.jpg"
        if photo_path.exists():
            img = Image.open(photo_path).convert("RGB")
            meme_output = await asyncio.to_thread(generate_meme, img, up_text, down_text)
            await message.reply_photo(
                BufferedInputFile(meme_output.getvalue(), filename="gadanie.jpg")
            )
    except Exception:
        logger.exception("Gadanie failed")
        await message.reply("Бля, карты рассыпались, попробуй позже.")

async def handle_matches_cmd(message: Message):
    raw_text = message.text or ""
    parts = raw_text.split(maxsplit=1)
    sport_alias = parts[1].strip().lower() if len(parts) > 1 else "dota2"

    if sport_alias not in config.ODDS_SPORTS:
        if message.from_user:
            clear_user_sport_context(message.from_user.id)
        await message.reply("Неизвестная лига. Доступно: dota2", parse_mode=None)
        return

    if message.from_user:
        remember_user_sport_context(message.from_user.id, sport_alias)

    await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
    result = await get_formatted_matches(sport_alias)
    await message.reply(result, parse_mode=None)

async def handle_bet_cmd(message: Message):
    if not message.from_user:
        await message.answer("Команда доступна только пользователям.")
        return
    raw_text = message.text or ""
    parts = raw_text.split()
    if len(parts) < 4:
        await message.reply(
            "Формат: /bet [номер] [исход] [ставка]\n"
            "Или: /bet dota2 [номер] [исход] [ставка]\n"
            "Исход: 1/2. Можно писать ещё название команды или коэффициент.\n"
            "Пример: /bet 1 1 0.5",
            parse_mode=None,
        )
        return

    sport_alias: str
    if parts[1].lower() == "dota2":
        if len(parts) < 5:
            await message.reply(
                "Формат: /bet dota2 [номер] [исход] [ставка]\nПример: /bet dota2 1 1 0.5",
                parse_mode=None,
            )
            return
        sport_alias = "dota2"
        index_token = parts[2]
        selection = " ".join(parts[3:-1]).strip()
        amount_token = parts[-1]
    else:
        sport_alias = "dota2"
        index_token = parts[1]
        selection = " ".join(parts[2:-1]).strip()
        amount_token = parts[-1]

    if not selection:
        await message.reply("Нужен исход: 1, 2, название команды или коэффициент.", parse_mode=None)
        return

    try:
        index = int(index_token)
    except ValueError:
        await message.reply("Номер матча должен быть числом.", parse_mode=None)
        return

    try:
        amount = round(float(amount_token.replace(",", ".")), 1)
    except ValueError:
        await message.reply("Ставка должна быть числом.", parse_mode=None)
        return

    match = await get_match_by_index(sport_alias, index)
    if not match:
        await message.reply(f"Матч не найден. Посмотри /matches {sport_alias}", parse_mode=None)
        return

    team = resolve_match_outcome(match, selection)
    if not team:
        await message.reply(
            f"Исход не найден. Доступные: {describe_match_outcomes(match)}",
            parse_mode=None,
        )
        return

    result = await asyncio.to_thread(place_bet, message.from_user.id, match, sport_alias, team, amount)
    await message.reply(result, parse_mode=None)

async def handle_mybets_cmd(message: Message):
    if not message.from_user:
        return
    result = await asyncio.to_thread(get_user_bets, message.from_user.id)
    await message.reply(result, parse_mode=None)

async def handle_bet_history_cmd(message: Message):
    if not message.from_user:
        return
    result = await asyncio.to_thread(get_user_bet_history, message.from_user.id)
    # Разделяем на части если слишком длинно
    if len(result) > 4000:
        parts = result.split("\n\n")
        chunk = ""
        for part in parts:
            if len(chunk) + len(part) + 2 > 4000:
                await message.reply(chunk, parse_mode=None)
                chunk = part
            else:
                chunk = chunk + "\n\n" + part if chunk else part
        if chunk:
            await message.reply(chunk, parse_mode=None)
    else:
        await message.reply(result, parse_mode=None)

async def handle_sports_debug_cmd(message: Message):
    await message.bot.send_chat_action(chat_id=message.chat.id, action="typing")
    result = await debug_sports()
    await message.reply(result, parse_mode=None)

_YT_URL_RE = re.compile(
    r"https?://(?:www\.)?(?:youtube\.com/(?:watch|shorts|live)|youtu\.be)/\S+",
    re.IGNORECASE,
)

async def handle_dow_cmd(message: Message, bot: Bot):
    parts = (message.text or "").split(maxsplit=1)

    # Если /dow без URL — пробуем взять ссылку из сообщения-источника (reply)
    url = ""
    if len(parts) >= 2:
        url = parts[1].strip()
    elif message.reply_to_message:
        src_text = message.reply_to_message.text or message.reply_to_message.caption or ""
        m = _YT_URL_RE.search(src_text)
        if m:
            url = m.group(0)

    if not url:
        await message.reply("Формат: /dow <ссылка>\nИли ответь на сообщение с YouTube-ссылкой командой /dow")
        return

    status = await message.reply("⏳ Скачиваю...")
    file_path, title, error = await download_video(url)

    if error:
        await status.edit_text(f"❌ Ошибка скачивания:\n{error[:500]}")
        return

    try:
        size_mb = file_path.stat().st_size / (1024 * 1024)
        if size_mb > 50:
            await status.edit_text(f"❌ Видео весит {size_mb:.1f} МБ — Telegram не позволяет отправить >50 МБ")
            return

        await status.edit_text("📤 Отправляю...")
        target = message.reply_to_message or message
        await target.reply_video(
            FSInputFile(str(file_path)),
            caption=title[:1024],
            supports_streaming=True,
        )
        await status.delete()
    except Exception as exc:
        logger.exception("Failed to send downloaded video")
        await status.edit_text(f"❌ Не смог отправить: {exc}")
    finally:
        shutil.rmtree(str(file_path.parent), ignore_errors=True)


async def handle_setcookies_cmd(message: Message, bot: Bot):
    """Загрузить файл кук для YouTube (только в личке, ответом на документ)."""
    if message.chat.type != "private":
        await message.reply("Куки можно загрузить только в личных сообщениях с ботом.")
        return

    doc = None
    if message.document:
        doc = message.document
    elif message.reply_to_message and message.reply_to_message.document:
        doc = message.reply_to_message.document

    if not doc:
        await message.reply(
            "Пришли файл cookies (Netscape/txt) в этот чат с подписью /setcookies "
            "или ответь на сообщение с файлом командой /setcookies"
        )
        return

    try:
        COOKIES_PATH.parent.mkdir(parents=True, exist_ok=True)
        file_info = await bot.get_file(doc.file_id)
        buf = BytesIO()
        await bot.download_file(file_info.file_path, destination=buf)
        buf.seek(0)
        COOKIES_PATH.write_bytes(buf.read())
        await message.reply(f"✅ Куки сохранены ({doc.file_size or '?'} байт). yt-dlp будет их использовать.")
    except Exception as exc:
        logger.exception("Failed to save cookies")
        await message.reply(f"❌ Не удалось сохранить: {exc}")


async def _settle_loop(bot: Bot):
    while True:
        await asyncio.sleep(1800)
        try:
            notifications = await settle_bets()
            for user_id, text in notifications:
                try:
                    await bot.send_message(user_id, text)
                except Exception:
                    logger.warning("Could not notify user %s about bet result", user_id)
        except Exception:
            logger.exception("settle_bets failed")

# --- Запуск ---

def main():
    token = os.getenv("BOT_TOKEN")
    if not token: raise RuntimeError("BOT_TOKEN не задан!")

    init_polychaetsi_storage()
    bot = Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
    dp = Dispatcher()

    # Регистрация хендлеров
    dp.message.register(handle_start, CommandStart())
    dp.message.register(handle_app_cmd, Command("app"))
    dp.message.register(handle_help, Command("help"))
    dp.message.register(handle_schedule_cmd, Command("schedule"))
    dp.message.register(handle_break_cmd, Command("break"))
    dp.message.register(handle_maket_cmd, Command("maket"))
    dp.message.register(handle_size_cmd, Command("size"))
    dp.message.register(handle_penis_cmd, Command("penis"))
    dp.message.register(handle_top_penis_cmd, Command("top_penis"))
    dp.message.register(handle_polychaetsi_cmd, Command("polychaetsi"))
    dp.message.register(handle_prices_cmd, Command("prices"))
    dp.message.register(handle_fetch_cmd, Command("fetch"))
    dp.message.register(handle_zvetok_cmd, Command("zvetok"))
    dp.message.register(handle_talk, Command("talk"))
    dp.message.register(handle_svodka_cmd, Command("svodka"))
    dp.message.register(handle_ebalnik_cmd, Command("ebalnik"))
    dp.message.register(handle_penis_casino_cmd, Command("penis_casino"))
    dp.message.register(handle_gadanie_cmd, Command("gadanie"))
    dp.message.register(handle_matches_cmd, Command("matches"))
    dp.message.register(handle_bet_cmd, Command("bet"))
    dp.message.register(handle_mybets_cmd, Command("mybets"))
    dp.message.register(handle_bet_history_cmd, Command("bet_history"))
    dp.message.register(handle_sports_debug_cmd, Command("sports_debug"))
    dp.message.register(handle_gen_mem, Command("gen_mem"))
    dp.message.register(handle_uwu_cmd, Command("uwu"))
    dp.message.register(handle_nude_cmd, Command("nude"))
    dp.message.register(handle_dow_cmd, Command("dow"))
    dp.message.register(handle_setcookies_cmd, Command("setcookies"))
    dp.message.register(handle_photo_message, _is_watch_image_message)
    
    # Экономические команды
    dp.message.register(handle_balance_cmd, Command("balance"))
    dp.message.register(handle_deposit_cmd, Command("deposit"))
    dp.message.register(handle_loans_cmd, Command("loans"))
    dp.message.register(handle_loan_cmd, Command("loan"))
    dp.message.register(handle_transfer_cmd, Command("transfer"))
    dp.message.register(handle_vanomasa_cmd, Command("vanomasa"))
    
    # Команды Центрального Банка (децентрализованные)
    dp.message.register(handle_central_bank_cmd, Command("central_bank"))
    dp.message.register(handle_cb_stats_cmd, Command("cb_stats"))
    dp.message.register(handle_cb_rules_cmd, Command("cb_rules"))
    dp.message.register(handle_keywords, F.text)
    dp.message.register(handle_unmatched_message_debug)

    async def on_startup(bot: Bot):
        try:
            commands = [
                BotCommand(command="start", description="Приветствие"),
                BotCommand(command="help", description="Подсказки по командам"),
                BotCommand(command="schedule", description="расписание пар"),
                BotCommand(command="maket", description="Вписать текст в макет 1"),
                BotCommand(command="gen_mem", description="Мем из фото (ответом)"),
                BotCommand(command="break", description="Сколько минут до перекура"),
                BotCommand(command="size", description="хуй"),
                BotCommand(command="penis", description="миниигра 1 раз в 24ч"),
                BotCommand(command="top_penis", description="топ по длине"),
                BotCommand(command="polychaetsi", description="топ по получается"),
                BotCommand(command="prices", description="цены и остатки Магнита"),
                BotCommand(command="fetch", description="alias для /prices"),
                BotCommand(command="zvetok", description="Цветянский бля"),
                BotCommand(command="talk", description="Побазарить"),
                BotCommand(command="watch", description="Коммент фото по подписи /watch"),
                BotCommand(command="ebalnik", description="вкл/выкл автответов"),
                BotCommand(command="penis_casino", description="казино на размер"),
                BotCommand(command="gadanie", description="гадание на фене"),
                BotCommand(command="matches", description="матчи для ставок"),
                BotCommand(command="bet", description="поставить см на матч"),
                BotCommand(command="mybets", description="мои активные ставки"),
                BotCommand(command="bet_history", description="история ставок за месяц"),
                BotCommand(command="sports_debug", description="диагностика API матчей"),
                BotCommand(command="svodka", description="СВО: итоги"),
                BotCommand(command="uwu", description="Случайная картинка с e621"),
                BotCommand(command="nude", description="Голые женщины из открытых источников"),
                BotCommand(command="dow", description="Скачать видео по ссылке"),
                BotCommand(command="setcookies", description="Загрузить куки YouTube (личка)"),
                BotCommand(command="balance", description="💰 баланс и статистика"),
                BotCommand(command="deposit", description="💎 открыть вклад"),
                BotCommand(command="loan", description="💵 взять кредит"),
                BotCommand(command="transfer", description="💸 перевести деньги"),
                BotCommand(command="cb_stats", description="🏛️ статистика ЦБ"),
                BotCommand(command="central_bank", description="🏛️ детальная статистика ЦБ"),
                BotCommand(command="cb_rules", description="📖 правила ЦБ"),
            ]
            scopes = (
                BotCommandScopeDefault(),
                BotCommandScopeAllPrivateChats(),
                BotCommandScopeAllGroupChats(),
            )
            for scope in scopes:
                await bot.set_my_commands(commands, scope=scope)
        except Exception:
            logger.warning("Не удалось выставить команды, но бот продолжит работу.")
        
        # Запуск фоновых задач экономики
        asyncio.create_task(economy_background_tasks())

    dp.startup.register(on_startup)

    async def start_settle(bot: Bot):
        asyncio.create_task(_settle_loop(bot))
    dp.startup.register(start_settle)

    logger.info("Бот запущен...")
    asyncio.run(dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()))


async def economy_background_tasks():
    """Фоновые задачи для экономики"""
    while True:
        try:
            moscow_time = datetime.now(ZoneInfo("Europe/Moscow"))
            current_hour = moscow_time.hour
            current_minute = moscow_time.minute

            await asyncio.to_thread(economy.collect_daily_taxes)
            await asyncio.to_thread(economy.regulate_inflation)
            await asyncio.to_thread(economy.process_matured_deposits)

            if current_hour == 0 and current_minute == 0:
                users_paid = await asyncio.to_thread(economy.pay_daily_activity_rewards)
                if users_paid > 0:
                    logger.info(f"Activity rewards paid to {users_paid} users at 00:00 MSK")

            logger.info(f"Economy background tasks completed at {moscow_time.strftime('%H:%M:%S MSK')}")
        except Exception as e:
            logger.exception(f"Economy background task failed: {e}")

        await asyncio.sleep(60)

if __name__ == "__main__":
    main()
