"""
spam.py — логика рассылки (run_spam_job, send_to_target, probe_spamblock).
Редактируй поведение при отправке сообщений и обработке SpamBot здесь.
"""
import asyncio
import re
import random
import time
from typing import Optional, Tuple, List, Dict

from telethon import errors
from telethon.tl import types as tltypes
from telethon import TelegramClient, events

from config import SPAM_ANTIBAN_PARAMS
from bot_ui.logger_setup import (
    log_spam_start, log_spam_result, log_spam_finish,
)
import logging
# Свой логгер «telepush.spam» — UI-консоль роутит его на вкладку «Рассылка»,
# а не «Системные» как раньше (когда импортировался общий «telepush»).
logger = logging.getLogger("telepush.spam")
from models import Account, SpamJob
from core.database import store
from bot_ui.utils import spintax_expand, get_user_timing
from bot_ui.ui import (
    _job_log_add, _sleep_with_job_ui,
    progress_bar, _eta_text, _status_label,
)
from core.captcha import wait_for_event_compat


# ── Триггер-слова (Telegram детектит как спам в DM) ──────────────────────────
DM_TRIGGER_WORDS = [
    "нужно", "нужны", "требуется", "требуються", "срочно",
    "ищем", "ищу", "вакансия", "задача", "работа",
    "разнорабочий", "заработок", "подработка", "шабашка", "халтура",
    "рубль", "рублей", "деньги", "доход",
    "оплата", "выплата", "плачу", "заплачу", "зарплата",
    "доставка", "курьер", "помощь", "помощник", "лс",
]


def check_dm_triggers(text: str) -> List[str]:
    """Проверяет текст на триггер-слова. Возвращает список найденных."""
    # Раскрываем спинтакс чтобы проверить все варианты
    lowered = text.lower()
    # Также проверяем внутри {вариант1|вариант2} — берём все части
    import re as _re
    # Извлекаем все варианты из спинтакса
    parts = _re.findall(r'\{([^}]+)\}', lowered)
    all_text = lowered
    for p in parts:
        all_text += " " + " ".join(p.split("|"))

    found = []
    for word in DM_TRIGGER_WORDS:
        # Ищем как отдельное слово (с границами)
        pattern = r'(?:^|[\s,.!?;:\-])' + re.escape(word) + r'(?:$|[\s,.!?;:\-])'
        if re.search(pattern, all_text):
            found.append(word)
    return found

try:
    from telethon.errors.rpcerrorlist import AuthKeyDuplicatedError
except ImportError:
    class AuthKeyDuplicatedError(Exception): pass  # type: ignore

try:
    from patches import (
        FrozenMethodInvalidError, FrozenParticipantMissingError,
        _FrozenParticipantMissingError,
    )
except ImportError:
    try:
        from telethon.errors.rpcerrorlist import FrozenMethodInvalidError as _FMI
        FrozenMethodInvalidError = _FMI
    except ImportError:
        class FrozenMethodInvalidError(Exception): pass  # type: ignore
    try:
        from telethon.errors.rpcerrorlist import FrozenParticipantMissingError as _FPME
        FrozenParticipantMissingError = _FPME
    except ImportError:
        class FrozenParticipantMissingError(Exception): pass  # type: ignore
    _FrozenParticipantMissingError = FrozenParticipantMissingError

from core.client_builder import safe_disconnect


def can_write_to_chat(ent) -> bool:
    if isinstance(ent, tltypes.Channel):
        if getattr(ent, 'broadcast', False):
            return False
        # Форум (топики) — нельзя писать в общий чат, только в топик
        if getattr(ent, 'forum', False):
            return False
        dbr = getattr(ent, 'default_banned_rights', None)
        if dbr:
            if getattr(dbr, 'send_messages', False):
                return False
            if getattr(dbr, 'send_plain', False):
                return False
    elif isinstance(ent, tltypes.Chat):
        # Деактивированный чат
        if getattr(ent, 'deactivated', False):
            return False
        dbr = getattr(ent, 'default_banned_rights', None)
        if dbr:
            if getattr(dbr, 'send_messages', False):
                return False
            if getattr(dbr, 'send_plain', False):
                return False
    return True


async def iter_writable_groups(client: TelegramClient):
    async for d in client.iter_dialogs(limit=None):
        ent = d.entity
        if isinstance(ent, tltypes.Chat):
            if getattr(ent, 'left', False):
                continue
            if not can_write_to_chat(ent):
                continue
            yield d
        elif isinstance(ent, tltypes.Channel):
            if getattr(ent, 'broadcast', False):
                continue
            if getattr(ent, 'left', False):
                continue
            if getattr(ent, 'megagroup', False) or getattr(ent, 'gigagroup', False):
                if not can_write_to_chat(ent):
                    continue
                yield d


async def collect_writable_group_ids(client: TelegramClient) -> list:
    """Собирает ID всех чатов куда можно написать."""
    result = []
    try:
        async for d in iter_writable_groups(client):
            result.append(d.id)
    except Exception:
        pass
    return result


async def count_writable_groups(acc: Account) -> int:
    if acc.client is None:
        return 0
    c = 0
    try:
        async for _ in iter_writable_groups(acc.client):
            c += 1
        return c
    except (errors.AuthKeyUnregisteredError, errors.SessionRevokedError, AuthKeyDuplicatedError) as e:
        try:
            acc.dead = True
            acc.dead_reason = type(e).__name__
            await safe_disconnect(acc.client)
        except Exception:
            pass
        return 0
    except Exception as e:
        try:
            logger.warning("count_writable_groups failed for %s: %s", getattr(acc, "phone", "?"), e)
        except Exception:
            pass
        return c


async def collect_writable_groups(acc: Account) -> Tuple[List[int], Dict[int, str]]:
    try:
        from core.client_builder import ensure_connected
        if not await ensure_connected(acc):
            logger.warning("[spam] %s: не удалось подключиться", getattr(acc, "phone", "?"))
            return [], {}
    except Exception as _ce:
        logger.warning("[spam] %s: ошибка переподключения: %s", getattr(acc, "phone", "?"), _ce)
        return [], {}

    if acc.client is None:
        logger.warning("[spam] %s: client is None after ensure_connected", getattr(acc, "phone", "?"))
        return [], {}

    ids: List[int] = []
    titles: Dict[int, str] = {}
    try:
        async for d in iter_writable_groups(acc.client):
            ids.append(int(d.id))
            titles[int(d.id)] = getattr(d.entity, "title", str(d.id))
    except Exception as _e:
        logger.warning("[spam] collect_writable_groups %s: %s", getattr(acc, "phone", "?"), _e)
    return ids, titles


def parse_dm_targets(text: str) -> Tuple[List[str], Dict[str, str], int]:
    """
    Парсит текст (по строкам) БЕЗ резолва — возвращает (targets, titles, failed).
    Targets — строки: "@username" или "12345678" (ID).
    Резолв будет при отправке (send_to_target поддерживает username).
    """
    targets: List[str] = []
    titles: Dict[str, str] = {}
    seen: set = set()
    failed = 0

    lines = [l.strip() for l in text.replace(",", "\n").splitlines() if l.strip()]
    for line in lines:
        target = line.strip()
        # t.me/username
        m = re.match(r"(?:https?://)?t\.me/([A-Za-z]\w{3,})", target)
        if m:
            target = "@" + m.group(1)
        # Числовой ID
        elif re.match(r"^\d{5,}$", target):
            pass  # оставляем как строку "12345678"
        # @username
        elif target.startswith("@"):
            pass  # уже ок
        # username без @
        elif re.match(r"^[A-Za-z]\w{3,}$", target):
            target = "@" + target
        else:
            failed += 1
            continue

        key = target.lower()
        if key in seen:
            continue
        seen.add(key)
        targets.append(target)
        titles[target] = target  # display = сам username/id

    logger.info("[dm-parse] Parsed %d targets from %d lines (%d failed)", len(targets), len(lines), failed)
    return targets, titles, failed


def summarize_spam_counts(c: Dict[str, int]) -> str:
    return (
        f"✅ Отправлено: {c.get('sent', 0)}  "
        f"🚫 Спам-блок: {c.get('spamfilter', 0)}  "
        f"⏳ Флуд: {c.get('flood', 0)}  "
        f"🐢 Слоу-мод: {c.get('slowmode', 0)}  "
        f"🔒 Нет прав: {c.get('no_rights', 0)}  "
        f"❌ Ошибок: {c.get('failed', 0)}"
    )


def spam_job_progress_text(job: SpamJob) -> str:
    from bot_ui.ui import progress_bar_detailed
    bar = progress_bar_detailed(job.processed, job.total)
    eta = _eta_text(job.processed, job.total, getattr(job, 'started_at', 0))
    status = _status_label(getattr(job, 'status', 'idle'))
    step = (getattr(job, 'current_step', '') or '').strip() or '—'

    speed_str = ""
    try:
        elapsed = time.time() - float(getattr(job, 'started_at', 0) or 0)
        if elapsed > 5 and job.processed > 0:
            rpm = job.processed / elapsed * 60
            speed_str = f"  💨 {rpm:.1f}/мин"
    except Exception:
        pass

    # Тайминги для юзера
    timing_str = ""
    try:
        _cat = "dm" if getattr(job, "dm_mode", False) else "spam"
        _min_d = get_user_timing(job.owner_id, _cat, "min_delay", SPAM_ANTIBAN_PARAMS["min_delay"], phone=job.acc.phone)
        _max_d = get_user_timing(job.owner_id, _cat, "max_delay", SPAM_ANTIBAN_PARAMS["max_delay"], phone=job.acc.phone)
        _burst = int(get_user_timing(job.owner_id, _cat, "burst_size", SPAM_ANTIBAN_PARAMS.get("burst_size", 7), phone=job.acc.phone))
        _pause_min = get_user_timing(job.owner_id, _cat, "burst_pause_min", SPAM_ANTIBAN_PARAMS["burst_pause_min"], phone=job.acc.phone)
        _pause_max = get_user_timing(job.owner_id, _cat, "burst_pause_max", SPAM_ANTIBAN_PARAMS["burst_pause_max"], phone=job.acc.phone)
        timing_str = f"⏳ Тайминги: {_min_d:.0f}-{_max_d:.0f}с, пауза кожні {_burst} шт ({_pause_min:.0f}-{_pause_max:.0f}с)\n"
    except Exception:
        pass

    return (
        f"📢 **Рассылка** — `{job.acc.phone}`\n"
        f"{'─' * 28}\n"
        f"📊 Прогресс: {bar}{speed_str}\n"
        f"⏱ Осталось: {eta}\n"
        f"🔄 Статус: {status}\n"
        f"⚙️  Шаг: {step}\n"
        f"{timing_str}"
        f"{'─' * 28}\n"
        f"{summarize_spam_counts(job.counts)}"
    )


def spam_report_text(job: SpamJob, limit: int = 80) -> str:
    from core.join import humanize_spam_status
    lines = [f"**Отчёт по рассылке** для `{job.acc.phone}`",
             f"Всего целей: {job.total}",
             f"Счётчики: {summarize_spam_counts(job.counts)}",
             ""]
    items = [(job.titles.get(cid, str(cid)), st) for cid, st in job.results.items()]
    if not items:
        lines.append("_Пока нет результатов._")
    else:
        if len(items) > limit:
            head = items[:limit//2]
            tail = items[-limit//2:]
            items = head + [("…", "… (сокращено)")] + tail
        lines.append("Подробности:")
        lines += [f"- {t} → {humanize_spam_status(s)}" for t, s in items]
    if job.status in ("finished", "stopped"):
        lines.append("")
        lines.append(f"Статус: **{job.status}**.")
    return "\n".join(lines)


async def _handle_large_floodwait(acc: Account, fw_sec: int, owner_id: int):
    """
    Обработка большого FloodWait.

    Если fw_sec >= FLOODWAIT_DISABLE_THRESHOLD_SEC:
      - Помечает аккаунт disabled
      - Уведомляет пользователя с временем ожидания
      - Предлагает переключиться на другой доступный аккаунт

    Если fw_sec >= FLOODWAIT_WARN_THRESHOLD_SEC:
      - Только предупреждение
    """
    try:
        threshold = getattr(state, 'FLOODWAIT_DISABLE_THRESHOLD_SEC', 3600)
        warn_threshold = getattr(state, 'FLOODWAIT_WARN_THRESHOLD_SEC', 300)

        # Обновляем статистику аккаунта
        try:
            acc.stats.floodwait_count += 1
            acc.stats.floodwait_total_sec += fw_sec
        except Exception:
            pass

        # Обновляем общую статистику пользователя
        try:
            from bot_ui.ui import get_ctx
            ctx = get_ctx(owner_id)
            ctx.stats.total_floodwaits += 1
        except Exception:
            pass

        # Форматируем время ожидания
        def fmt_time(sec: int) -> str:
            if sec < 60:
                return f"{sec} секунд"
            if sec < 3600:
                m = sec // 60
                s = sec % 60
                return f"{m} мин{f' {s} с' if s else ''}"
            h = sec // 3600
            m = (sec % 3600) // 60
            if sec < 86400:
                return f"{h} ч{f' {m} мин' if m else ''}"
            d = sec // 86400
            h2 = (sec % 86400) // 3600
            return f"{d} дн{f' {h2} ч' if h2 else ''}"

        if fw_sec >= threshold:
            # Отключаем аккаунт
            acc.floodwait_until = time.time() + fw_sec
            acc.floodwait_seconds = fw_sec
            acc.disabled = True
            acc.disabled_reason = f"флуд {fmt_time(fw_sec)}"

            logger.warning(
                "[флуд] Аккаунт %s отключён: флуд-блок %d сек (%s)",
                acc.phone, fw_sec, fmt_time(fw_sec)
            )

            # Формируем сообщение пользователю
            msg_lines = [
                f"⛔ **Аккаунт `{acc.phone}` временно недоступен**",
                f"",
                f"📛 Telegram наложил ограничение на этот аккаунт.",
                f"⏱ **Ждать:** {fmt_time(fw_sec)}",
                f"",
                f"🕐 Аккаунт автоматически разблокируется через указанное время.",
                f"Сейчас нет смысла его использовать.",
            ]

            # Ищем другие доступные аккаунты
            try:
                from bot_ui.ui import get_ctx
                ctx = get_ctx(owner_id)
                available = [
                    p for p, a in ctx.accounts.items()
                    if p != acc.phone and a.is_available
                ]
                if available:
                    msg_lines.append(f"")
                    msg_lines.append(f"✅ **Доступные аккаунты для замены:**")
                    for p in available[:5]:
                        a2 = ctx.accounts[p]
                        uname = f" (@{a2.username})" if a2.username else ""
                        msg_lines.append(f"  • `{p}`{uname}")
                    msg_lines.append(f"")
                    msg_lines.append(f"Используйте кнопку «📋 Аккаунты» для переключения.")
                else:
                    msg_lines.append(f"")
                    msg_lines.append(f"⚠️ Нет других доступных аккаунтов.")
                    msg_lines.append(f"Подключите новый аккаунт через «➕ Подключить».")
            except Exception as _e:
                logger.debug("[floodwait] Ошибка поиска доступных аккаунтов: %s", _e)

            # Информация попадает в наш telepush.floodwait логгер; UI читает
            # его в карточке акка. Раньше тут ещё уходило сообщение в Telegram-бот,
            # но в standalone бота нет — убрали dead code.

        elif fw_sec >= warn_threshold:
            # Только предупреждение
            acc.floodwait_until = time.time() + fw_sec
            acc.floodwait_seconds = fw_sec

            logger.warning(
                "[floodwait] ⚠️ Аккаунт %s: FloodWait %d сек (%s) — предупреждение",
                acc.phone, fw_sec, fmt_time(fw_sec)
            )

    except Exception as outer_err:
        logger.error("[floodwait] Ошибка в _handle_large_floodwait: %s", outer_err)


async def send_to_target(acc: Account, chat_id, text: str, image_path: str = None,
                         parse_mode: str | None = None,
                         forward_from_chat=None, forward_msg_id: int | None = None) -> str:
    """Отправляет одно сообщение в чат.
    parse_mode: None / 'md' / 'html' — формат текста (Telethon-стандарт).
    forward_from_chat + forward_msg_id: режим пересылки — переслать сообщение
    из source-чата вместо отправки своего текста."""
    if getattr(acc, 'dead', False) or getattr(acc, 'frozen', False):
        return "failed:account_dead"
    if getattr(acc, 'disabled', False):
        return "failed:account_disabled"
    if acc.client is None:
        return "failed:client_not_connected"

    msg = spintax_expand(text) if text else ""
    try:
        if forward_from_chat is not None and forward_msg_id:
            # Режим пересылки — переслать готовое сообщение из source-чата
            await acc.client.forward_messages(chat_id, forward_msg_id, forward_from_chat)
        elif image_path:
            await acc.client.send_file(chat_id, file=image_path, caption=msg, parse_mode=parse_mode)
        else:
            await acc.client.send_message(chat_id, msg, parse_mode=parse_mode)
        # Обновляем статистику
        try:
            acc.stats.spam_sent += 1
            acc.stats.last_activity_at = time.time()
        except Exception:
            pass
        return "sent"
    except errors.FloodWaitError as e:
        logger.warning("[рассылка:%s] ⏳ FloodWait %s сек", acc.phone, e.seconds)
        try:
            acc.stats.floodwait_count += 1
            acc.stats.floodwait_total_sec += e.seconds
        except Exception:
            pass
        return f"floodwait:{e.seconds}"
    except errors.PeerFloodError:
        logger.warning("[рассылка:%s] 🚫 PeerFlood — спам-фильтр", acc.phone)
        try:
            acc.spam_blocked = True
            acc.spam_block_note = "вечный спам-блок"
            acc.spam_checked_at = time.time()
            acc.stats.spam_spamblock += 1
        except Exception:
            pass
        return "spamfilter:peer_flood"
    except errors.SlowModeWaitError as e:
        return f"slowmode:{e.seconds}"
    except errors.ChatWriteForbiddenError:
        return "no_rights:forbidden"
    except errors.UserBannedInChannelError:
        return "no_rights:banned"
    except errors.ChatAdminRequiredError:
        return "no_rights:admin_required"
    except errors.UserPrivacyRestrictedError:
        return "no_rights:privacy_restricted"
    except (errors.AuthKeyUnregisteredError, errors.SessionRevokedError) as e:
        try:
            acc.dead = True
            acc.dead_reason = type(e).__name__
        except Exception:
            pass
        return f"failed:{type(e).__name__}"
    except FrozenMethodInvalidError as e:
        try:
            acc.frozen = True
            acc.dead = True
            acc.dead_reason = "FrozenMethodInvalidError"
            logger.warning("[#%s] ❄️ Аккаунт заморожен при отправке: %s", acc.phone, e)
        except Exception:
            pass
        return "failed:FrozenMethodInvalidError"
    except (FrozenParticipantMissingError, _FrozenParticipantMissingError) as e:
        try:
            acc.frozen = True
            acc.dead = True
            acc.dead_reason = "FrozenParticipantMissingError"
            logger.warning("[#%s] ❄️ FrozenParticipantMissingError: %s", acc.phone, e)
        except Exception:
            pass
        return "failed:FrozenMethodInvalidError"
    except errors.RPCError as ex:
        # Ловим FrozenMethodInvalidError И по типу класса И по тексту — Telegram
        # возвращает FROZEN_METHOD_INVALID с кодом 420 (FloodWait family), и
        # некоторые версии Telethon не оборачивают в спец-класс.
        _ex_name = type(ex).__name__
        _ex_str = str(ex).upper()
        if (
            "FrozenMethod" in _ex_name or "FrozenParticipant" in _ex_name
            or "FROZEN_METHOD_INVALID" in _ex_str
            or "FROZEN_PARTICIPANT_MISSING" in _ex_str
        ):
            try:
                acc.frozen = True
                acc.dead = True
                acc.dead_reason = "FrozenMethodInvalidError"
            except Exception:
                pass
            return "failed:FrozenMethodInvalidError"
        # PeerIdInvalidError — аккаунт не состоит в этом чате (или чат удалён).
        # Это частый случай: клиент вставил ID чатов из старых списков, но
        # текущий аккаунт туда не вступал. Раньше показывали сырой
        # «failed:PeerIdInvalidError» — клиент не понимал что к чему.
        if "PeerId" in type(ex).__name__ or "PeerIdInvalid" in str(ex):
            try: acc.stats.spam_failed += 1
            except Exception: pass
            return "failed:not_in_chat"
        logger.debug("Send failed to %s: %s", chat_id, ex)
        try:
            acc.stats.spam_failed += 1
        except Exception:
            pass
        return f"failed:{type(ex).__name__}"
    except ValueError as ex:
        try:
            acc.stats.spam_failed += 1
        except Exception:
            pass
        _msg_lc = str(ex).lower()
        if "empty" in _msg_lc:
            return "failed:empty_message"
        if "no user has" in _msg_lc or "no chat has" in _msg_lc:
            # Telegram вернул пустой ответ на ResolveUsername. Юзер существует,
            # но аккаунт-рассыльщик не может его «увидеть»: чаще всего у юзера
            # стоит приватность по username (только контакты), либо username
            # был изменён/освобождён. Один INFO-лог с подсказкой.
            logger.info(
                "[рассылка:%s] %s — юзер скрыт от незнакомцев или сменил username (приватность)",
                acc.phone, chat_id
            )
            return "failed:user_hidden"
        if any(k in _msg_lc for k in ("cannot find any entity",
                                        "could not find the input entity",
                                        "could not find any user",
                                        "could not find any chat")):
            logger.info("[рассылка:%s] %s не resolve (нет в кеше акка): %s",
                        acc.phone, chat_id, str(ex)[:80])
            return "failed:not_resolved"
        # Прочие ValueError — даём полный текст в WARNING, чтобы можно было
        # понять причину при разборе.
        logger.warning("[рассылка:%s] ValueError для %s: %s", acc.phone, chat_id, ex)
        return f"failed:{type(ex).__name__}"
    except Exception as ex:
        logger.debug("Send failed to %s: %s", chat_id, ex)
        try:
            acc.stats.spam_failed += 1
        except Exception:
            pass
        return f"failed:{type(ex).__name__}"


def classify_spambot_note_scope(note: str) -> str:
    try:
        t = (note or "").strip().lower()
    except Exception:
        t = ""
    if not t:
        return "unknown"
    if "peerflood" in t or "peer_flood" in t:
        return "hard"
    if any(x in t for x in [
        "свободен от каких-либо ограничений", "нет каких-либо огранич",
        "без каких-либо огранич", "no limits", "no restrictions",
        "not limited", "not restricted",
    ]):
        return "none"
    if any(x in t for x in [
        "не можете писать тем, кто не сохранил",
        "не можете отправлять сообщения тем, кто не сохранил",
        "можете писать только тем", "если незнакомый пользователь",
        "cannot send messages to people who are not in your contacts",
        "you can only send messages to", "if a user messages you first",
        "can't invite", "cannot invite", "приглашать таких пользователей",
    ]):
        return "dm"
    if any(x in t for x in [
        "ваш аккаунт временно ограничен", "ваш аккаунт ограничен",
        "вы ограничены", "temporarily limited", "you are limited",
        "reported as spam", "we have received complaints",
        "жалоб", "пожалова",
    ]):
        return "hard"
    return "unknown"


async def deep_health_check_account(acc: Account, *, timeout_each: float = 10.0) -> dict:
    """Глубокая проверка живости одного аккаунта. Возвращает:
    {"status": "alive"|"frozen"|"dead", "reason": "..."}.

    6 шагов от мягкого read-метода к жёсткому write-action:
      1. get_me() — базовая авторизация (AuthKey/SessionRevoked/Frozen* errors).
      2. me.restricted — Telegram сам помечает frozen-аккаунт флагом.
      3. messages.GetCommonChatsRequest(self) — read-метод.
      4. messages.SetTypingRequest(self, cancel) — self-action.
      5. account.GetAuthorizationsRequest — revoked-сессия (AUTH_KEY_UNREGISTERED).
      6. account.UpdateStatusRequest(offline=False) — РЕАЛЬНЫЙ write-action.
         typing Telegram пропускает глобально, updateStatus идёт через write-валидацию
         и frozen-аккаунты падают с FROZEN_METHOD_INVALID / USER_RESTRICTED.

    NB: FrozenMethodInvalidError в части версий Telethon отсутствует в namespace
    `telethon.errors` — поэтому используем модульный импорт сверху файла
    (`FrozenMethodInvalidError`) или ловим generic RPCError и проверяем
    имя класса по строке. Прямой `_te.FrozenMethodInvalidError` падал
    AttributeError на сборках Telethon 1.34+."""
    import asyncio as _aio
    from telethon import errors as _te

    def _classify_frozen(exc) -> str:
        """Возвращает имя класса если это «заморозка», иначе пустую строку."""
        ename = type(exc).__name__
        emsg = str(exc).upper()
        if "Frozen" in ename or "FROZEN" in emsg:
            return ename
        return ""

    def _persist_status(**kw):
        """Сохраняет статус в account_states.json чтобы переживал рестарт."""
        try:
            from core.account_state import save_account_state
            save_account_state(getattr(acc, "phone", None), **kw)
        except Exception:
            pass

    def _mark_dead(reason: str, ret_reason: str):
        acc.dead = True
        acc.dead_reason = reason
        _persist_status(dead=True, dead_reason=reason)
        return {"status": "dead", "reason": ret_reason}

    def _mark_frozen(reason: str, ret_reason: str = None):
        acc.frozen = True
        acc.frozen_reason = reason
        _persist_status(frozen=True, frozen_reason=reason)
        return {"status": "frozen", "reason": ret_reason or reason}

    if acc.client is None:
        return {"status": "dead", "reason": "no_client"}

    # Шаг 1 — get_me()
    try:
        me = await _aio.wait_for(acc.client.get_me(), timeout=timeout_each + 5)
    except _te.AuthKeyUnregisteredError:
        return _mark_dead("AuthKeyUnregistered", "session_revoked")
    except _te.SessionRevokedError:
        return _mark_dead("SessionRevoked", "session_revoked")
    except _te.UserDeactivatedError:
        return _mark_dead("UserDeactivated", "deactivated")
    except _te.UserDeactivatedBanError:
        return _mark_dead("UserDeactivatedBan", "banned")
    except _aio.TimeoutError:
        return {"status": "dead", "reason": "timeout_get_me"}
    except Exception as _e:
        fr = _classify_frozen(_e)
        if fr:
            return _mark_frozen(fr)
        if isinstance(_e, _te.RPCError):
            return {"status": "dead", "reason": f"rpc:{type(_e).__name__}"}
        return {"status": "dead", "reason": f"err:{type(_e).__name__}"}

    if me is None:
        return _mark_dead("get_me_returned_none", "unauthorized")

    # Шаг 2 — restricted флаг.
    if getattr(me, "restricted", False):
        return _mark_frozen("restricted_by_telegram", "restricted")

    # Шаг 3 — GetCommonChats (read).
    try:
        from telethon.tl.functions.messages import GetCommonChatsRequest
        from telethon.tl.types import InputUserSelf
        await _aio.wait_for(
            acc.client(GetCommonChatsRequest(user_id=InputUserSelf(), max_id=0, limit=1)),
            timeout=timeout_each
        )
    except Exception as _e:
        fr = _classify_frozen(_e)
        if fr:
            return _mark_frozen(fr + "_chats", fr)

    # Шаг 4 — SetTyping (self-action).
    try:
        from telethon.tl.functions.messages import SetTypingRequest
        from telethon.tl.types import InputUserSelf, SendMessageCancelAction
        await _aio.wait_for(
            acc.client(SetTypingRequest(peer=InputUserSelf(), action=SendMessageCancelAction())),
            timeout=timeout_each
        )
    except Exception as _e:
        fr = _classify_frozen(_e)
        if fr:
            return _mark_frozen(fr + "_typing", fr)
        ename = type(_e).__name__
        emsg = str(_e).upper()
        if "WriteForbidden" in ename or "USER_BANNED" in emsg:
            return _mark_frozen(ename)

    # Шаг 5 — GetAuthorizations.
    try:
        from telethon.tl.functions.account import GetAuthorizationsRequest
        await _aio.wait_for(acc.client(GetAuthorizationsRequest()), timeout=timeout_each)
    except (_te.AuthKeyUnregisteredError, _te.SessionRevokedError):
        return _mark_dead("session_revoked", "session_revoked")
    except Exception as _e:
        fr = _classify_frozen(_e)
        if fr:
            return _mark_frozen(fr + "_auth", fr)

    # Шаг 6 — РЕАЛЬНЫЙ write-action: account.UpdateStatusRequest(offline=False).
    try:
        from telethon.tl.functions.account import UpdateStatusRequest
        await _aio.wait_for(acc.client(UpdateStatusRequest(offline=False)), timeout=timeout_each)
    except Exception as _e:
        fr = _classify_frozen(_e)
        if fr:
            return _mark_frozen(fr + "_updateStatus", fr)
        ename = type(_e).__name__
        emsg = str(_e).upper()
        if "USER_RESTRICTED" in emsg or "Restricted" in ename:
            return _mark_frozen(ename)

    # Дошли сюда — акк жив. Если ранее был помечен dead/frozen
    # (например ложно пометили в прошлый раз) — снимаем метку и в файле.
    try:
        if getattr(acc, "dead", False) or getattr(acc, "frozen", False):
            acc.dead = False
            acc.dead_reason = None
            acc.frozen = False
            acc.frozen_reason = None
            _persist_status(dead=False, dead_reason="", frozen=False, frozen_reason="")
    except Exception:
        pass
    return {"status": "alive", "reason": ""}


async def probe_spamblock(acc: Account, force: bool = False) -> tuple:
    if acc.client is None:
        return False, "not_checked"
    try:
        now = time.time()
    except Exception:
        now = 0.0
    if not force and acc.spam_checked_at and (now - acc.spam_checked_at) < 3600:
        return acc.spam_blocked, acc.spam_block_note or ""

    try:
        bot_ent = await acc.client.get_entity("SpamBot")
    except Exception:
        acc.spam_checked_at = now
        acc.spam_blocked = False
        acc.spam_block_note = "не проверено"
        return False, "не проверено"

    try:
        await asyncio.wait_for(acc.client.send_message(bot_ent, "/start"), timeout=8.0)
        ev = await wait_for_event_compat(acc.client, events.NewMessage(chats=bot_ent), timeout=8.0)
        note = getattr(ev, "raw_text", "") or ""
        msg = note.lower()

        clear_patterns = [
            r"\bno\s+limits?\b", r"\bno\s+restrictions?\b", r"\bnot\s+limited\b",
            r"\bnot\s+restricted\b", r"free\s+as\s+a\s+bird", r"good\s+news[^\n]*no\s+limits",
            r"нет\s+[^\n]*огранич", r"без\s+[^\n]*огранич", r"не\s+ограничен",
            r"свобод\w*\s+от\s+[^\n]*огранич",
            r"ваш\s+аккаунт\s+свобод\w*\s+от\s+[^\n]*огранич",
            r"нема\w*\s+[^\n]*обмеж", r"немає\s+[^\n]*обмеж",
            r"обмежен\w*\s+відсут", r"обмеження\s+відсут",
        ]
        blocked_patterns = [
            r"\byou\s+are\s+limited\b", r"limited\s+until",
            r"\btemporar(?:ily)?\s+limited\b", r"\byou\s+can\s+only\s+send\b",
            r"\bwe\s+have\s+received\s+complaints\b", r"reported\s+as\s+spam",
            r"ваш\s+аккаунт\s+огранич", r"вы\s+ограничены",
            r"временно\s+огранич", r"вы\s+можете\s+писать\s+только",
            r"мы\s+получили\s+жалоб", r"пожалова\w*",
            r"обмежен\w*\s+до", r"обмежен\w*\s+на", r"скарг\w*",
        ]

        def _match_any(patterns):
            try:
                for p in patterns:
                    if re.search(p, msg, flags=re.IGNORECASE):
                        return True
                return False
            except Exception:
                low = (msg or "")
                return any((p.strip("\\b").strip("\\").lower() in low) for p in patterns)

        if _match_any(clear_patterns):
            blocked = False
        elif _match_any(blocked_patterns):
            blocked = True
        else:
            blocked = False
    except Exception:
        blocked = False
        note = "no_reply"
    acc.spam_blocked = bool(blocked)
    acc.spam_block_note = note
    acc.spam_checked_at = now
    return acc.spam_blocked, note


async def run_spam_job(job: SpamJob):
    from core.join import humanize_spam_status
    job.status = "running"
    job.pause_event.set()
    acc = job.acc

    _mode_label = "DM" if getattr(job, "dm_mode", False) else "чатам"
    log_spam_start(acc.phone, job.total, job.owner_id)
    logger.info("[рассылка:%s] Режим: %s | targets[0]=%s", acc.phone, _mode_label, job.targets[0] if job.targets else "?")

    # Проверка перед стартом
    if getattr(acc, 'dead', False) or getattr(acc, 'frozen', False):
        job.status = "finished"
        reason = "заморожен" if getattr(acc, 'frozen', False) else "сессия недействительна"
        logger.error("[рассылка:%s] ❌ Аккаунт недоступен (%s) — рассылка отменена", acc.phone, reason)
        return

    if getattr(acc, 'disabled', False):
        job.status = "finished"
        reason = getattr(acc, 'disabled_reason', '?')
        logger.error("[рассылка:%s] 🚫 Аккаунт отключён: %s — рассылка отменена", acc.phone, reason)
        return

    # Обновляем статистику пользователя
    try:
        from bot_ui.ui import get_ctx
        ctx = get_ctx(job.owner_id)
        ctx.stats.last_active_at = time.time()
    except Exception:
        pass

    # Кэшируем ctx и stats один раз до цикла — не вызываем get_ctx на каждой итерации
    try:
        from bot_ui.ui import get_ctx as _get_ctx_spam
        _cached_ctx = _get_ctx_spam(job.owner_id)
        _cached_stats = _cached_ctx.stats
    except Exception:
        _cached_ctx = None
        _cached_stats = None

    for cid in job.targets:
        if job.stop_flag:
            break
        await job.pause_event.wait()

        # Стоп если аккаунт стал недоступен
        if getattr(acc, 'dead', False) or getattr(acc, 'frozen', False):
            _status = "failed:FrozenMethodInvalidError" if getattr(acc, 'frozen', False) else "failed:account_dead"
            job.results[cid] = _status
            job.counts["failed"] += 1
            logger.warning("[рассылка:%s] %s — рассылка остановлена",
                           acc.phone, "заморожен" if getattr(acc, 'frozen', False) else "умер")
            job.stop_flag = True
            break

        if getattr(acc, 'disabled', False) and not getattr(acc, 'is_floodwaiting', False):
            # Аккаунт отключён — стопаем
            job.results[cid] = "failed:account_disabled"
            job.counts["failed"] += 1
            job.stop_flag = True
            break

        # Pre-check для рассылки по чатам — проверяем права перед отправкой
        if not getattr(job, "dm_mode", False) and acc.client:
            try:
                ent = await acc.client.get_entity(cid)
                if not can_write_to_chat(ent):
                    job.results[cid] = "no_rights:pre_check"
                    job.counts.setdefault("no_rights", 0)
                    job.counts["no_rights"] += 1
                    job.processed += 1
                    # Авто-выход
                    try:
                        await acc.client.delete_dialog(cid)
                        _job_log_add(job, f"Покинули чат {job.titles.get(cid, str(cid))}: нет прав на отправку")
                    except Exception:
                        pass
                    continue
            except Exception:
                pass

        # Раньше тут был pre-resolve через get_input_entity для DM-режима
        # (видеть конкретный UsernameNotOccupied/Hidden/FloodWait до send_message).
        # Но get_input_entity оказался строже чем resolve внутри send_message —
        # бракотал отправку с «failed:user_hidden» там, где send_message прошёл бы:
        #   1) у юзера несколько username (collectible), primary не совпадает с искомым;
        #   2) Telethon `_get_entity_from_string` не смотрит entity.usernames список.
        # Убрали — пусть send_message делает свой resolve. Всё равно ловим ошибки
        # в send_to_target и возвращаем понятный статус.

        # Anti-detect: solve_captcha для chat-mode перед первой отправкой в чат.
        # Если в чате антиспам-бот с кнопкой «я не бот» — нажмём.
        if not getattr(job, 'dm_mode', False):
            ad_cfg_s = getattr(job, 'ad_config', None) or {}
            if ad_cfg_s.get('solve_captcha'):
                try:
                    chat_ent = await acc.client.get_input_entity(cid)
                    from utils.anti_detect import solve_captcha
                    await solve_captcha(acc.client, chat_ent, module="spam")
                except Exception:
                    pass

        # Anti-detect: для DM-режима имитация просмотра профиля/фото перед отправкой.
        # Сэмпл-ставка 20% / 10% — реальный человек не открывает каждый профиль.
        if getattr(job, 'dm_mode', False):
            ad_cfg = getattr(job, 'ad_config', None) or {}
            if ad_cfg.get('view_profiles') or ad_cfg.get('view_photos'):
                try:
                    target_user = await acc.client.get_input_entity(cid)
                    if ad_cfg.get('view_profiles'):
                        from utils.anti_detect import view_profile
                        await view_profile(acc.client, target_user, module="dm")
                    if ad_cfg.get('view_photos'):
                        from utils.anti_detect import view_profile_photo
                        await view_profile_photo(acc.client, target_user, module="dm")
                except Exception:
                    pass

        status = await send_to_target(
            acc, cid, job.text,
            image_path=getattr(job, 'image_path', None),
            parse_mode=getattr(job, 'parse_mode', None),
            forward_from_chat=getattr(job, 'forward_from_chat', None),
            forward_msg_id=getattr(job, 'forward_msg_id', None),
        )
        job.results[cid] = status

        _title = job.titles.get(cid, str(cid))
        log_spam_result(acc.phone, job.processed + 1, job.total, _title, status)

        if status.startswith("sent"):
            job.counts["sent"] += 1
            acc.stats.spam_sent += 1
            try:
                if _cached_stats: _cached_stats.total_spam_sent += 1
            except Exception:
                pass
            try:
                _kind = "spam_user" if getattr(job, "dm_mode", False) else "spam"
                store.push_quota_event(job.owner_id, acc.phone, _kind)
            except Exception:
                pass
            # Per-account лимит: остановка задачи когда акк отправил N сообщений.
            # 0 = без лимита (старое поведение — до спамблока/конца списка).
            try:
                _cap = int(getattr(job, "max_per_acc", 0) or 0)
            except Exception:
                _cap = 0
            if _cap > 0 and job.counts["sent"] >= _cap:
                _label = "DM" if getattr(job, "dm_mode", False) else "рассылка"
                logger.info("[%s:%s] достигнут лимит %d сообщений с акка — остановка",
                            _label, acc.phone, _cap)
                try:
                    job.log_lines.append(
                        f"🛑 Достигнут лимит: {_cap} сообщений с этого аккаунта. Остановка."
                    )
                except Exception:
                    pass
                job.stop_flag = True

        elif status.startswith("spamfilter"):
            job.counts["spamfilter"] += 1
            try:
                acc.stats.spam_spamblock += 1
            except Exception:
                pass
            job.stop_flag = True

        elif status.startswith("floodwait"):
            job.counts["flood"] += 1
            try:
                fw_sec = int(status.split(":", 1)[1])
            except Exception:
                fw_sec = 60

            if fw_sec > 0:
                # Уведомляем пользователя о FloodWait
                try:
                    from core.auto_resume import handle_floodwait_spam
                    await handle_floodwait_spam(job, fw_sec)
                except Exception:
                    pass
                # Большой FW — нет смысла ждать, тормозим задачу (клиент перезапустит на другом акке)
                try:
                    _cat = "dm" if getattr(job, "dm_mode", False) else "spam"
                    _fw_limit = int(get_user_timing(job.owner_id, _cat, "floodwait_stop_sec", 1800, phone=acc.phone))
                except Exception:
                    _fw_limit = 1800
                if fw_sec >= _fw_limit:
                    logger.warning("[рассылка:%s] FloodWait %ds ≥ порога %ds — останавливаю задачу", acc.phone, fw_sec, _fw_limit)
                    try:
                        job.current_step = f"🛑 Стоп: FloodWait {fw_sec}с превысил лимит {_fw_limit}с"
                    except Exception:
                        pass
                    _job_log_add(job, f"🛑 FloodWait {fw_sec}с ≥ {_fw_limit}с — стоп. Запусти остаток на другом акке.")
                    job.stop_flag = True
                    break
                # Обрабатываем флудвейт: если большой — отключаем аккаунт
                await _handle_large_floodwait(acc, fw_sec, job.owner_id)

                # Если аккаунт теперь disabled — стопаем задачу
                if getattr(acc, 'disabled', False):
                    job.stop_flag = True
                    break

                # Иначе — ждём
                try:
                    job.current_step = f"FloodWait: ждём {fw_sec}с"
                except Exception:
                    pass
                _remaining = float(fw_sec)
                while _remaining > 0 and not job.stop_flag:
                    _step = min(5.0, _remaining)
                    await asyncio.sleep(_step)
                    _remaining -= _step
                    try:
                        job.current_step = f"FloodWait: ждём {int(_remaining)}с"
                    except Exception:
                        pass

        elif status.startswith("slowmode"):
            job.counts["slowmode"] += 1

        elif status.startswith("no_rights"):
            job.counts["no_rights"] += 1
            if status in ("no_rights:forbidden", "no_rights:admin_required", "no_rights:banned") and not getattr(job, "dm_mode", False):
                try:
                    if not acc.client:
                        continue
                    await acc.client.delete_dialog(cid)
                    logger.info("[рассылка:%s] 🚪 Покинули чат %s: нет прав",
                                acc.phone, job.titles.get(cid, str(cid)))
                    _job_log_add(job, f"Покинули чат {job.titles.get(cid, str(cid))}: {humanize_spam_status(status)}")
                except Exception as _le:
                    logger.debug("[spam] Не удалось покинуть чат %s: %s", cid, _le)

        elif status.startswith("failed:FrozenMethodInvalidError") or status.startswith("failed:account_dead"):
            job.counts["failed"] += 1
            job.stop_flag = True

        elif status in ("failed:not_in_chat", "failed:not_resolved",
                        "failed:user_hidden", "failed:user_not_found",
                        "failed:username_invalid"):
            # Не «ошибка рассылки», а недоступная цель: акк не в чате, юзер
            # закрыл username, либо username не существует. Клиенту понятнее
            # что это не баг, а отказ доступа от Telegram.
            job.counts["no_rights"] += 1

        else:
            job.counts["failed"] += 1
            try:
                if _cached_stats: _cached_stats.total_spam_failed += 1
            except Exception:
                pass

        job.processed += 1
        # Ограничиваем рост памяти
        if len(job.log_lines) > 200:
            job.log_lines = job.log_lines[-100:]
        if len(job.results) > 5000:
            job.results = dict(list(job.results.items())[-2000:])
        # Обновляем per-account статистику
        if status.startswith("failed") or status.startswith("spamfilter"):
            acc.stats.spam_failed += 1
        elif status.startswith("no_rights"):
            acc.stats.spam_failed += 1
        # UI: обновляем каждые 10 отправок или каждые 30 секунд
        import time as _t
        if job.stop_flag:
            break

        _cat = "dm" if getattr(job, "dm_mode", False) else "spam"
        _min_delay = get_user_timing(job.owner_id, _cat, "min_delay", SPAM_ANTIBAN_PARAMS["min_delay"], phone=acc.phone)
        _max_delay = get_user_timing(job.owner_id, _cat, "max_delay", SPAM_ANTIBAN_PARAMS["max_delay"], phone=acc.phone)
        _delay = random.uniform(_min_delay, _max_delay)
        await asyncio.sleep(_delay)

        _burst = int(get_user_timing(job.owner_id, _cat, "burst_size", SPAM_ANTIBAN_PARAMS.get("burst_size", 8), phone=acc.phone))
        if job.counts["sent"] > 0 and job.counts["sent"] % _burst == 0:
            _burst_pause_min = get_user_timing(job.owner_id, _cat, "burst_pause_min", SPAM_ANTIBAN_PARAMS["burst_pause_min"], phone=acc.phone)
            _burst_pause_max = get_user_timing(job.owner_id, _cat, "burst_pause_max", SPAM_ANTIBAN_PARAMS["burst_pause_max"], phone=acc.phone)
            _long_pause = random.uniform(_burst_pause_min, _burst_pause_max)
            logger.info("[рассылка:%s] ⏸️ Пауза безопасности: %.0fс", acc.phone, _long_pause)
            try:
                job.current_step = f"Пауза безопасности: {int(_long_pause)}с"
            except Exception:
                pass
            _rem = _long_pause
            while _rem > 0 and not job.stop_flag:
                _st = min(5.0, _rem)
                await asyncio.sleep(_st)
                _rem -= _st

    job.status = "stopped" if job.stop_flag else "finished"
    log_spam_finish(acc.phone, job.status, job.counts)

    # Обновляем статистику при завершении
    try:
        from bot_ui.ui import get_ctx
        ctx = get_ctx(job.owner_id)
        ctx.stats.tasks_completed += 1
    except Exception:
        pass

    # UI приложения сам берёт прогресс из job.counts/log_lines — никаких
    # сообщений в Telegram-бот в standalone не нужно.

    failed = [(job.titles.get(cid, str(cid)), st) for cid, st in job.results.items() if not st.startswith("sent")]
    if failed:
        lines = ["❗️**Не отправлено**:"]
        for t, st in failed[:80]:
            lines.append(f"• {t} — {humanize_spam_status(st)}")
        if len(failed) > 80:
            lines.append(f"… и ещё {len(failed)-80}")
        # Финальный отчёт пишется в job.log_lines (его читает консоль приложения).
        for _line in lines:
            try:
                job.log_lines.append(_line)
            except Exception:
                break

    # ── Уведомление о завершении ──
    try:
        from core.notifications import notify_task_finished
        await notify_task_finished(
            None, job.owner_id, "spam", acc.phone,
            dict(job.counts), job.status,
        )
    except Exception as _ne:
        logger.debug("[spam] notify error: %s", _ne)

    # ── Loop-режим: круги розсилки ──
    if job.status == "finished" and not job.stop_flag:
        try:
            from core.notifications import maybe_loop_spam
            _owner = job.owner_id
            _acc = acc
            _tpl = job.text
            _img = job.image_path
            _round = getattr(job, '_loop_round', 1)

            async def _restart_spam():
                from bot_ui.ui import get_ctx
                ctx = get_ctx(_owner)
                if not _acc.client or not _acc.client.is_connected():
                    return
                targets, titles = await collect_writable_groups(_acc)
                if not targets:
                    return
                new_job = SpamJob(
                    owner_id=_owner, acc=_acc, text=_tpl,
                    targets=targets, titles=titles, total=len(targets),
                    image_path=_img,
                )
                new_job._loop_round = _round + 1
                ctx.spam_job = new_job
                from core.worker_pool import submit_for_user
                new_job.task = submit_for_user(
                    _owner, lambda: run_spam_job(new_job),
                    job_id=f"loop_spam_{_owner}", name="loop-spam",
                )

            await maybe_loop_spam(_owner, _restart_spam, _round)
        except Exception as _le:
            logger.debug("[spam] loop error: %s", _le)
