"""
core/monitor.py — реал-тайм мониторинг чатов по ключевым словам.

Аккаунт подписывается на events.NewMessage для заданного списка чатов.
При попадании любого ключевого слова в текст входящего сообщения — форматирует
уведомление (время, автор, ссылка, текст) и шлёт его в целевой чат.

Состояние джоба лежит в ctx.monitor_job, настройки персистятся в user_settings
чтобы переживать рестарты.
"""
import asyncio
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List, Optional

from telethon import events
from telethon.tl import types as tt

logger = logging.getLogger("telepush.monitor")


@dataclass
class MonitorJob:
    owner_id: int
    acc: object                        # Account
    chat_inputs: List[str]             # исходные ссылки/username
    keywords: List[str]                # lowercased
    ignore_words: List[str] = field(default_factory=list)  # lowercased — пропускаем сообщение если хоть одно встречается
    target: str = "me"                 # "me" или @username / chat_id / t.me/x
    status: str = "idle"               # idle / running / stopped
    stop_flag: bool = False
    started_at: float = 0.0
    matched: int = 0                   # сколько алертов отправили
    resolved_chats: List[int] = field(default_factory=list)   # chat_id которые удалось подписать
    failed_chats: List[str] = field(default_factory=list)     # какие не открылись
    target_entity: object = None
    _handler: object = None
    _last_match_at: float = 0.0
    # Автосообщение в ЛС автору совпавшего коммента (опционально).
    # Поддерживает спинтакс. Каждому user_id шлём максимум один раз (дедуп
    # в monitor_contacted.json — общий для всех акков клиента).
    auto_dm_text: str = ""
    auto_dm_sent: int = 0
    auto_dm_skipped_dup: int = 0
    # Bot API (опционально): если токен задан — алерты идут через HTTP Bot
    # API, не от лица аккаунта-мониторщика. Плюс: не тратит лимиты акка,
    # не палит аккаунт, приходит в твой личный Telegram в чат с ботом.
    bot_token: str = ""                # plaintext в памяти (шифрован на диске)
    bot_chat_id: str = ""              # получатель сообщений от бота
    # Процесс-локальный дедуп по (chat_id, msg_id). Без него одно и то же
    # сообщение могло попасть в обработчик дважды: первый раз через realtime
    # events.NewMessage, второй — через polling-fallback (30с позже). Оба
    # срабатывания создавали по алерту, клиент жаловался на дубли.
    _processed_msgs: set = field(default_factory=set)
    # Дедуп по ТЕКСТУ — ловит случаи когда спаммер копипастит одно и то же
    # объявление в разные чаты (разные msg_id, разные sender_id, но текст
    # идентичный). В contacted-листе не помогает, потому что авторы разные.
    # Формат: hash(normalized_text) → timestamp первого матча.
    _processed_text_hashes: dict = field(default_factory=dict)
    # Опциональные общие dedupe-структуры на пул из нескольких акков (multi-mode).
    # Если pool передал shared set/dict, мы используем их вместо локальных —
    # чтобы один и тот же матч на 5 акках не дал 5 алертов.
    _shared_msgs: object = None
    _shared_text_hashes: object = None
    csv_path: str = ""
    _total_events: int = 0


def _format_link(chat, msg_id: int) -> str:
    """Строит t.me ссылку на сообщение если возможно."""
    try:
        uname = getattr(chat, "username", None)
        if uname:
            return f"https://t.me/{uname}/{msg_id}"
        # Приватный super-канал: t.me/c/<id>/<msg_id>
        if isinstance(chat, tt.Channel):
            cid = getattr(chat, "id", None)
            if cid:
                return f"https://t.me/c/{cid}/{msg_id}"
    except Exception:
        pass
    return ""


def _format_author(sender) -> str:
    if sender is None:
        return "—"
    uname = getattr(sender, "username", None)
    if uname:
        return f"https://t.me/{uname}"
    try:
        uid = getattr(sender, "id", None)
        if uid:
            return f"id={uid}"
    except Exception:
        pass
    return "—"


def _format_notification(chat, sender, msg, matched_kw: str) -> str:
    """Собирает текст уведомления в том стиле что клиент просил."""
    try:
        dt = msg.date
        if dt is not None:
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            time_s = dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")
        else:
            time_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    except Exception:
        time_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    chat_title = getattr(chat, "title", None) or getattr(chat, "username", None) or "?"
    link = _format_link(chat, getattr(msg, "id", 0))
    author = _format_author(sender)
    text = (getattr(msg, "message", None) or getattr(msg, "text", None) or "").strip()
    if len(text) > 500:
        text = text[:500] + "…"

    lines = [
        "🆕 <b>НОВЫЙ КОММЕНТ</b>",
        f"🔑 Ключ: <code>{matched_kw}</code>",
        f"💬 Чат: <b>{chat_title}</b>",
        f"🕐 Время: {time_s}",
        f"👤 Автор: {author}",
    ]
    if link:
        lines.append(f"🔗 Ссылка: {link}")
    lines.append(f"📝 Текст: {text or '(пусто)'}")
    return "\n".join(lines)


async def _resolve_target(client, target: str):
    """me / username / id / t.me/xxx → entity."""
    t = (target or "me").strip()
    if t.lower() in ("", "me", "saved", "savedmessages", "сохранённые", "сохранённые сообщения"):
        return await client.get_me()
    # URL
    m = re.match(r"(?:https?://)?t\.me/([A-Za-z0-9_]+)", t, re.I)
    if m:
        t = m.group(1)
    if t.startswith("@"):
        t = t[1:]
    # Пробуем как int (chat_id)
    try:
        return await client.get_entity(int(t))
    except Exception:
        pass
    return await client.get_entity(t)


async def _resolve_chats(client, chat_inputs: List[str]):
    """Резолвит список чатов в entity'и. Возвращает (entities, failed).

    Стратегия (от дешёвого к дорогому):
    1) Ищем в уже имеющихся диалогах аккаунта (быстро, не требует API-запроса,
       работает даже на spam-block-нутых аккаунтах).
    2) ResolveUsernameRequest — прямой запрос к серверу (может быть заблокирован
       для ограниченных аккаунтов).
    3) t.me-URL как финальный фоллбек.
    """
    entities = []
    failed = []

    # Преднагружаем диалоги один раз — быстрый lookup by username/title.
    dialog_by_username = {}
    dialog_by_id = {}
    try:
        async for dialog in client.iter_dialogs(limit=500):
            ent = dialog.entity
            u = (getattr(ent, "username", "") or "").lower()
            if u:
                dialog_by_username[u] = ent
            try:
                dialog_by_id[int(getattr(ent, "id", 0))] = ent
            except Exception:
                pass
    except Exception as _de:
        logger.debug("[monitor] iter_dialogs failed: %s", _de)

    for raw in chat_inputs:
        s = (raw or "").strip()
        if not s:
            continue
        # t.me/+hash — приватный инвайт, надо вступить
        m_inv = re.match(r"(?:https?://)?t\.me/(?:\+|joinchat/)([A-Za-z0-9_\-]+)", s, re.I)
        if m_inv:
            try:
                from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
                _hash = m_inv.group(1)
                try:
                    info = await client(CheckChatInviteRequest(_hash))
                    if isinstance(info, tt.ChatInviteAlready):
                        entities.append(info.chat)
                        continue
                except Exception:
                    pass
                r = await client(ImportChatInviteRequest(_hash))
                if hasattr(r, "chats") and r.chats:
                    entities.append(r.chats[0])
                    continue
            except Exception as e:
                failed.append(f"{s}: invite — {e}")
                continue
        # Обычная ссылка/username
        try:
            # Нормализация
            candidate = s
            m_u = re.match(r"(?:https?://)?t\.me/([A-Za-z0-9_]+)", candidate, re.I)
            if m_u:
                candidate = m_u.group(1)
            if candidate.startswith("@"):
                candidate = candidate[1:]
            ent = None

            # ШАГ 1: ищем в уже имеющихся диалогах — работает даже если акк
            # spam-block-нут и не может вызывать ResolveUsernameRequest.
            _lookup = dialog_by_username.get(candidate.lower())
            if _lookup is not None:
                ent = _lookup
                logger.debug("[monitor] %s найден в диалогах", candidate)

            # ШАГ 2: обычный get_entity (использует и локальный кеш, и resolve)
            if ent is None:
                try:
                    ent = await client.get_entity(candidate)
                except Exception as _ge:
                    # ШАГ 3: явный ResolveUsernameRequest (может упасть на
                    # заблокированных акках, но для чистых акков работает когда
                    # get_entity в кеше не нашёл).
                    try:
                        from telethon.tl.functions.contacts import ResolveUsernameRequest
                        _resolved = await client(ResolveUsernameRequest(candidate))
                        if _resolved.chats:
                            ent = _resolved.chats[0]
                        elif _resolved.users:
                            ent = _resolved.users[0]
                    except Exception:
                        # ШАГ 4: t.me URL fallback
                        try:
                            ent = await client.get_entity(f"https://t.me/{candidate}")
                        except Exception:
                            raise _ge

            if ent is None:
                raise RuntimeError("не найден ни в диалогах, ни через resolve")
            entities.append(ent)
        except Exception as e:
            failed.append(f"{s}: {e}")
    return entities, failed


def _contacted_file():
    """Файл с id юзеров которым уже писали в ЛС из мониторинга.
    Один общий список на всех акков клиента — чтобы с других акков не писать
    повторно тем же людям (клиенты просили: имена часто совпадают "А", ".",
    нужна дедупликация именно по telegram user_id)."""
    from config import DATA_DIR
    return DATA_DIR / "monitor_contacted.json"


def _contacted_backup_file():
    """Бэкап игнор-базы в ~/.telepush/ — переживает полный сброс data/
    (например при перетаскивании в новую папку или при ручной чистке).
    Зеркало основного файла, читается как fallback."""
    from pathlib import Path
    return Path.home() / ".telepush" / "monitor_contacted.json"


def _load_contacted() -> set:
    """Читает из data/, если пусто — пробует из ~/.telepush/ бэкап."""
    import json
    primary = _contacted_file()
    backup = _contacted_backup_file()
    for path in (primary, backup):
        try:
            if not path.exists():
                continue
            data = json.loads(path.read_text(encoding="utf-8"))
            if isinstance(data, list):
                ids = set(int(x) for x in data if str(x).strip().lstrip("-").isdigit())
            elif isinstance(data, dict):
                ids = set(int(x) for x in data.get("ids", []) if str(x).strip().lstrip("-").isdigit())
            else:
                continue
            if ids:
                # Если читали из бэкапа а основной пуст — восстанавливаем основной
                if path == backup and not primary.exists():
                    try:
                        primary.parent.mkdir(parents=True, exist_ok=True)
                        primary.write_bytes(backup.read_bytes())
                        logger.info("[monitor] игнор-база восстановлена из ~/.telepush (%d юзеров)", len(ids))
                    except Exception:
                        pass
                return ids
        except Exception as e:
            logger.debug("[monitor] contacted load %s: %s", path, e)
    return set()


def _save_contacted(ids: set) -> None:
    """Пишет в основной файл (data/) И в бэкап (~/.telepush/) — чтобы
    база переживала любые манипуляции с папкой data/."""
    import json
    payload = json.dumps({"ids": sorted(int(x) for x in ids)}, ensure_ascii=False)
    for path in (_contacted_file(), _contacted_backup_file()):
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(payload, encoding="utf-8")
        except Exception as e:
            logger.debug("[monitor] contacted save %s: %s", path, e)


# Атомарный «проверить и пометить»: один общий lock на процесс. Без него два
# параллельных monitor-джоба на разных акках, слушающих один чат, оба читали
# пустой файл → оба алертили того же юзера. Теперь только первый успевает.
import threading as _th_mod
_CONTACTED_LOCK = _th_mod.Lock()
_CONTACTED_CACHE = None  # ленивая инициализация при первом claim


def _claim_contact(user_id: int) -> bool:
    """Возвращает True если юзер ещё не контактировался (и сразу его помечает).
    False — юзер уже в списке, обрабатывать не нужно. Атомарно для всех потоков."""
    global _CONTACTED_CACHE
    if not user_id:
        return True
    with _CONTACTED_LOCK:
        if _CONTACTED_CACHE is None:
            _CONTACTED_CACHE = _load_contacted()
        if user_id in _CONTACTED_CACHE:
            return False
        _CONTACTED_CACHE.add(user_id)
        _save_contacted(_CONTACTED_CACHE)
        return True


def _release_contact(user_id: int) -> None:
    """Снять claim — если все попытки доставки алерта/DM провалились, чтобы
    в следующий раз попробовать ещё."""
    global _CONTACTED_CACHE
    if not user_id:
        return
    with _CONTACTED_LOCK:
        if _CONTACTED_CACHE is None:
            return
        _CONTACTED_CACHE.discard(user_id)
        _save_contacted(_CONTACTED_CACHE)


def _is_contacted(user_id: int) -> bool:
    """Только проверка, без записи."""
    global _CONTACTED_CACHE
    if not user_id:
        return False
    with _CONTACTED_LOCK:
        if _CONTACTED_CACHE is None:
            _CONTACTED_CACHE = _load_contacted()
        return user_id in _CONTACTED_CACHE


async def run_monitor_job(job: MonitorJob):
    """Главный цикл: резолвит чаты, вешает хендлер, ждёт стопа."""
    job.status = "running"
    job.started_at = time.time()
    client = job.acc.client

    # Диагностика Bot API при старте — чтобы клиент видел в консоли
    # реально ли будет слать через бота или через акк в fallback.
    _bot_ok = bool(job.bot_token) and bool(job.bot_chat_id)
    if _bot_ok:
        logger.info("[monitor] 🤖 Bot API настроен: token=***%s, chat_id=%s",
                    (job.bot_token or "")[-4:], job.bot_chat_id)
    else:
        _reason = []
        if not job.bot_token: _reason.append("токен пустой")
        if not job.bot_chat_id: _reason.append("chat_id пустой")
        logger.warning("[monitor] ⚠ Bot API НЕ используется (%s) — алерты пойдут через target=%s",
                       ", ".join(_reason), job.target)

    # Если используем Bot API — не обязательно резолвить target через аккаунт
    # (акк может даже не быть знаком с чатом-приёмником). Резолвим «me» чтобы
    # не сломать fallback-путь если Bot API отвалится.
    try:
        target_ent = await _resolve_target(client, job.target or "me")
        job.target_entity = target_ent
        logger.info("[monitor] uid=%s target=%s (id=%s, bot=%s)",
                    job.owner_id, job.target, getattr(target_ent, "id", "?"),
                    "yes" if (job.bot_token and job.bot_chat_id) else "no")
    except Exception as e:
        if job.bot_token and job.bot_chat_id:
            # Bot API работает независимо от target_entity — просто логируем.
            logger.warning("[monitor] target %r не резолвится, но Bot API активен: %s",
                           job.target, e)
            job.target_entity = None
        else:
            logger.error("[monitor] не удалось получить target %r: %s", job.target, e)
            job.status = "stopped"
            return

    entities, failed = await _resolve_chats(client, job.chat_inputs)
    job.failed_chats = failed
    job.resolved_chats = [int(getattr(e, "id", 0)) for e in entities if getattr(e, "id", None)]
    if failed:
        logger.warning("[monitor] не открылись чаты: %s", "; ".join(failed[:5]))
    if not entities:
        logger.error(
            "[monitor] нет чатов для мониторинга — останавливаюсь. "
            "Аккаунт %s не смог найти ни один чат ни в своих диалогах, ни через API. "
            "ЧТО ДЕЛАТЬ: (1) с этого аккаунта зайди в Telegram, вступи/подпишись руками "
            "в нужные чаты — они попадут в диалоги и мониторинг их подхватит. ИЛИ "
            "(2) используй другой аккаунт где нет ограничений Telegram (этот может быть "
            "spam-block-нут и не может резолвить публичные username-ы через API). ИЛИ "
            "(3) смени прокси — некоторые режут ResolveUsernameRequest.",
            getattr(job.acc, "phone", "?"),
        )
        job.status = "stopped"
        return
    logger.info("[monitor] стартует: %d чатов, %d ключей, %d стоп-слов, владелец=%s",
                len(entities), len(job.keywords),
                len(getattr(job, "ignore_words", []) or []), job.owner_id)

    # Anti-detect: пройтись по resolved-чатам и попробовать решить капчу-боты
    # (если в чате висит непройденная капча — мониторинг не получает сообщения)
    ad_cfg = getattr(job, 'ad_config', None) or {}
    if ad_cfg.get('solve_captcha'):
        try:
            from utils.anti_detect import solve_captcha
            for ent in entities[:30]:
                try:
                    await solve_captcha(client, ent, module="monitor")
                except Exception:
                    pass
        except Exception as _ce:
            logger.debug("[monitor] solve_captcha pass failed: %s", _ce)

    kws_lower = [k.lower() for k in job.keywords if k.strip()]
    ign_lower = [w.lower() for w in (getattr(job, "ignore_words", []) or []) if w.strip()]

    # Локальный CSV-лог всех попаданий рядом с Экспорт/Мониторинг_<start>.csv
    from config import DATA_DIR
    _base = DATA_DIR.parent / "\u042d\u043a\u0441\u043f\u043e\u0440\u0442"
    try:
        _base.mkdir(parents=True, exist_ok=True)
    except Exception:
        _base = DATA_DIR / "exports"
        _base.mkdir(parents=True, exist_ok=True)
    _stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    _csv_path = _base / f"\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433_{_stamp}.csv"
    try:
        with open(_csv_path, "w", encoding="utf-8-sig") as fh:
            fh.write("time;chat;author;keyword;link;text\n")
    except Exception as e:
        logger.warning("[monitor] CSV init failed: %s", e)
        _csv_path = None
    job.csv_path = str(_csv_path) if _csv_path else ""

    def _append_csv(time_s, chat_title, author, kw, link, text):
        if not _csv_path:
            return
        try:
            # Экранируем ; и переводы строк для CSV
            def _esc(v):
                v = str(v or "").replace("\r", " ").replace("\n", " ").replace(";", ",").replace("\"", "'")
                return v
            line = ";".join([_esc(time_s), _esc(chat_title), _esc(author),
                             _esc(kw), _esc(link), _esc(text)]) + "\n"
            with open(_csv_path, "a", encoding="utf-8-sig") as fh:
                fh.write(line)
        except Exception as _ce:
            logger.debug("[monitor] csv write: %s", _ce)

    async def _on_message(event):
        try:
            if job.stop_flag:
                return
            # Дедуп — защита от двойной обработки того же сообщения:
            # (1) realtime events.NewMessage и polling-fallback могут
            #     пересечься по таймингу и оба обработать одно сообщение
            # (2) Telegram сам иногда шлёт update дважды через разных peer-ов
            # (3) realtime и polling делают разные значения event.chat_id
            #     (чистый id vs -100<id>), поэтому нормализуем: берём abs()
            try:
                # Нормализация chat_id: Telegram/Telethon даёт его в разных
                # формах — чистый id (123456789), отрицательный (-123456789),
                # полный channel-id (-100123456789). Приводим к чистому числу
                # через abs и удаление префикса 100 если он есть.
                _raw_cid = int(event.chat_id)
                _cid_abs = abs(_raw_cid)
                # Если id начинается с 100 и длиннее 10 цифр — это форма
                # -100<channel_id>, убираем префикс.
                _cid_str = str(_cid_abs)
                if len(_cid_str) >= 13 and _cid_str.startswith("100"):
                    _cid_norm = int(_cid_str[3:])
                else:
                    _cid_norm = _cid_abs
                _mid_norm = int(event.message.id)
                _dedup_key = (_cid_norm, _mid_norm)
                # Если работаем в пуле (multi-acc) — используем общий set,
                # чтобы 5 акков мониторящих один чат не сделали 5 алертов
                # на одно и то же сообщение.
                _msgs_set = job._shared_msgs if job._shared_msgs is not None else job._processed_msgs
                if _dedup_key in _msgs_set:
                    return
                _msgs_set.add(_dedup_key)
                if len(_msgs_set) > 5000:
                    _half = len(_msgs_set) // 2
                    _new = set(list(_msgs_set)[_half:])
                    _msgs_set.clear()
                    _msgs_set.update(_new)
            except Exception:
                pass
            text = (event.message.message or "").lower()
            if not text:
                return
            # Стоп-слова: если в тексте встречается любое — алерта/DM не будет.
            # Клиент использует чтобы отсеивать рекламу/казино/спам которые
            # ловит широкий ключ типа «куплю» или «ищу».
            if ign_lower:
                for _bad in ign_lower:
                    if _bad and _bad in text:
                        logger.debug("[monitor] ⊘ стоп-слово «%s» — пропуск", _bad)
                        return
            matched = None
            for kw in kws_lower:
                if kw in text:
                    matched = kw
                    break
            if not matched:
                return

            # Дедуп по ТЕКСТУ — если идентичный текст уже был замечен за
            # последние 12 часов, считаем это копипаст-спамом и пропускаем.
            # Защищает от сценария: один и тот же рекламный пост бот-спаммер
            # кидает в 10 разных чатов — приходит 10 алертов с одинаковым
            # текстом. Теперь первый попадает, остальные отсеиваются.
            try:
                import hashlib as _hl
                _norm_text = " ".join(text.split())  # нормализуем пробелы
                if len(_norm_text) >= 30:  # хешируем только осмысленные тексты
                    _txt_hash = _hl.sha256(_norm_text.encode("utf-8")).hexdigest()[:16]
                    _now_ts = time.time()
                    _hashes = job._shared_text_hashes if job._shared_text_hashes is not None else job._processed_text_hashes
                    _seen_at = _hashes.get(_txt_hash)
                    # 12 часов = 43200 сек; после этого считаем новой волной
                    if _seen_at and (_now_ts - _seen_at) < 43200:
                        logger.info("[monitor] ⊘ пропуск текст-дубля (тот же пост уже алертили %d мин назад)",
                                    int((_now_ts - _seen_at) / 60))
                        return
                    _hashes[_txt_hash] = _now_ts
                    # Чистим старые записи (> 12ч) и ограничиваем размер
                    if len(_hashes) > 2000:
                        _cutoff = _now_ts - 43200
                        _new = {k: v for k, v in _hashes.items() if v > _cutoff}
                        _hashes.clear()
                        _hashes.update(_new)
            except Exception as _te:
                logger.debug("[monitor] text-dedup error: %s", _te)
            # Небольшой антиспам: не чаще 1 алерта в секунду
            _now = time.time()
            if _now - job._last_match_at < 0.5:
                await asyncio.sleep(0.5)
            job._last_match_at = _now
            try:
                sender = await event.get_sender()
            except Exception:
                sender = None
            try:
                chat = await event.get_chat()
            except Exception:
                chat = None

            # Игнор-лист: если автор коммента уже был обработан (писали ему
            # вручную / авто-DM / алертили в прошлом) — полностью пропускаем
            # всё сообщение, не алертим и не DM-им. Клиент просил: диалог с
            # юзером уже был, дубли не нужны.
            sender_id = 0
            try:
                sender_id = int(getattr(sender, "id", 0) or 0)
            except Exception:
                sender_id = 0
            # Атомарный claim: помечаем юзера в contacted ДО алерта. Если
            # параллельный handler (другой акк-мониторщик) уже его захватил —
            # выходим. Гарантирует «один алерт на user_id» даже при гонке.
            if sender_id:
                if not _claim_contact(sender_id):
                    job.auto_dm_skipped_dup += 1
                    logger.info("[monitor] ⊗ пропуск юзера id=%s (уже в игноре, ключ %s)",
                                sender_id, matched)
                    return

            notif = _format_notification(chat, sender, event.message, matched)
            # CSV-лог — независимо от отправки алерта, чтобы ничего не терять
            try:
                try:
                    _dt = event.message.date
                    if _dt and _dt.tzinfo is None:
                        _dt = _dt.replace(tzinfo=timezone.utc)
                    _time_s = _dt.astimezone().strftime("%Y-%m-%d %H:%M:%S") if _dt else ""
                except Exception:
                    _time_s = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                _append_csv(
                    _time_s,
                    getattr(chat, "title", "") or getattr(chat, "username", "") or "",
                    _format_author(sender),
                    matched,
                    _format_link(chat, getattr(event.message, "id", 0)),
                    (getattr(event.message, "message", None) or "").strip()[:500],
                )
            except Exception:
                pass
            # Отправка алерта: через Bot API (если токен задан) ИЛИ от лица
            # аккаунта-мониторщика (fallback).
            sent_ok = False
            if job.bot_token and job.bot_chat_id:
                try:
                    import aiohttp
                    url = f"https://api.telegram.org/bot{job.bot_token}/sendMessage"
                    payload = {
                        "chat_id": job.bot_chat_id,
                        "text": notif,
                        "parse_mode": "HTML",
                        "disable_web_page_preview": True,
                    }
                    async with aiohttp.ClientSession() as sess:
                        async with sess.post(url, json=payload, timeout=15) as resp:
                            data = await resp.json()
                            if data.get("ok"):
                                sent_ok = True
                                job.matched += 1
                                logger.info("[monitor] 🤖 Bot API алерт #%d (%s) в %s → chat_id=%s",
                                            job.matched, matched, getattr(chat, "title", "?"), job.bot_chat_id)
                            else:
                                # Подробная диагностика для клиента
                                _desc = data.get("description", "") or str(data)
                                _code = data.get("error_code", "?")
                                _hint = ""
                                if _code == 400 and "chat not found" in _desc.lower():
                                    _hint = " → проверь Chat ID (должен быть числом вида 123456789 или -1001234567890 для группы)"
                                elif _code == 400 and "can't parse" in _desc.lower():
                                    _hint = " → ошибка HTML-разметки в тексте"
                                elif _code == 401:
                                    _hint = " → токен неверный/отозван, создай бота заново у @BotFather"
                                elif _code == 403 and "blocked" in _desc.lower():
                                    _hint = " → юзер с этим chat_id заблокировал бота, напиши /start боту снова"
                                elif _code == 403 and "not a member" in _desc.lower():
                                    _hint = " → бот не в чате, добавь его и сделай админом"
                                elif _code == 403:
                                    _hint = " → напиши боту /start со своего Telegram (с аккаунта с chat_id=" + str(job.bot_chat_id) + ")"
                                logger.warning("[monitor] Bot API отказал [код %s]: %s%s",
                                               _code, _desc, _hint)
                except Exception as be:
                    logger.warning("[monitor] Bot API ошибка: %s — fallback через акк", be)
            if not sent_ok:
                try:
                    await client.send_message(job.target_entity, notif, parse_mode="html", link_preview=False)
                    job.matched += 1
                    logger.info("[monitor] матч (%s) в %s — алерт #%d",
                                matched, getattr(chat, "title", "?"), job.matched)
                except Exception as se:
                    logger.warning("[monitor] send_message failed: %s", se)

            # Автосообщение в ЛС автору (если включено). Дедупликация уже
            # сработала наверху — если мы до сюда дошли, юзера в contacted нет.
            _sent_dm = False
            if job.auto_dm_text and sender is not None and sender_id:
                if sender_id != getattr(job.target_entity, "id", None):
                    try:
                        from bot_ui.utils import spintax_expand as _sx
                        msg_out = _sx(job.auto_dm_text)
                    except Exception:
                        msg_out = job.auto_dm_text
                    # Задержка чтобы не выглядеть как бот (3-10с)
                    import random as _rnd
                    await asyncio.sleep(_rnd.uniform(3, 10))
                    try:
                        await client.send_message(sender, msg_out, link_preview=False)
                        job.auto_dm_sent += 1
                        _sent_dm = True
                        logger.info("[monitor] ✉ авто-DM юзеру id=%s (всего %d)",
                                    sender_id, job.auto_dm_sent)
                    except Exception as _de:
                        logger.warning("[monitor] авто-DM %s не ушло: %s",
                                       sender_id, _de)

            # contacted уже помечен атомарно через _claim_contact выше. Если
            # ВСЕ способы доставки провалились (ни алерт, ни fallback, ни DM
            # не прошли) — снимаем claim чтобы в следующий раз дать шанс.
            if sender_id and not (sent_ok or _sent_dm):
                _release_contact(sender_id)
        except Exception as e:
            logger.debug("[monitor] handler error: %s", e)

    # Собираем набор chat_id для ручной фильтрации в обёртке ниже. Берём и
    # «сырой» id энтити, и нормализованный megagroup/channel id (-100...) —
    # Telethon в разных версиях делает peer.chat_id/channel_id по-разному.
    allowed_ids = set()
    for e in entities:
        try:
            eid = getattr(e, "id", None)
            if eid is None:
                continue
            allowed_ids.add(int(eid))
            # Для каналов/супергрупп добавляем -100<id> форму — она часто
            # приходит в event.chat_id вместо чистого id.
            if hasattr(e, "megagroup") or hasattr(e, "broadcast"):
                allowed_ids.add(int(f"-100{eid}"))
            # Минусовая форма для чатов (basic group → -id)
            allowed_ids.add(-int(eid))
        except Exception:
            pass
    logger.info("[monitor] allowed chat_ids для фильтрации: %s", sorted(allowed_ids)[:20])

    # Обёртка — проверяем chat_id вручную, не полагаясь на Telethon-фильтр.
    # В разных версиях Telethon `events.NewMessage(chats=[...])` по-разному
    # нормализует ID, и для публичных супергрупп фильтр мог ложно отсекать
    # сообщения. Клиент писал ключевое слово в мониторимый чат — алерта нет.
    async def _on_message_filtered(event):
        try:
            cid = None
            try:
                cid = int(event.chat_id)
            except Exception:
                pass
            if cid is not None and cid not in allowed_ids:
                return
            await _on_message(event)
        except Exception as e:
            logger.debug("[monitor] filter wrap error: %s", e)

    try:
        client.add_event_handler(_on_message_filtered, events.NewMessage())
        job._handler = _on_message_filtered
        logger.info("[monitor] handler повешен на NewMessage, фильтр — ручной по %d чатам", len(allowed_ids))
    except Exception as e:
        logger.error("[monitor] add_event_handler failed: %s", e)
        job.status = "stopped"
        return

    # Диагностический «watchdog»-счётчик всех входящих (чтобы видеть что
    # updates-loop вообще работает, даже если ни один матч не случился).
    job._total_events = 0
    async def _on_any(event):
        try:
            job._total_events += 1
        except Exception:
            pass
    try:
        client.add_event_handler(_on_any, events.NewMessage())
        # Сохраняем для cleanup'а — иначе на каждом reconnect (ниже в heartbeat
        # при отвале коннекта) handler накапливается заново, а старый никто
        # не снимает. Через сутки на одном клиенте висит 20+ копий счётчика.
        job._counter_handler = _on_any
    except Exception:
        pass

    # Каждые 60с пишем в лог сколько событий пришло — клиент видит что
    # мониторинг живой, даже если матчей нет. Заодно следит за TCP-коннектом:
    # если client отвалился (NAT/файрвол/сеть пропала на ноуте), пытаемся
    # переподключиться и заново перевесить handler — без этого мониторинг
    # молча умирал на сутки и просыпался только после ручного рестарта.
    async def _heartbeat():
        import asyncio as _aio
        last = 0
        _silent_minutes = 0
        # ВЕСЬ цикл оборачиваем в try/except — без этого случайное исключение
        # (например при race с client.disconnect) убивало heartbeat-таску
        # навсегда без следа в логах.
        while not job.stop_flag:
            try:
                await _aio.sleep(60)
                if job.stop_flag: break
                diff = job._total_events - last
                last = job._total_events
                logger.info("[monitor] ♥ за последнюю минуту: %d сообщений, %d алертов всего",
                            diff, job.matched)
                # Самолечение: если клиент отвалился — реконнект + перевешивание handler.
                try:
                    if client and not client.is_connected():
                        logger.warning("[monitor] клиент отключён — переподключаюсь…")
                        try:
                            await _aio.wait_for(client.connect(), timeout=20)
                            logger.info("[monitor] ✅ переподключён, перевешиваю handler")
                            try:
                                if job._handler is not None:
                                    client.remove_event_handler(job._handler)
                            except Exception:
                                pass
                            client.add_event_handler(_on_message_filtered, events.NewMessage())
                            job._handler = _on_message_filtered
                            try:
                                await _aio.wait_for(client.catch_up(), timeout=10)
                            except Exception:
                                pass
                            _silent_minutes = 0
                        except Exception as _re:
                            logger.warning("[monitor] reconnect провалился: %s", _re)
                            continue
                except Exception as _ce:
                    logger.debug("[monitor] heartbeat reconnect-check error: %s", _ce)
                # Тишина 30 минут — пробуждаем update-loop через catch_up
                if diff == 0:
                    _silent_minutes += 1
                else:
                    _silent_minutes = 0
                if _silent_minutes >= 30:
                    _silent_minutes = 0
                    try:
                        if client and client.is_connected():
                            logger.info("[monitor] 30 мин нет updates — пробуждаю update-loop через catch_up")
                            try:
                                await _aio.wait_for(client.catch_up(), timeout=15)
                            except Exception:
                                pass
                    except Exception:
                        pass
            except _aio.CancelledError:
                # Нормальное завершение при stop_flag
                raise
            except Exception as _hbe:
                # Никакая исключительная ситуация не должна убить heartbeat —
                # логируем и продолжаем спать минуту.
                logger.warning("[monitor] heartbeat tick error: %s — продолжаю", _hbe)
                try:
                    await _aio.sleep(30)
                except Exception:
                    pass
    _hb_task = asyncio.create_task(_heartbeat())
    # Если корутина всё-таки умрёт с unhandled — логируем явно (раньше Python
    # ругался только при GC: «Task exception was never retrieved»).
    def _hb_done(t):
        if t.cancelled(): return
        exc = t.exception()
        if exc:
            logger.error("[monitor] _heartbeat task crashed: %s", exc, exc_info=exc)
    _hb_task.add_done_callback(_hb_done)

    # Пробуем принудительно поднять update-loop через catch_up с таймаутом.
    # Раньше ждали бесконечно, сейчас — максимум 10 сек, не блокирует старт.
    try:
        await asyncio.wait_for(client.catch_up(), timeout=10)
        logger.info("[monitor] catch_up завершён, update-loop активен")
    except asyncio.TimeoutError:
        logger.warning("[monitor] catch_up не успел за 10с — включаем polling-fallback")
    except Exception as _cu_e:
        logger.warning("[monitor] catch_up упал: %s — polling-fallback", _cu_e)

    # Polling-fallback: даже если Telethon update-loop не доставляет события
    # (Telegram иногда перестаёт слать updates idle-аккаунтам), мы раз в 30с
    # напрямую читаем последние сообщения каждого чата и проверяем по ключам.
    # Не заменяет real-time handler, но гарантирует что ни один матч не потерян.
    async def _polling_fallback():
        import asyncio as _aio
        from telethon.tl import types as _tt
        # last_msg_id[chat_id] = max_msg_id который уже обработали
        last_msg_id = {}
        # Инициализация: запоминаем текущий max id в каждом чате, чтобы не
        # обработать задним числом всю историю как «матчи».
        for ent in entities:
            try:
                msgs = await client.get_messages(ent, limit=1)
                if msgs:
                    last_msg_id[getattr(ent, "id", 0)] = msgs[0].id
                else:
                    last_msg_id[getattr(ent, "id", 0)] = 0
            except Exception:
                last_msg_id[getattr(ent, "id", 0)] = 0
        logger.info("[monitor] polling-fallback инициализирован, %d чатов", len(last_msg_id))

        while not job.stop_flag:
            await _aio.sleep(30)
            if job.stop_flag:
                break
            _polled = 0
            _new_msgs = 0
            for ent in entities:
                if job.stop_flag:
                    break
                try:
                    cid = getattr(ent, "id", 0)
                    prev_max = last_msg_id.get(cid, 0)
                    # Берём последние 20 сообщений, отсеиваем те что уже обработали
                    msgs = await client.get_messages(ent, limit=20)
                    _polled += 1
                    if not msgs:
                        continue
                    new_max = max((m.id for m in msgs), default=prev_max)
                    # Обрабатываем только новые (id > prev_max), в хронологическом порядке
                    fresh = sorted([m for m in msgs if m.id > prev_max], key=lambda m: m.id)
                    _new_msgs += len(fresh)
                    for m in fresh:
                        try:
                            # Создаём псевдо-event — наш handler читает event.message
                            class _FakeEvent:
                                pass
                            fake = _FakeEvent()
                            fake.message = m
                            fake.chat_id = int(f"-100{cid}") if (hasattr(ent, "megagroup") or hasattr(ent, "broadcast")) else -cid
                            # get_sender/get_chat через Telethon API
                            async def _get_sender(_m=m):
                                try: return await _m.get_sender()
                                except Exception: return None
                            async def _get_chat(_e=ent):
                                return _e
                            fake.get_sender = _get_sender
                            fake.get_chat = _get_chat
                            fake.is_private = False
                            await _on_message(fake)
                        except Exception as _pe:
                            logger.debug("[monitor] polling msg %s in %s: %s",
                                         getattr(m, "id", "?"), cid, _pe)
                    last_msg_id[cid] = max(new_max, prev_max)
                except Exception as _pce:
                    logger.debug("[monitor] polling chat %s: %s", getattr(ent, "title", "?"), _pce)
                # Небольшая пауза чтобы не спамить rpc
                await _aio.sleep(0.3)
            if _polled > 0:
                logger.debug("[monitor] polling round: %d чатов, %d новых сообщений",
                             _polled, _new_msgs)
    _pl_task = asyncio.create_task(_polling_fallback())
    def _pl_done(t):
        if t.cancelled(): return
        exc = t.exception()
        if exc:
            logger.error("[monitor] _polling_fallback task crashed: %s", exc, exc_info=exc)
    _pl_task.add_done_callback(_pl_done)

    # Ждём стоп-флага
    while not job.stop_flag:
        await asyncio.sleep(1.0)

    try: _hb_task.cancel()
    except Exception: pass
    try: _pl_task.cancel()
    except Exception: pass

    # Cleanup — снимаем оба handler'а (основной + counter). Counter раньше
    # оставался зарегистрированным после stop → следующий старт мониторинга
    # на том же клиенте инкрементил два разных счётчика.
    try:
        if job._handler is not None:
            client.remove_event_handler(job._handler)
    except Exception:
        pass
    try:
        ch = getattr(job, "_counter_handler", None)
        if ch is not None:
            client.remove_event_handler(ch)
    except Exception:
        pass
    job.status = "stopped"
    logger.info("[monitor] остановлен. Алертов отправлено: %d", job.matched)
