# handlers/admin.py
from __future__ import annotations
import html
import json as _json2
import os
import logging
from datetime import date, datetime, timezone, timedelta
from aiogram import Router, Bot, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.text_decorations import html_decoration
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from config import validate_event_json, load_event, WelcomeConfig, IdleEvent
from db import (
    get_stats, get_unsubscribed_users, get_all_users, get_total_users,
    record_broadcast, get_material_click_stats, get_gate_stats,
    get_started_since, get_recent_broadcasts,
)
from services.broadcast import broadcast, broadcast_unsubscribed, broadcast_to_all
from services.calendar import format_event_time, google_calendar_url
from services.stats import format_stats_report
from services.archive import (
    DEFAULT_ARCHIVE_WELCOME,
    add_material,
    format_list_materials,
    remove_material,
    save_archive,
    set_required_channel,
    set_welcome,
)
from services.subscription import is_subscribed

logger = logging.getLogger(__name__)
router = Router()


class BroadcastStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class WelcomeStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class BroadcastUnsubscribedStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class BroadcastInviteAllStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class BroadcastZoomStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class BroadcastAllStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class AddMaterialStates(StatesGroup):
    waiting_for_title = State()
    waiting_for_description = State()
    waiting_for_button_label = State()
    waiting_for_url = State()


class SetArchiveWelcomeStates(StatesGroup):
    waiting_for_content = State()
    waiting_for_confirm = State()


class SetGateChannelStates(StatesGroup):
    waiting_for_channel = State()


def _archive_welcome_keyboard() -> InlineKeyboardBuilder:
    builder = InlineKeyboardBuilder()
    builder.row(
        InlineKeyboardButton(text="✅ Сохранить", callback_data="archive_welcome_confirm"),
        InlineKeyboardButton(text="❌ Отменить", callback_data="archive_welcome_cancel"),
    )
    return builder


TITLE_SOFT_LIMIT = 60


def _confirm_keyboard() -> InlineKeyboardBuilder:
    builder = InlineKeyboardBuilder()
    builder.row(
        InlineKeyboardButton(text="✅ Отправить", callback_data="broadcast_confirm"),
        InlineKeyboardButton(text="❌ Отменить", callback_data="broadcast_cancel"),
    )
    return builder


# (label, action) по типу события. action → callback_data "menu:<action>".
_MENU_ITEMS: dict = {
    "idle": [
        ("📢 Рассылка всем", "broadcast_all"),
        ("➕ Добавить материал", "add_material"),
        ("📋 Список материалов", "list_materials"),
        ("🗑 Удалить материал", "remove_material"),
        ("✏️ Приветствие витрины", "set_archive_welcome"),
        ("🔐 Канал для доступа", "set_gate_channel"),
        ("📊 Статистика", "stats"),
    ],
    "webinar": [
        ("📢 Пригласить всех", "broadcast_invite_all"),
        ("🔗 Разослать Zoom-ссылку", "broadcast_zoom"),
        ("✏️ Приветствие", "set_welcome"),
        ("📊 Статистика", "stats"),
        ("🔀 Перевести в архив", "to_idle"),
    ],
    "conference": [
        ("📺 Ссылка на трансляцию", "broadcast_stream"),
        ("🎬 Ссылка на запись", "broadcast_record"),
        ("✉️ Написать неподписавшимся", "broadcast_unsubscribed"),
        ("✏️ Приветствие", "set_welcome"),
        ("📊 Статистика", "stats"),
        ("🔀 Перевести в архив", "to_idle"),
    ],
}

# Реестр действий диспатча. Держать в синхроне с ветками on_menu_action —
# тест test_every_menu_action_is_dispatchable ловит кнопку без действия.
MENU_ACTIONS = frozenset({
    "broadcast_all", "add_material", "list_materials", "remove_material",
    "set_archive_welcome", "set_gate_channel", "stats", "broadcast_invite_all",
    "broadcast_zoom", "set_welcome", "to_idle", "broadcast_stream",
    "broadcast_record", "broadcast_unsubscribed",
})


def build_admin_menu(event_type: str) -> InlineKeyboardMarkup:
    """Inline-меню админа под текущий режим. Кнопки несут callback_data 'menu:<action>'."""
    builder = InlineKeyboardBuilder()
    for label, action in _MENU_ITEMS.get(event_type, []):
        builder.row(InlineKeyboardButton(text=label, callback_data=f"menu:{action}"))
    return builder.as_markup()


def _commands_for_type(event_type: str) -> str:
    if event_type == "conference":
        return "/broadcast_stream, /broadcast_record, /broadcast_unsubscribed"
    if event_type == "webinar":
        return "/broadcast_invite_all, /broadcast_zoom"
    if event_type == "idle":
        return "/broadcast_all, /add_material, /list_materials, /remove_material, /set_archive_welcome"
    return ""


async def _require_event_type(
    message: Message,
    event_holder: dict,
    expected: str,
    alt_commands: str = "",
    switch_command: str = "/set_event",
) -> bool:
    """Reply with a refusal if the current event isn't of `expected` type. Returns True if allowed."""
    current = event_holder["event"].type
    if current == expected:
        return True
    # If caller didn't pass explicit alt_commands, derive from current type.
    alts = alt_commands or _commands_for_type(current)
    await message.answer(
        f"❌ Эта команда доступна только для event типа <b>{expected}</b>. "
        f"Сейчас активен event типа <b>{current}</b>. "
        f"Смени event через {switch_command} или используй: {alts}",
        parse_mode="HTML",
    )
    return False


def setup_admin_handlers(
    db,
    event_holder: dict,
    event_path: str,
    admin_id: int,
    welcome_holder: dict,
    welcome_path: str,
    archive_holder: dict,
) -> None:
    # ВАЖНО: порядок регистрации хендлеров ниже — часть поведения, не оформление.
    # aiogram отдаёт update первому подходящему хендлеру, поэтому:
    #   • /cancel и /stats зарегистрированы раньше FSM-контент-хендлеров —
    #     работают даже когда бот ждёт текст рассылки; команды, объявленные ниже
    #     (/set_event, /add_material, …), в это время проглатываются как контент;
    #   • on_event_json (F.document, без стейт-фильтра) стоит после archive-флоу,
    #     но до welcome/unsubscribed/webinar — документ в их content-стейтах
    #     уходит в загрузку event.json.
    #   • контент-хендлер /broadcast_all стоит ДО on_event_json — документ,
    #     присланный посреди ввода текста рассылки, отклоняется, а не грузится
    #     как event.json;
    # Не переставлять и не разносить по модулям без тестов на этот порядок.
    event_holder["path"] = event_path

    async def _log_broadcast(conference_id, kind: str, report: dict) -> None:
        # Fail-open: logging a broadcast must never break the admin flow.
        try:
            await record_broadcast(
                db, conference_id, kind,
                delivered=report["delivered"], skipped=report["skipped"], errors=report["errors"],
            )
        except Exception as e:
            logger.warning("broadcast (%s) not logged: %s", kind, e)

    async def _send_stats(message: Message) -> None:
        event = event_holder["event"]
        stats = await get_stats(db, event.event_id)
        total = await get_total_users(db)
        now = datetime.now(timezone.utc)
        started_24h = await get_started_since(db, event.event_id, (now - timedelta(hours=24)).isoformat())
        started_7d = await get_started_since(db, event.event_id, (now - timedelta(days=7)).isoformat())
        recent = await get_recent_broadcasts(db, limit=5)

        material_rows = []
        gate_channel = None
        gate_delivered = gate_stuck = 0
        if event.type == "idle":
            archive = archive_holder["archive"]
            title_by_id = {m.id: m.title for m in archive.materials}
            for row in await get_material_click_stats(db, event.event_id):
                label = title_by_id.get(row["material_id"], row["material_id"])
                material_rows.append((label, row["clicks"], row["uniques"]))
            gate_channel = archive.required_channel
            gate = await get_gate_stats(db, event.event_id)
            gate_delivered, gate_stuck = gate["delivered"], gate["stuck"]

        text = format_stats_report(
            title=event.title, event_type=event.type,
            started=stats["started"], started_24h=started_24h, started_7d=started_7d,
            subscribed=stats["subscribed"], total=total, blocked=stats["blocked"],
            material_rows=material_rows, gate_channel=gate_channel,
            gate_delivered=gate_delivered, gate_stuck=gate_stuck, recent_broadcasts=recent,
        )
        await message.answer(text, parse_mode="HTML")

    @router.message(Command("stats"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_stats(message: Message) -> None:
        await _send_stats(message)

    async def _send_menu(message: Message) -> None:
        event_type = event_holder["event"].type
        titles = {"idle": "архив", "webinar": "вебинар", "conference": "конференция"}
        if event_type == "idle":
            hint = "\n\nЧтобы включить конференцию или вебинар — загрузи JSON-файл события (/set_event)."
        else:
            hint = "\n\nСменить тип события — загрузи JSON-файл (/set_event)."
        await message.answer(
            f"🛠 Меню админа · режим: <b>{titles.get(event_type, event_type)}</b>{hint}",
            reply_markup=build_admin_menu(event_type),
            parse_mode="HTML",
        )

    @router.message(Command("menu"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_menu(message: Message, state: FSMContext) -> None:
        await state.clear()
        await _send_menu(message)

    async def _start_broadcast(message: Message, state: FSMContext, label: str) -> None:
        await state.set_state(BroadcastStates.waiting_for_content)
        await state.update_data(label=label)
        await message.answer(
            f"Введи текст рассылки <b>{label}</b> (можно с форматированием HTML).\n"
            f"Или отправь фото с подписью — оно будет разослано как есть.\n\n"
            f"Для отмены: /cancel",
            parse_mode="HTML",
        )

    @router.message(Command("broadcast_stream"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_stream(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "conference", "/broadcast_invite_all, /broadcast_zoom"):
            return
        await _start_broadcast(message, state, "ссылки на трансляцию")

    @router.message(Command("broadcast_record"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_record(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "conference", "/broadcast_invite_all, /broadcast_zoom"):
            return
        await _start_broadcast(message, state, "ссылки на запись")

    @router.message(Command("cancel"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_cancel(message: Message, state: FSMContext) -> None:
        await state.clear()
        await message.answer("Отменено.")

    @router.message(BroadcastStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_broadcast_content(message: Message, state: FSMContext, bot: Bot) -> None:
        # Preserve HTML formatting (bold/italic/links from Telegram's built-in menu),
        # matching /broadcast_zoom and /broadcast_unsubscribed.
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text and not photo_file_id:
            await message.answer(
                "Нельзя отправить пустое сообщение. Пришли текст или фото с подписью."
            )
            return
        if photo_file_id and len(text) > 1024:
            await message.answer(
                f"❌ Подпись к фото слишком длинная: {len(text)} символов (максимум 1024).\n\n"
                "Сократи текст и попробуй снова."
            )
            return
        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(BroadcastStates.waiting_for_confirm)

        await message.answer("Превью сообщения 👇")
        if photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id, caption=text, parse_mode="HTML")
        else:
            await message.answer(text, parse_mode="HTML")
        await message.answer("Отправить эту рассылку?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        BroadcastStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_broadcast_confirm(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Рассылка отменена.")
            return

        data = await state.get_data()
        await state.clear()

        # Snapshot conference_id and speakers at broadcast start — race protection
        event = event_holder["event"]
        if event.type != "conference":
            await callback.message.answer("Событие изменилось, рассылка отменена.")
            return
        await callback.message.answer("⏳ Рассылка запущена...")
        report = await broadcast(
            bot=bot,
            db=db,
            conference_id=event.event_id,
            speakers=event.speakers,
            text=data["text"],
            photo_file_id=data.get("photo_file_id"),
        )
        await _log_broadcast(event.event_id, data.get("label", "рассылка"), report)
        await callback.message.answer(
            f"✅ Рассылка завершена.\n\n"
            f"Доставлено: {report['delivered']}\n"
            f"Пропущено (отписались / заблокировали): {report['skipped']}\n"
            f"Ошибки: {report['errors']}"
        )

    @router.message(
        Command("set_event", "set_conference"),
        lambda m: m.from_user is not None and m.from_user.id == admin_id,
    )
    async def cmd_set_event(message: Message) -> None:
        await message.answer(
            "Пришли файл <code>event.json</code> документом (не фото).\n"
            "Текущий event останется активным до успешной загрузки нового файла.",
            parse_mode="HTML",
        )

    async def _do_set_idle(message: Message) -> None:
        event_id = f"idle-{date.today().isoformat()}"
        idle_event = IdleEvent(event_id=event_id, type="idle", title="Архив")
        payload = {"event_id": event_id, "type": "idle", "title": "Архив"}
        tmp = event_holder["path"] + ".tmp"
        try:
            with open(tmp, "w", encoding="utf-8") as f:
                _json2.dump(payload, f, ensure_ascii=False, indent=2)
            os.replace(tmp, event_holder["path"])
        except OSError as e:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            await message.answer(f"❌ Ошибка сохранения event.json: {e}")
            return
        event_holder["event"] = idle_event
        await message.answer("✅ Бот в режиме архива.")

    @router.message(Command("set_idle"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_set_idle(message: Message) -> None:
        await _do_set_idle(message)

    async def _start_add_material(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        await state.set_state(AddMaterialStates.waiting_for_title)
        await message.answer(
            "Введи название материала (текст кнопки, до ~60 символов).\n\n"
            "Для отмены: /cancel",
        )

    @router.message(Command("add_material"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_add_material(message: Message, state: FSMContext) -> None:
        await _start_add_material(message, state)

    @router.message(AddMaterialStates.waiting_for_title, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_material_title(message: Message, state: FSMContext) -> None:
        title = (message.text or "").strip()
        if not title:
            await message.answer("Пустое название. Введи ещё раз.")
            return
        if len(title) > TITLE_SOFT_LIMIT:
            await message.answer(
                f"Длина {len(title)} > {TITLE_SOFT_LIMIT}. На маленьких экранах кнопка обрежется. "
                "Введи покороче."
            )
            return
        await state.update_data(title=title)
        await state.set_state(AddMaterialStates.waiting_for_description)
        await message.answer(
            "Введи описание (можно HTML — bold/italic/ссылки из меню Telegram сохранятся).\n\n"
            "Для отмены: /cancel",
        )

    @router.message(AddMaterialStates.waiting_for_description, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_material_description(message: Message, state: FSMContext) -> None:
        # Preserve formatting (bold/italic/links from Telegram's built-in menu).
        description = message.html_text or ""
        if not description.strip():
            await message.answer("Пустое описание. Введи ещё раз.")
            return
        await state.update_data(description=description)
        await state.set_state(AddMaterialStates.waiting_for_button_label)
        await message.answer(
            "Введи текст кнопки (например «💻 Открыть скилл на GitHub»).\n"
            "Отправь «-», чтобы оставить стандартную «🔗 Смотреть запись».\n\n"
            "Для отмены: /cancel",
        )

    @router.message(AddMaterialStates.waiting_for_button_label, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_material_button_label(message: Message, state: FSMContext) -> None:
        raw = (message.text or "").strip()
        if not raw:
            await message.answer("Пусто. Введи текст кнопки или «-» для стандартной.")
            return
        if raw == "-":
            label = ""  # add_material подставит дефолт
        elif len(raw) > 50:
            await message.answer("Слишком длинно, введи короче (до 50 символов).")
            return
        else:
            label = raw
        await state.update_data(button_label=label)
        await state.set_state(AddMaterialStates.waiting_for_url)
        await message.answer(
            "Введи ссылку (https://...).\n\n"
            "Для отмены: /cancel",
        )

    @router.message(AddMaterialStates.waiting_for_url, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_material_url(message: Message, state: FSMContext) -> None:
        url = (message.text or "").strip()
        if not (url.startswith("https://") or url.startswith("http://")):
            await message.answer("Нужна ссылка вида https://... Введи ещё раз.")
            return
        data = await state.get_data()
        await state.clear()

        archive = archive_holder["archive"]
        mat = add_material(
            archive,
            title=data["title"],
            description=data["description"],
            url=url,
            button_label=data.get("button_label", ""),
        )
        try:
            save_archive(archive_holder["path"], archive)
        except OSError as e:
            # Roll back in-memory mutation
            remove_material(archive, mat.id)
            await message.answer(f"❌ Ошибка сохранения archive.json: {e}")
            return

        await message.answer(f"✅ Материал добавлен.\nid: <code>{mat.id}</code>", parse_mode="HTML")

    async def _start_set_gate_channel(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        current = archive_holder["archive"].required_channel
        current_line = f"Сейчас: {current}" if current else "Сейчас: гейт выключен (архив открыт для всех)."
        await state.set_state(SetGateChannelStates.waiting_for_channel)
        await message.answer(
            f"🔐 Канал для доступа к материалам.\n{current_line}\n\n"
            "Пришли публичный @канал, подписку на который проверять. "
            "Или «-», чтобы снять гейт.\n\n"
            "⚠️ Бот должен быть админом в этом канале, иначе проверка не заработает.\n\n"
            "Для отмены: /cancel",
        )

    @router.message(SetGateChannelStates.waiting_for_channel, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_gate_channel_input(message: Message, state: FSMContext, bot: Bot) -> None:
        raw = (message.text or "").strip()
        if not raw:
            await message.answer("Пусто. Пришли @канал или «-», чтобы снять гейт.")
            return
        if raw != "-" and not raw.startswith("@"):
            await message.answer("Нужен публичный канал вида @channel (или «-», чтобы снять гейт).")
            return
        await state.clear()
        archive = archive_holder["archive"]
        old = archive.required_channel
        new_channel = None if raw == "-" else raw
        set_required_channel(archive, new_channel)
        try:
            save_archive(archive_holder["path"], archive)
        except OSError as e:
            set_required_channel(archive, old)  # roll back in-memory
            await message.answer(f"❌ Ошибка сохранения archive.json: {e}")
            return
        if new_channel is None:
            await message.answer("✅ Гейт снят — архив открыт для всех.")
            return
        # Smoke-test bot access; warn (don't block) when it can't read membership.
        status = await is_subscribed(bot, admin_id, new_channel)
        if status is None:
            await message.answer(
                f"✅ Канал сохранён: {new_channel}\n\n"
                "⚠️ Бот не админ в этом канале или канал недоступен — проверка подписки "
                "не заработает, пока не добавишь бота в админы канала."
            )
        else:
            await message.answer(f"✅ Канал сохранён: {new_channel}. Проверка подписки включена.")

    @router.message(Command("list_materials"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_list_materials(message: Message) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        text = format_list_materials(archive_holder["archive"].materials)
        await message.answer(text)

    @router.message(Command("remove_material"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_remove_material(message: Message) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        # Parse "<id>" from "/remove_material <id>"
        parts = (message.text or "").split(maxsplit=1)
        if len(parts) < 2 or not parts[1].strip():
            await message.answer("Использование: /remove_material &lt;id&gt;\nСписок: /list_materials", parse_mode="HTML")
            return
        material_id = parts[1].strip()
        archive = archive_holder["archive"]
        target = next((m for m in archive.materials if m.id == material_id), None)
        if target is None:
            await message.answer(
                f"❌ Не нашёл материал с id <code>{material_id}</code>. Список: /list_materials",
                parse_mode="HTML",
            )
            return
        builder = InlineKeyboardBuilder()
        builder.row(
            InlineKeyboardButton(text="✅ Удалить", callback_data=f"rm_yes:{material_id}"),
            InlineKeyboardButton(text="❌ Отмена", callback_data="remove_material_cancel"),
        )
        await message.answer(
            f"Удалить «{target.title}»?",
            reply_markup=builder.as_markup(),
        )

    @router.callback_query(lambda c: c.data is not None and (c.data.startswith("rm_yes:") or c.data == "remove_material_cancel"))
    async def on_remove_material_confirm(callback: CallbackQuery) -> None:
        if callback.from_user.id != admin_id:
            await callback.answer("Только для админа.")
            return
        await callback.answer()
        if callback.data == "remove_material_cancel":
            await callback.message.answer("Отменено.")
            return
        material_id = callback.data.split(":", 1)[1]
        archive = archive_holder["archive"]
        # Snapshot for rollback
        target = next((m for m in archive.materials if m.id == material_id), None)
        if target is None:
            await callback.message.answer("Материал уже удалён.")
            return
        idx = archive.materials.index(target)
        removed = remove_material(archive, material_id)
        if not removed:
            await callback.message.answer("Материал уже удалён.")
            return
        try:
            save_archive(archive_holder["path"], archive)
        except OSError as e:
            # Roll back in-memory
            archive.materials.insert(idx, target)
            await callback.message.answer(f"❌ Ошибка сохранения archive.json: {e}")
            return
        await callback.message.answer(f"✅ Удалён: «{target.title}»")

    @router.callback_query(lambda c: c.data is not None and c.data.startswith("menu:"))
    async def on_menu_action(callback: CallbackQuery, state: FSMContext) -> None:
        if callback.from_user.id != admin_id:
            await callback.answer("Только для админа.")
            return
        await callback.answer()
        # Данные брошенного флоу не должны протекать в новый.
        await state.clear()
        action = callback.data.split(":", 1)[1]
        msg = callback.message
        event_type = event_holder["event"].type
        valid = {a for _, a in _MENU_ITEMS.get(event_type, [])}
        if action not in valid:
            # Протухшее меню: режим сменился после отрисовки кнопок.
            await msg.answer("Режим сменился 👇")
            await _send_menu(msg)
            return

        if action == "broadcast_all":
            await _start_broadcast_all(msg, state)
        elif action == "broadcast_invite_all":
            await _start_broadcast_invite_all(msg, state)
        elif action == "broadcast_zoom":
            await _start_broadcast_zoom(msg, state)
        elif action == "broadcast_stream":
            await _start_broadcast(msg, state, "ссылки на трансляцию")
        elif action == "broadcast_record":
            await _start_broadcast(msg, state, "ссылки на запись")
        elif action == "broadcast_unsubscribed":
            await _start_broadcast_unsubscribed(msg, state)
        elif action == "add_material":
            await _start_add_material(msg, state)
        elif action == "set_archive_welcome":
            await _start_set_archive_welcome(msg, state)
        elif action == "set_gate_channel":
            await _start_set_gate_channel(msg, state)
        elif action == "set_welcome":
            await _start_set_welcome(msg, state)
        elif action == "list_materials":
            await msg.answer(format_list_materials(archive_holder["archive"].materials))
        elif action == "remove_material":
            materials = archive_holder["archive"].materials
            if not materials:
                await msg.answer("Материалов нет.")
                return
            builder = InlineKeyboardBuilder()
            for m in materials:
                builder.row(InlineKeyboardButton(text=f"🗑 {m.title}", callback_data=f"mrm:{m.id}"))
            await msg.answer("Какой материал удалить?", reply_markup=builder.as_markup())
        elif action == "stats":
            await _send_stats(msg)
        elif action == "to_idle":
            builder = InlineKeyboardBuilder()
            builder.row(
                InlineKeyboardButton(text="✅ Перевести", callback_data="to_idle_yes"),
                InlineKeyboardButton(text="❌ Отмена", callback_data="to_idle_cancel"),
            )
            await msg.answer(
                "Точно перевести бота в режим архива? Пользователи сразу увидят витрину вместо события.",
                reply_markup=builder.as_markup(),
            )
        else:
            # Кнопка есть в _MENU_ITEMS, но ветки нет — рассинхрон с MENU_ACTIONS.
            await msg.answer("Неизвестное действие — набери /menu.")

    @router.callback_query(lambda c: c.data is not None and c.data.startswith("mrm:"))
    async def on_menu_rm_pick(callback: CallbackQuery) -> None:
        if callback.from_user.id != admin_id:
            await callback.answer("Только для админа.")
            return
        await callback.answer()
        material_id = callback.data.split(":", 1)[1]
        target = next((m for m in archive_holder["archive"].materials if m.id == material_id), None)
        if target is None:
            await callback.message.answer("Материал уже удалён.")
            return
        builder = InlineKeyboardBuilder()
        builder.row(
            InlineKeyboardButton(text="✅ Удалить", callback_data=f"rm_yes:{material_id}"),
            InlineKeyboardButton(text="❌ Отмена", callback_data="remove_material_cancel"),
        )
        await callback.message.answer(f"Удалить «{target.title}»?", reply_markup=builder.as_markup())

    @router.callback_query(lambda c: c.data in ("to_idle_yes", "to_idle_cancel"))
    async def on_to_idle_confirm(callback: CallbackQuery) -> None:
        if callback.from_user.id != admin_id:
            await callback.answer("Только для админа.")
            return
        await callback.answer()
        if callback.data == "to_idle_cancel":
            await callback.message.answer("Отменено.")
            return
        if event_holder["event"].type == "idle":
            await callback.message.answer("Бот уже в режиме архива.")
            return
        await _do_set_idle(callback.message)
        await _send_menu(callback.message)

    async def _start_set_archive_welcome(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        await state.set_state(SetArchiveWelcomeStates.waiting_for_content)
        await message.answer(
            "Пришли текст приветствия для архива (HTML / форматирование Telegram сохранится).\n"
            "Можно прислать фото с подписью.\n\n"
            "Для отмены: /cancel",
        )

    @router.message(Command("set_archive_welcome"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_set_archive_welcome(message: Message, state: FSMContext) -> None:
        await _start_set_archive_welcome(message, state)

    @router.message(SetArchiveWelcomeStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_archive_welcome_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text:
            await message.answer("Нужен текст (или фото с подписью). Попробуй ещё раз.")
            return
        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(SetArchiveWelcomeStates.waiting_for_confirm)
        await message.answer("Превью приветствия 👇")
        if photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id, caption=text, parse_mode="HTML")
        else:
            await message.answer(text, parse_mode="HTML")
        await message.answer("Сохранить?", reply_markup=_archive_welcome_keyboard().as_markup())

    @router.callback_query(
        SetArchiveWelcomeStates.waiting_for_confirm,
        lambda c: c.data in ("archive_welcome_confirm", "archive_welcome_cancel"),
    )
    async def on_archive_welcome_confirm(callback: CallbackQuery, state: FSMContext) -> None:
        await callback.answer()
        if callback.data == "archive_welcome_cancel":
            await state.clear()
            await callback.message.answer("Отменено.")
            return
        data = await state.get_data()
        await state.clear()
        archive = archive_holder["archive"]
        # Snapshot for rollback
        old_welcome = archive.welcome
        set_welcome(archive, text=data["text"], photo_file_id=data.get("photo_file_id"))
        try:
            save_archive(archive_holder["path"], archive)
        except OSError as e:
            archive.welcome = old_welcome
            await callback.message.answer(f"❌ Ошибка сохранения archive.json: {e}")
            return
        await callback.message.answer("✅ Приветствие архива обновлено.")

    async def _start_broadcast_all(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "idle", switch_command="/set_idle"):
            return
        await state.set_state(BroadcastAllStates.waiting_for_content)
        await message.answer(
            "Введи текст объявления для рассылки всем, кто запускал бота "
            "(HTML-форматирование поддерживается). Можно фото с подписью.\n\n"
            "Кнопок не будет — добавь в текст призыв нажать /start.\n\n"
            "Для отмены: /cancel",
            parse_mode="HTML",
        )

    @router.message(Command("broadcast_all"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_all(message: Message, state: FSMContext) -> None:
        await _start_broadcast_all(message, state)

    @router.message(BroadcastAllStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_broadcast_all_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text and not photo_file_id:
            await message.answer("Нельзя отправить пустое сообщение. Пришли текст или фото с подписью.")
            return

        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(BroadcastAllStates.waiting_for_confirm)

        count = len(await get_all_users(db, exclude_user_ids={admin_id}))
        await message.answer(f"Получат: <b>{count} человек</b>", parse_mode="HTML")
        await message.answer("Превью сообщения 👇")

        if photo_file_id and len(text) <= 1024:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id, caption=text or None, parse_mode="HTML")
        elif photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id)
            await message.answer(text, parse_mode="HTML", disable_web_page_preview=True)
        else:
            await message.answer(text, parse_mode="HTML", disable_web_page_preview=True)

        await message.answer("Отправить эту рассылку?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        BroadcastAllStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_broadcast_all_confirm(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Рассылка отменена.")
            return

        data = await state.get_data()
        await state.clear()

        await callback.message.answer("⏳ Рассылка запущена...")
        report = await broadcast_to_all(
            bot=bot,
            db=db,
            text=data["text"],
            photo_file_id=data.get("photo_file_id"),
            keyboard=None,
            exclude_user_ids={admin_id},
        )
        await _log_broadcast(None, "всем", report)
        await callback.message.answer(
            f"✅ Рассылка завершена.\n\n"
            f"Доставлено: {report['delivered']}\n"
            f"Пропущено (заблокировали): {report['skipped']}\n"
            f"Ошибки: {report['errors']}"
        )

    @router.message(F.document, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_event_json(message: Message, bot: Bot) -> None:
        if not (message.document.file_name or "").endswith(".json"):
            await message.answer("Ожидается файл с расширением .json")
            return
        try:
            file = await bot.get_file(message.document.file_id)
            raw = await bot.download_file(file.file_path)
            content = raw.read()
        except Exception as e:
            logger.error("Failed to download event.json: %s", e)
            await message.answer(f"❌ Не удалось загрузить файл: {e}\nТекущий event не изменён.")
            return
        try:
            new_event = validate_event_json(content)
        except ValueError as e:
            await message.answer(f"❌ Ошибка в файле: {e}\nТекущий event не изменён.")
            return

        tmp_path = event_holder["path"] + ".tmp"
        try:
            with open(tmp_path, "wb") as f:
                f.write(content)
            os.replace(tmp_path, event_holder["path"])
        except OSError as e:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            logger.error("Failed to write event.json: %s", e)
            await message.answer(f"❌ Ошибка при сохранении файла: {e}\nТекущий event не изменён.")
            return
        event_holder["event"] = new_event

        if new_event.type == "webinar":
            when = format_event_time(new_event, include_utc=True)
            await message.answer(
                f"✅ Event обновлён: <b>{new_event.title}</b>\n"
                f"Тип: webinar\n"
                f"Когда: {when}",
                parse_mode="HTML",
            )
        elif new_event.type == "idle":
            await message.answer(
                f"✅ Event обновлён: <b>{new_event.title}</b>\n"
                f"Тип: idle (архив)",
                parse_mode="HTML",
            )
        else:
            await message.answer(
                f"✅ Event обновлён: <b>{new_event.title}</b>\n"
                f"Тип: conference\n"
                f"Спикеров: {len(new_event.speakers)}",
                parse_mode="HTML",
            )

    async def _start_set_welcome(message: Message, state: FSMContext) -> None:
        await state.set_state(WelcomeStates.waiting_for_content)
        await message.answer(
            "Пришли фото с текстом приветствия (caption).\n\n"
            "Ссылки на спикеров добавляй через встроенное форматирование Telegram: "
            "выдели слово → Форматирование → Ссылка.\n\n"
            "Для отмены: /cancel",
        )

    @router.message(Command("set_welcome"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_set_welcome(message: Message, state: FSMContext) -> None:
        await _start_set_welcome(message, state)

    @router.message(WelcomeStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_welcome_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text:
            await message.answer("Нужен текст (или фото с подписью). Попробуй ещё раз.")
            return
        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(WelcomeStates.waiting_for_confirm)
        await message.answer("Превью приветствия 👇")
        if photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id, caption=text, parse_mode="HTML")
        else:
            await message.answer(text, parse_mode="HTML")
        await message.answer("Сохранить это приветствие?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        WelcomeStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_welcome_confirm(callback: CallbackQuery, state: FSMContext) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Отменено.")
            return
        data = await state.get_data()
        await state.clear()
        import json as _json
        new_welcome = WelcomeConfig(text=data["text"], photo_file_id=data.get("photo_file_id"))
        tmp_path = welcome_path + ".tmp"
        try:
            with open(tmp_path, "w", encoding="utf-8") as f:
                _json.dump({"text": new_welcome.text, "photo_file_id": new_welcome.photo_file_id}, f, ensure_ascii=False, indent=2)
            os.replace(tmp_path, welcome_path)
        except OSError as e:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            await callback.message.answer(f"❌ Ошибка сохранения: {e}")
            return
        welcome_holder["welcome"] = new_welcome
        await callback.message.answer("✅ Приветственное сообщение обновлено.")

    async def _start_broadcast_unsubscribed(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "conference", "/broadcast_invite_all, /broadcast_zoom"):
            return
        await state.set_state(BroadcastUnsubscribedStates.waiting_for_content)
        await message.answer(
            "Введи текст рассылки для тех, кто не подписался (можно с форматированием HTML).\n"
            "Или отправь фото с подписью.\n\n"
            "Список спикеров и кнопка подписки добавятся автоматически.\n\n"
            "Для отмены: /cancel",
            parse_mode="HTML",
        )

    @router.message(Command("broadcast_unsubscribed"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_unsubscribed(message: Message, state: FSMContext) -> None:
        await _start_broadcast_unsubscribed(message, state)

    @router.message(BroadcastUnsubscribedStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_unsubscribed_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text and not photo_file_id:
            await message.answer("Нельзя отправить пустое сообщение. Пришли текст или фото с подписью.")
            return
        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(BroadcastUnsubscribedStates.waiting_for_confirm)

        event = event_holder["event"]
        count = len(await get_unsubscribed_users(db, event.event_id))
        speakers_list = "\n".join(
            f"• <a href='https://t.me/{s.channel.lstrip('@')}'>{html.escape(s.name)}</a>"
            for s in event.speakers
        )
        full_text = f"{text}\n\n{speakers_list}" if text else speakers_list
        builder = InlineKeyboardBuilder()
        builder.row(InlineKeyboardButton(
            text="✅ Я подписался на всех",
            callback_data=f"check_sub:{event.event_id}",
        ))

        await message.answer(f"Получат: <b>{count} человек</b>", parse_mode="HTML")
        await message.answer("Превью сообщения 👇")
        if photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id)
        await message.answer(full_text, reply_markup=builder.as_markup(), parse_mode="HTML", disable_web_page_preview=True)
        await message.answer("Отправить эту рассылку?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        BroadcastUnsubscribedStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_unsubscribed_confirm(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Рассылка отменена.")
            return

        data = await state.get_data()
        await state.clear()

        event = event_holder["event"]
        builder = InlineKeyboardBuilder()
        builder.row(InlineKeyboardButton(
            text="✅ Я подписался на всех",
            callback_data=f"check_sub:{event.event_id}",
        ))
        keyboard = builder.as_markup()

        await callback.message.answer("⏳ Рассылка запущена...")
        report = await broadcast_unsubscribed(
            bot=bot,
            db=db,
            conference_id=event.event_id,
            speakers=event.speakers,
            text=data["text"],
            photo_file_id=data.get("photo_file_id"),
            keyboard=keyboard,
        )
        await _log_broadcast(event.event_id, "неподписавшимся", report)
        await callback.message.answer(
            f"✅ Рассылка завершена.\n\n"
            f"Доставлено: {report['delivered']}\n"
            f"Пропущено (заблокировали): {report['skipped']}\n"
            f"Ошибки: {report['errors']}"
        )

    async def _start_broadcast_invite_all(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "webinar", "/broadcast_stream, /broadcast_record"):
            return
        await state.set_state(BroadcastInviteAllStates.waiting_for_content)
        await message.answer(
            "Введи текст приглашения на вебинар (можно с форматированием HTML).\n"
            "Или отправь фото с подписью.\n\n"
            "Кнопка <b>📅 Добавить в Google Calendar</b> прикрепится автоматически.\n\n"
            "Для отмены: /cancel",
            parse_mode="HTML",
        )

    @router.message(Command("broadcast_invite_all"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_invite_all(message: Message, state: FSMContext) -> None:
        await _start_broadcast_invite_all(message, state)

    @router.message(BroadcastInviteAllStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_invite_all_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text and not photo_file_id:
            await message.answer("Нельзя отправить пустое сообщение. Пришли текст или фото с подписью.")
            return

        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(BroadcastInviteAllStates.waiting_for_confirm)

        event = event_holder["event"]
        count = len(await get_all_users(db, exclude_user_ids={admin_id}))

        builder = InlineKeyboardBuilder()
        builder.row(InlineKeyboardButton(
            text="📅 Добавить в Google Calendar",
            url=google_calendar_url(event),
        ))
        keyboard = builder.as_markup()

        await message.answer(f"Получат: <b>{count} человек</b>", parse_mode="HTML")
        await message.answer("Превью сообщения 👇")

        # Render the preview exactly like users will see it.
        if photo_file_id and len(text) <= 1024:
            await bot.send_photo(
                chat_id=message.chat.id,
                photo=photo_file_id,
                caption=text or None,
                reply_markup=keyboard,
                parse_mode="HTML",
            )
        elif photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id)
            await message.answer(text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True)
        else:
            await message.answer(text, reply_markup=keyboard, parse_mode="HTML", disable_web_page_preview=True)

        await message.answer("Отправить эту рассылку?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        BroadcastInviteAllStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_invite_all_confirm(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Рассылка отменена.")
            return

        data = await state.get_data()
        await state.clear()

        event = event_holder["event"]
        builder = InlineKeyboardBuilder()
        builder.row(InlineKeyboardButton(
            text="📅 Добавить в Google Calendar",
            url=google_calendar_url(event),
        ))
        keyboard = builder.as_markup()

        await callback.message.answer("⏳ Рассылка запущена...")
        report = await broadcast_to_all(
            bot=bot,
            db=db,
            text=data["text"],
            photo_file_id=data.get("photo_file_id"),
            keyboard=keyboard,
            exclude_user_ids={admin_id},
        )
        await _log_broadcast(None, "приглашение всех", report)
        await callback.message.answer(
            f"✅ Рассылка завершена.\n\n"
            f"Доставлено: {report['delivered']}\n"
            f"Пропущено (заблокировали): {report['skipped']}\n"
            f"Ошибки: {report['errors']}"
        )

    async def _start_broadcast_zoom(message: Message, state: FSMContext) -> None:
        if not await _require_event_type(message, event_holder, "webinar", "/broadcast_stream, /broadcast_record"):
            return
        await state.set_state(BroadcastZoomStates.waiting_for_content)
        await message.answer(
            "Введи текст с Zoom-ссылкой (HTML-форматирование поддерживается). "
            "Можно отправить фото с подписью.\n\n"
            "Кнопок не будет — это финальный шаг.\n\n"
            "Для отмены: /cancel",
            parse_mode="HTML",
        )

    @router.message(Command("broadcast_zoom"), lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def cmd_broadcast_zoom(message: Message, state: FSMContext) -> None:
        await _start_broadcast_zoom(message, state)

    @router.message(BroadcastZoomStates.waiting_for_content, lambda m: m.from_user is not None and m.from_user.id == admin_id)
    async def on_zoom_content(message: Message, state: FSMContext, bot: Bot) -> None:
        if message.caption is not None:
            text = html_decoration.unparse(message.caption, message.caption_entities or [])
        else:
            text = message.html_text or ""
        photo_file_id = message.photo[-1].file_id if message.photo else None
        if not text and not photo_file_id:
            await message.answer("Нельзя отправить пустое сообщение. Пришли текст или фото с подписью.")
            return

        await state.update_data(text=text, photo_file_id=photo_file_id)
        await state.set_state(BroadcastZoomStates.waiting_for_confirm)

        count = len(await get_all_users(db, exclude_user_ids={admin_id}))
        await message.answer(f"Получат: <b>{count} человек</b>", parse_mode="HTML")
        await message.answer("Превью сообщения 👇")

        if photo_file_id and len(text) <= 1024:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id, caption=text or None, parse_mode="HTML")
        elif photo_file_id:
            await bot.send_photo(chat_id=message.chat.id, photo=photo_file_id)
            await message.answer(text, parse_mode="HTML", disable_web_page_preview=True)
        else:
            await message.answer(text, parse_mode="HTML", disable_web_page_preview=True)

        await message.answer("Отправить эту рассылку?", reply_markup=_confirm_keyboard().as_markup())

    @router.callback_query(
        BroadcastZoomStates.waiting_for_confirm,
        lambda c: c.data in ("broadcast_confirm", "broadcast_cancel"),
    )
    async def on_zoom_confirm(callback: CallbackQuery, state: FSMContext, bot: Bot) -> None:
        await callback.answer()
        if callback.data == "broadcast_cancel":
            await state.clear()
            await callback.message.answer("Рассылка отменена.")
            return

        data = await state.get_data()
        await state.clear()

        await callback.message.answer("⏳ Рассылка запущена...")
        report = await broadcast_to_all(
            bot=bot,
            db=db,
            text=data["text"],
            photo_file_id=data.get("photo_file_id"),
            keyboard=None,
            exclude_user_ids={admin_id},
        )
        await _log_broadcast(None, "zoom-ссылка", report)
        await callback.message.answer(
            f"✅ Рассылка завершена.\n\n"
            f"Доставлено: {report['delivered']}\n"
            f"Пропущено (заблокировали): {report['skipped']}\n"
            f"Ошибки: {report['errors']}"
        )
