"""
core/parser.py — Парсинг: юзернеймы, ссылки, ключевые слова, глобальный поиск.
Режимы: members, writers, links, keyword, search (глобальный).
Фильтры: онлайн, активность за N часов, без спам-блока, пропуск админов.
"""
import asyncio
import time
import random
import re
import logging
from typing import List, Tuple, Optional, Callable, Set, Dict
from datetime import datetime, timezone, timedelta

from telethon.tl.types import (
    UserStatusOnline, UserStatusOffline, UserStatusRecently,
    UserStatusLastWeek, UserStatusLastMonth, UserStatusEmpty,
    ChannelParticipantsSearch, ChannelParticipantsAdmins,
    ChannelParticipantsRecent,
    User, Channel, Chat,
    MessageEntityTextUrl, MessageEntityUrl, MessageEntityMention,
)
from telethon.tl.functions.channels import GetParticipantsRequest
from telethon.tl.functions.contacts import SearchRequest as ContactsSearchRequest
from telethon.errors import (
    FloodWaitError, ChatAdminRequiredError, ChannelPrivateError,
    MsgIdInvalidError,
)

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


# ── Progressive Delay (anti-flood) ───────────────────────────────────────────

class ProgressiveDelay:
    """Прогрессивная задержка — плавно увеличивается, резко на FloodWait."""

    def __init__(self, base=0.2, step=0.05, max_delay=3.0):
        self.delay = base
        self.base = base
        self.step = step
        self.max_delay = max_delay

    async def wait(self):
        await asyncio.sleep(self.delay)
        self.delay = min(self.delay + self.step, self.max_delay)

    async def on_flood(self, seconds: int):
        await asyncio.sleep(min(seconds + 1, 60))
        self.delay = min(self.delay * 2, self.max_delay)

    def reset(self):
        self.delay = self.base


# ── Фильтры статуса ──────────────────────────────────────────────────────────

def _is_online(user) -> bool:
    s = getattr(user, 'status', None)
    if isinstance(s, UserStatusOnline):
        return True
    if isinstance(s, UserStatusOffline):
        return (datetime.now(timezone.utc) - s.was_online.replace(tzinfo=timezone.utc)).total_seconds() < 300
    return False


def _was_active_within(user, seconds: int, *, strict: bool = False) -> bool:
    """Проверка активности. strict=True — только точные timestamps, без Recently."""
    s = getattr(user, 'status', None)
    if isinstance(s, UserStatusOnline):
        return True
    if isinstance(s, UserStatusOffline):
        delta = (datetime.now(timezone.utc) - s.was_online.replace(tzinfo=timezone.utc)).total_seconds()
        return delta <= seconds
    if strict:
        return False  # strict: Recently/LastWeek не считаются
    if isinstance(s, UserStatusRecently):
        return seconds >= 86400  # ~1-3 дня
    if isinstance(s, UserStatusLastWeek):
        return seconds >= 7 * 86400
    return False


def _seconds_since_online(user) -> Optional[float]:
    """Возвращает сколько секунд прошло с последнего онлайна. None если статус скрыт."""
    s = getattr(user, 'status', None)
    if isinstance(s, UserStatusOnline):
        return 0
    if isinstance(s, UserStatusOffline):
        return (datetime.now(timezone.utc) - s.was_online.replace(tzinfo=timezone.utc)).total_seconds()
    if isinstance(s, UserStatusRecently):
        return 3600  # ~час
    if isinstance(s, UserStatusLastWeek):
        return 7 * 86400 / 2  # ~3.5 дня
    if isinstance(s, UserStatusLastMonth):
        return 30 * 86400 / 2  # ~15 дней
    return None  # Status hidden или Empty


def _is_valid_user(user) -> bool:
    if not user or not isinstance(user, User):
        return False
    if user.bot or user.deleted:
        return False
    if getattr(user, 'scam', False) or getattr(user, 'fake', False):
        return False
    return True


def _has_username(user) -> bool:
    return bool(getattr(user, 'username', None))


def _has_spam_restriction(user) -> bool:
    """Расширенная проверка спам-блока: restriction_reason + restricted флаг."""
    if getattr(user, 'restricted', False):
        return True
    r = getattr(user, 'restriction_reason', None)
    if not r:
        return False
    spam_kw = ('spam', 'ban', 'restrict', 'fraud', 'abuse', 'scam', 'fake')
    for rr in r:
        reason = str(getattr(rr, 'reason', '')).lower()
        text = str(getattr(rr, 'text', '')).lower()
        if any(k in reason or k in text for k in spam_kw):
            return True
    return False


# ── Парсинг участников (Members) ─────────────────────────────────────────────

async def gather_members(
    client,
    chat_id: int,
    limit: int = 5000,
    filter_mode: str = "all",
    hours: int = 24,
    skip_admins: bool = False,
    strict_activity: bool = False,
    progress_cb: Optional[Callable] = None,
    extra_filters: Optional[Dict] = None,
) -> Tuple[List[str], dict]:
    """Парсит участников чата. Возвращает (список @username, stats).
    stats['_users_meta'] содержит полные данные юзеров (для серверной БД)."""
    usernames: Set[str] = set()
    users_meta: List[dict] = []
    stats = {"scanned": 0, "spam_skipped": 0, "no_username": 0, "filtered": 0, "admins_skipped": 0}
    delay = ProgressiveDelay()
    ef = extra_filters or {}
    save_mode = ef.get("save_mode", "usernames")  # usernames | usernames_ids
    roles_filter = ef.get("roles_filter", "users")  # users | admins | all
    only_avatar = bool(ef.get("only_avatar", False))
    only_premium = bool(ef.get("only_premium", False))
    online_min_h = int(ef.get("online_min_hours", 0))
    online_max_h = int(ef.get("online_max_hours", 99999))
    dm_access = str(ef.get("dm_access", "all") or "all").lower()  # all | open | closed

    seen_ids: Set[int] = set()

    def _process_user(user) -> None:
        """Прогоняет юзера через фильтры. Прибавляет в usernames если проходит."""
        if user.id in seen_ids:
            return
        seen_ids.add(user.id)
        stats["scanned"] += 1

        if not _is_valid_user(user):
            return

        # Фильтр ролей: only-users / only-admins / all
        is_admin = user.id in admin_ids
        if roles_filter == "users" and is_admin:
            stats["admins_skipped"] += 1
            return
        if roles_filter == "admins" and not is_admin:
            stats["filtered"] += 1
            return
        # Старый skip_admins оставляем для совместимости
        if skip_admins and is_admin:
            stats["admins_skipped"] += 1
            return

        if _has_spam_restriction(user):
            stats["spam_skipped"] += 1
            return
        if save_mode == "usernames" and not _has_username(user):
            stats["no_username"] += 1
            return
        # При save_mode=usernames_ids — пускаем и без юзернейма (по ID)

        # Фильтр: только с аватаркой
        if only_avatar and not getattr(user, "photo", None):
            stats["filtered"] += 1
            return
        # Фильтр: только с премиумом
        if only_premium and not getattr(user, "premium", False):
            stats["filtered"] += 1
            return

        # Фильтр доступа к ЛС: open=пишут все, closed=только Premium-контактам.
        # Telegram отдаёт contact_require_premium прямо в составе ChannelParticipants
        # (layer 178+), отдельный GetFullUser не нужен. Если поле отсутствует —
        # считаем ЛС открытыми (старое поведение, не теряем юзеров).
        if dm_access in ("open", "closed"):
            require_prem = bool(getattr(user, "contact_require_premium", False))
            if dm_access == "open" and require_prem:
                stats["filtered"] += 1
                return
            if dm_access == "closed" and not require_prem:
                stats["filtered"] += 1
                return

        # Фильтр последнего онлайна по диапазону часов
        if online_min_h > 0 or online_max_h < 99999:
            last_seen_seconds = _seconds_since_online(user)
            if last_seen_seconds is None:
                stats["filtered"] += 1
                return
            hours_ago = last_seen_seconds / 3600
            if hours_ago < online_min_h or hours_ago > online_max_h:
                stats["filtered"] += 1
                return

        # Старые filter_mode для совместимости
        if filter_mode == "online" and not _is_online(user):
            stats["filtered"] += 1
            return
        elif filter_mode == "hours" and not _was_active_within(user, hours * 3600, strict=strict_activity):
            stats["filtered"] += 1
            return

        # Сборка результата по save_mode
        if user.username:
            usernames.add(f"@{user.username}")
        elif save_mode == "usernames_ids":
            usernames.add(str(user.id))  # По ID если без юзернейма

        users_meta.append({
            "user_id": user.id,
            "username": user.username or "",
            "first_name": getattr(user, "first_name", "") or "",
            "last_name": getattr(user, "last_name", "") or "",
            "is_bot": bool(getattr(user, "bot", False)),
            "is_premium": bool(getattr(user, "premium", False)),
            "has_photo": bool(getattr(user, "photo", None)),
        })

    try:
        # ID админов для фильтра skip_admins
        admin_ids: Set[int] = set()
        if skip_admins:
            try:
                result = await client(GetParticipantsRequest(
                    channel=chat_id, filter=ChannelParticipantsAdmins(),
                    offset=0, limit=200, hash=0,
                ))
                admin_ids = {u.id for u in result.users}
                logger.info("[parser] Found %d admins to skip", len(admin_ids))
            except Exception as e:
                logger.debug("[parser] Failed to get admins: %s", e)

        # Пустой search("") с пагинацией по offset — стандартный способ получить
        # всех доступных участников. Ломаемся ТОЛЬКО когда Telegram вернул пустой
        # ответ (конец списка). Не ломаться на неполной странице — Telegram часто
        # отдаёт <200 на странице, но дальше ещё есть.
        offset = 0
        total_got = 0
        empty_pages = 0
        while total_got < limit:
            page_limit = min(200, limit - total_got)
            try:
                result = await client(GetParticipantsRequest(
                    channel=chat_id,
                    filter=ChannelParticipantsSearch(""),
                    offset=offset, limit=page_limit, hash=0,
                ))
            except FloodWaitError as e:
                logger.warning("[parser] FloodWait %ds", e.seconds)
                await delay.on_flood(e.seconds)
                continue
            except (ChatAdminRequiredError, ChannelPrivateError) as e:
                stats["error"] = str(e)
                return list(usernames), stats

            got = len(result.users)
            if got == 0:
                empty_pages += 1
                if empty_pages >= 2:
                    break
                # Ещё одна пустая страница может быть глюком — одна попытка добрать
                offset += page_limit
                await delay.wait()
                continue
            empty_pages = 0

            for user in result.users:
                _process_user(user)
                if len(usernames) >= limit:
                    break

            total_got += got
            offset += got

            if progress_cb:
                try:
                    await progress_cb(len(usernames), stats["scanned"])
                except Exception:
                    pass

            await delay.wait()

        logger.info("[parser] gather_members: scanned=%d, usernames=%d (limit=%d)",
                    stats["scanned"], len(usernames), limit)

        # Подсказка если Telegram сильно ограничил выдачу для non-admin
        if stats["scanned"] < 50 and total_got < 50:
            stats["hint"] = (
                "Telegram отдал очень мало участников — обычно это ограничение API "
                "для non-admin сессии на больших группах. Попробуйте режим «Писавшие»."
            )

    except Exception as e:
        logger.error("[parser] gather_members error: %s", e)
        stats["error"] = str(e)

    result_list = list(usernames)
    random.shuffle(result_list)
    stats["total"] = len(result_list)
    stats["_users_meta"] = users_meta  # для upload на сервер
    _report_stats(users=len(result_list))
    return result_list, stats


# ── Парсинг писателей (Writers) ──────────────────────────────────────────────

async def gather_writers(
    client,
    chat_id: int,
    limit_messages: int = 3000,
    filter_mode: str = "all",
    hours: int = 24,
    min_messages: int = 1,
    strict_activity: bool = False,
    date_limit_days: int = 0,
    skip_admins: bool = False,
    progress_cb: Optional[Callable] = None,
    extra_filters: Optional[Dict] = None,
) -> Tuple[List[str], dict]:
    """Парсит юзеров которые писали в чат."""
    user_msg_count: Dict[int, int] = {}
    user_objects: Dict[int, User] = {}
    stats = {"scanned_msgs": 0, "spam_skipped": 0, "no_username": 0, "filtered": 0}
    delay = ProgressiveDelay(base=0.01)
    ef = extra_filters or {}
    only_avatar_w = bool(ef.get("only_avatar", False))
    only_premium_w = bool(ef.get("only_premium", False))
    online_min_w = int(ef.get("online_min_hours", 0))
    online_max_w = int(ef.get("online_max_hours", 99999))
    dm_access_w = str(ef.get("dm_access", "all") or "all").lower()

    offset_date = None
    if date_limit_days > 0:
        offset_date = datetime.now(timezone.utc) - timedelta(days=date_limit_days)

    # Получаем админов если нужно
    admin_ids: Set[int] = set()
    if skip_admins:
        try:
            result = await client(GetParticipantsRequest(
                channel=chat_id, filter=ChannelParticipantsAdmins(),
                offset=0, limit=200, hash=0,
            ))
            admin_ids = {u.id for u in result.users}
        except Exception:
            pass

    try:
        kwargs = {"limit": limit_messages}
        if offset_date:
            kwargs["offset_date"] = offset_date

        async for msg in client.iter_messages(chat_id, **kwargs):
            stats["scanned_msgs"] += 1
            sender = msg.sender
            if not sender or not _is_valid_user(sender):
                continue
            if sender.id in admin_ids:
                continue

            uid = sender.id
            user_msg_count[uid] = user_msg_count.get(uid, 0) + 1
            user_objects[uid] = sender

            # Извлекаем @username из текста сообщений (упоминания)
            text = getattr(msg, 'message', '') or ''
            if text:
                for m in RE_USERNAME_MENTION.finditer(text):
                    uname = m.group(1).lower()
                    if not uname.endswith('bot') and len(uname) >= 4:
                        stats.setdefault("mentions_found", 0)
                        stats["mentions_found"] += 1

            # Извлекаем из entities (MessageEntityMention, MessageEntityTextUrl)
            for ent in (getattr(msg, 'entities', None) or []):
                if isinstance(ent, MessageEntityMention):
                    try:
                        chunk = text[ent.offset:ent.offset + ent.length].lstrip('@')
                        if chunk and not chunk.lower().endswith('bot') and len(chunk) >= 4:
                            stats.setdefault("mentions_found", 0)
                            stats["mentions_found"] += 1
                    except Exception:
                        pass

            if progress_cb and stats["scanned_msgs"] % 500 == 0:
                await progress_cb(len(user_objects), stats["scanned_msgs"])
            if stats["scanned_msgs"] % 200 == 0:
                await delay.wait()

    except FloodWaitError as e:
        logger.warning("[parser] FloodWait %ds", e.seconds)
        stats["flood_wait"] = e.seconds
    except (ChannelPrivateError, ChatAdminRequiredError) as e:
        logger.info("[parser] gather_writers: нет доступа к %s (%s) — пропуск", chat_id, type(e).__name__)
        stats["skipped_private"] = 1
        stats["skip_reason"] = "Нет доступа к чату (приватный / вы не участник / бан)"
    except Exception as e:
        # «Cannot send requests while disconnected» бывает когда parser продолжает
        # работу, но клиент уже отключили stop'ом или таймаутом. Не error — info,
        # job штатно завершится по своему пути.
        msg = str(e).lower()
        if "disconnected" in msg or "not connected" in msg or "connection" in msg:
            logger.info("[parser] gather_writers: клиент отключён (нормально при stop) — %s", e)
            stats["skip_reason"] = "Аккаунт отключён во время сбора"
        else:
            logger.error("[parser] gather_writers error: %s", e)
            stats["error"] = str(e)

    usernames = []
    users_meta: List[dict] = []
    for uid, user in user_objects.items():
        if user_msg_count.get(uid, 0) < min_messages:
            continue
        if _has_spam_restriction(user):
            stats["spam_skipped"] += 1
            continue
        if not _has_username(user):
            stats["no_username"] += 1
            continue
        if filter_mode == "online" and not _is_online(user):
            stats["filtered"] += 1
            continue
        elif filter_mode == "hours" and not _was_active_within(user, hours * 3600, strict=strict_activity):
            stats["filtered"] += 1
            continue
        # Фильтр аватарки/премиума/ЛС/диапазон часов онлайна (общие с gather_members).
        if only_avatar_w and not getattr(user, "photo", None):
            stats["filtered"] += 1
            continue
        if only_premium_w and not getattr(user, "premium", False):
            stats["filtered"] += 1
            continue
        if dm_access_w in ("open", "closed"):
            require_prem_w = bool(getattr(user, "contact_require_premium", False))
            if dm_access_w == "open" and require_prem_w:
                stats["filtered"] += 1
                continue
            if dm_access_w == "closed" and not require_prem_w:
                stats["filtered"] += 1
                continue
        if online_min_w > 0 or online_max_w < 99999:
            secs_w = _seconds_since_online(user)
            if secs_w is None:
                stats["filtered"] += 1
                continue
            h_w = secs_w / 3600
            if h_w < online_min_w or h_w > online_max_w:
                stats["filtered"] += 1
                continue
        usernames.append(f"@{user.username}")
        users_meta.append({
            "user_id": user.id,
            "username": user.username or "",
            "first_name": getattr(user, "first_name", "") or "",
            "last_name": getattr(user, "last_name", "") or "",
            "is_bot": bool(getattr(user, "bot", False)),
        })

    random.shuffle(usernames)
    stats["total"] = len(usernames)
    stats["_users_meta"] = users_meta
    _report_stats(users=len(usernames))
    return usernames, stats


# ── Парсинг комментаторов канала (Broadcast) ─────────────────────────────────

RE_USERNAME_MENTION = re.compile(r'@([A-Za-z_]\w{3,31})\b')

async def gather_channel_commenters(
    client,
    chat_id: int,
    limit_posts: int = 200,
    filter_mode: str = "all",
    hours: int = 24,
    date_limit_days: int = 0,
    progress_cb: Optional[Callable] = None,
) -> Tuple[List[str], dict]:
    """Собирает юзернеймы из комментариев к постам канала + @mentions из текста."""
    usernames: Set[str] = set()
    stats = {"scanned_posts": 0, "commenters": 0, "mentions": 0}
    delay = ProgressiveDelay(base=0.15)

    offset_date = None
    if date_limit_days > 0:
        offset_date = datetime.now(timezone.utc) - timedelta(days=date_limit_days)

    try:
        # Собираем посты
        post_ids = []
        kwargs = {"limit": limit_posts}
        if offset_date:
            kwargs["offset_date"] = offset_date

        async for msg in client.iter_messages(chat_id, **kwargs):
            stats["scanned_posts"] += 1
            post_ids.append(msg.id)

            # @mentions из текста постов
            text = getattr(msg, 'message', '') or ''
            for m in RE_USERNAME_MENTION.finditer(text):
                uname = m.group(1).lower()
                if not uname.endswith('bot'):
                    usernames.add(f"@{uname}")
                    stats["mentions"] += 1

        # Собираем комментаторов
        for i, post_id in enumerate(post_ids):
            try:
                async for reply in client.iter_messages(chat_id, reply_to=post_id, limit=500):
                    sender = reply.sender
                    if sender and _is_valid_user(sender) and _has_username(sender):
                        if not _has_spam_restriction(sender):
                            if filter_mode == "all" or \
                               (filter_mode == "online" and _is_online(sender)) or \
                               (filter_mode == "hours" and _was_active_within(sender, hours * 3600)):
                                usernames.add(f"@{sender.username}")
                                stats["commenters"] += 1
            except (FloodWaitError,) as e:
                await delay.on_flood(e.seconds)
            except (MsgIdInvalidError, ChannelPrivateError):
                continue
            except Exception:
                continue

            if progress_cb and i % 10 == 0:
                await progress_cb(len(usernames), stats["scanned_posts"])
            await delay.wait()

    except (ChannelPrivateError, ChatAdminRequiredError) as e:
        logger.info("[parser] gather_channel_commenters: нет доступа к %s (%s) — пропуск", chat_id, type(e).__name__)
        stats["skipped_private"] = 1
        stats["skip_reason"] = "Нет доступа к каналу (приватный / вы не участник / бан)"
    except Exception as e:
        logger.error("[parser] gather_channel_commenters error: %s", e)
        stats["error"] = str(e)

    result = list(usernames)
    random.shuffle(result)
    stats["total"] = len(result)
    _report_stats(users=len(result))
    return result, stats


# ── Парсинг ссылок из чата ───────────────────────────────────────────────────

RE_TG_INVITE = re.compile(r'https?://t\.me/(?:\+|joinchat/)[A-Za-z0-9_-]+')
RE_TG_PUBLIC = re.compile(r'https?://t\.me/([A-Za-z_]\w{3,30})(?:/\d+)?')
RE_TG_ALL = re.compile(r'https?://(?:t\.me|telegram\.me)/[^\s<>"\')\]]+')
RE_HTTP_ANY = re.compile(r'https?://[^\s<>"\')\]]+')
RESERVED_SLUGS = {'joinchat', 'addstickers', 'addemoji', 'addtheme', 'addlist', 'proxy', 'iv', 'c', 's', 'share'}

# Домены для unwrap коротких URL
UNWRAP_DOMAINS = {'bit.ly', 'tinyurl.com', 't.co', 'is.gd', 'goo.gl', 'cutt.ly',
                  'ow.ly', 'vk.cc', 'clck.ru', 'click.ru', 't.ly',
                  'telegra.ph', 'tgraph.io', 'graph.org'}


def _normalize_link(url: str) -> str:
    """Нормализация Telegram ссылки с каноникализацией."""
    url = url.strip().rstrip('.,;:!?)>]')
    url = url.replace('telegram.me/', 't.me/')
    url = url.replace('http://', 'https://')
    # joinchat/HASH → +HASH
    url = re.sub(r'/joinchat/([A-Za-z0-9_-]+)', r'/+\1', url)
    # Убрать tracking params
    url = re.sub(r'\?(?:start|comment|thread|single)=[^&\s]*', '', url)
    # Lowercase slug для публичных (но не для invite hashes)
    if '/+' not in url:
        m = re.match(r'(https://t\.me/)([A-Za-z_]\w+)(.*)', url)
        if m:
            url = m.group(1) + m.group(2).lower() + m.group(3)
    return url


def _is_valid_tg_link(url: str) -> bool:
    if '/c/' in url:
        return False
    m = re.match(r'https?://t\.me/(\w+)', url)
    if m and m.group(1).lower() in RESERVED_SLUGS:
        return False
    return True


def _should_unwrap(url: str) -> bool:
    """Проверяет нужно ли разворачивать короткий URL."""
    try:
        from urllib.parse import urlparse
        host = urlparse(url).netloc.lower()
        if ':' in host:
            host = host.split(':')[0]
        return host in UNWRAP_DOMAINS or any(host.endswith('.' + d) for d in UNWRAP_DOMAINS)
    except Exception:
        return False


async def _unwrap_short_urls(urls: Set[str], session=None) -> Set[str]:
    """Разворачивает короткие URL, возвращает найденные t.me ссылки."""
    to_unwrap = [u for u in urls if _should_unwrap(u)]
    if not to_unwrap:
        return set()

    found: Set[str] = set()
    try:
        import aiohttp
        should_close = False
        if session is None:
            timeout = aiohttp.ClientTimeout(total=8, connect=4)
            session = aiohttp.ClientSession(timeout=timeout)
            should_close = True

        try:
            for url in to_unwrap[:5]:  # макс 5 URL на сообщение
                try:
                    async with session.get(url, allow_redirects=True, max_redirects=10) as resp:
                        final = str(resp.url)
                        if 't.me/' in final:
                            normalized = _normalize_link(final)
                            if _is_valid_tg_link(normalized):
                                found.add(normalized)
                except Exception:
                    continue
        finally:
            if should_close:
                await session.close()
    except Exception:
        pass
    return found


def _utf16_slice(text: str, offset: int, length: int) -> str:
    """Telegram MessageEntity offset/length считаются в UTF-16 code units,
    а Python str — в Unicode codepoints. Без конверсии эмодзи (🔥 = 1 codepoint
    но 2 UTF-16 units) сдвигают offset → URL обрезается посередине.

    Раньше: text[offset:offset+length] → 'tps://t.me/+ab' / 's://t.me/+a bc' /
    пробелы внутри инвайт-хеша / кириллица в начале.

    Теперь: encode в utf-16-le → байтовый срез по 2*offset → decode обратно."""
    try:
        b = text.encode("utf-16-le", errors="replace")
        chunk = b[offset * 2:(offset + length) * 2]
        return chunk.decode("utf-16-le", errors="replace")
    except Exception:
        # Fallback на codepoint-индексацию
        return text[offset:offset + length]


def _clean_url_candidate(url: str) -> str:
    """Защита от мусора в URL: только http(s)://, обрезка по первому пробелу."""
    if not url:
        return ""
    url = url.strip()
    # Если в URL вкралcя пробел/перенос — берём только до него
    for sep in (" ", "\n", "\t", "\r"):
        if sep in url:
            url = url.split(sep, 1)[0]
    # Только валидный http(s) префикс — отсекает "tps://", "s://" и пр.
    if not (url.startswith("http://") or url.startswith("https://")):
        return ""
    return url


async def _extract_links_from_msg(msg, *, unwrap: bool = False) -> List[str]:
    """Извлекает Telegram ссылки из сообщения (текст + entities + кнопки + unwrap)."""
    links: Set[str] = set()
    external_urls: Set[str] = set()
    text = getattr(msg, 'text', '') or getattr(msg, 'message', '') or ''

    # Из текста по regex (regex по строке Unicode-целостен — offset-проблем нет)
    for m in RE_TG_ALL.finditer(text):
        url = _clean_url_candidate(m.group())
        if not url:
            continue
        url = _normalize_link(url)
        if _is_valid_tg_link(url):
            links.add(url)

    # Из entities (UTF-16 slice + sanity)
    entities = getattr(msg, 'entities', None) or []
    for ent in entities:
        url = None
        if isinstance(ent, MessageEntityTextUrl) and ent.url:
            # У TextUrl URL прилетает уже целым в ent.url — offset не нужен
            url = ent.url
        elif isinstance(ent, MessageEntityUrl):
            try:
                url = _utf16_slice(text, int(ent.offset), int(ent.length))
            except Exception:
                pass
        url = _clean_url_candidate(url or "")
        if not url:
            continue
        if 't.me/' in url or 'telegram.me/' in url:
            url = _normalize_link(url)
            if _is_valid_tg_link(url):
                links.add(url)
        elif unwrap:
            external_urls.add(url)

    # Из кнопок
    if hasattr(msg, 'reply_markup') and msg.reply_markup:
        rows = getattr(msg.reply_markup, 'rows', []) or []
        for row in rows:
            for btn in getattr(row, 'buttons', []) or []:
                url = _clean_url_candidate(getattr(btn, 'url', None) or "")
                if not url:
                    continue
                if 't.me/' in url:
                    url = _normalize_link(url)
                    if _is_valid_tg_link(url):
                        links.add(url)
                elif unwrap:
                    external_urls.add(url)

    # Из внешних URL собираем для unwrap
    if unwrap and not external_urls:
        for m in RE_HTTP_ANY.finditer(text):
            u = _clean_url_candidate(m.group())
            if u and 't.me/' not in u and _should_unwrap(u):
                external_urls.add(u)

    # Unwrap коротких URL
    if unwrap and external_urls:
        unwrapped = await _unwrap_short_urls(external_urls)
        links.update(unwrapped)

    return list(links)


async def gather_links(
    client,
    chat_id: int,
    limit_messages: int = 5000,
    link_type: str = "all",
    date_limit_days: int = 0,
    unwrap_urls: bool = False,
    progress_cb: Optional[Callable] = None,
) -> Tuple[List[str], dict]:
    """Парсит Telegram ссылки из сообщений чата."""
    links: Set[str] = set()
    stats = {"scanned_msgs": 0, "invites": 0, "public": 0, "total": 0, "unwrapped": 0}
    delay = ProgressiveDelay(base=0.01)

    offset_date = None
    if date_limit_days > 0:
        offset_date = datetime.now(timezone.utc) - timedelta(days=date_limit_days)

    try:
        kwargs = {"limit": limit_messages}
        if offset_date:
            kwargs["offset_date"] = offset_date

        async for msg in client.iter_messages(chat_id, **kwargs):
            stats["scanned_msgs"] += 1

            extracted = await _extract_links_from_msg(msg, unwrap=unwrap_urls)
            for url in extracted:
                is_invite = '/+' in url
                if link_type == "invites" and not is_invite:
                    continue
                if link_type == "public" and is_invite:
                    continue

                if url not in links:
                    links.add(url)
                    if is_invite:
                        stats["invites"] += 1
                    else:
                        stats["public"] += 1

            if progress_cb and stats["scanned_msgs"] % 500 == 0:
                await progress_cb(len(links), stats["scanned_msgs"])
            if stats["scanned_msgs"] % 200 == 0:
                await delay.wait()

    except FloodWaitError as e:
        logger.warning("[parser] FloodWait %ds", e.seconds)
        stats["flood_wait"] = e.seconds
    except (ChannelPrivateError, ChatAdminRequiredError) as e:
        # Аккаунт не в чате / забанен / канал приватный — нормальная ситуация
        # при массовом парсинге. НЕ ошибка уровня UI.
        logger.info("[parser] gather_links: нет доступа к %s (%s) — пропуск", chat_id, type(e).__name__)
        stats["skipped_private"] = 1
        stats["skip_reason"] = "Нет доступа к чату (приватный / вы не участник / бан)"
    except Exception as e:
        # Техническое имя класса + сообщение, чтобы в UI не висело одинокое «Ошибка»
        _emsg = f"{type(e).__name__}: {e}" if str(e) else type(e).__name__
        logger.error("[parser] gather_links error: %s", _emsg)
        stats["error"] = _emsg

    result = list(links)
    stats["total"] = len(result)
    _report_stats(links=len(result))
    return result, stats


# ── Поиск по ключевым словам ─────────────────────────────────────────────────

async def keyword_search(
    client,
    keywords: List[str],
    scope: str = "all",
    limit_per_chat: int = 100,
    collect_links: bool = True,
    collect_usernames: bool = False,
    unwrap_urls: bool = False,
    progress_cb: Optional[Callable] = None,
) -> Tuple[List[str], dict]:
    """Поиск по ключевым словам во всех чатах аккаунта."""
    from telethon.tl import types as _tt
    results: Set[str] = set()
    stats = {"chats_searched": 0, "messages_found": 0, "keywords": len(keywords),
             "dialogs_total": 0, "dialogs_after_scope": 0}
    delay = ProgressiveDelay(base=0.15)

    try:
        dialogs = []
        raw_count = 0
        async for d in client.iter_dialogs():  # без limit — берём всё
            raw_count += 1
            ent = d.entity
            # DM-юзеры (tt.User) исключаются из групп/каналов
            if scope in ("groups", "channels") and isinstance(ent, _tt.User):
                continue
            if scope == "groups":
                # Группой считаем: classic tt.Chat + megagroup tt.Channel
                is_classic_group = isinstance(ent, _tt.Chat)
                is_megagroup = isinstance(ent, _tt.Channel) and bool(getattr(ent, "megagroup", False))
                if not (is_classic_group or is_megagroup):
                    continue
            elif scope == "channels":
                # Каналом считаем только broadcast Channel
                if not (isinstance(ent, _tt.Channel) and bool(getattr(ent, "broadcast", False))):
                    continue
            # scope="all" — берём всё (включая ЛС)
            dialogs.append(d)
        stats["dialogs_total"] = raw_count
        stats["dialogs_after_scope"] = len(dialogs)
        logger.info("[kw_search] scope=%s: dialogs total=%d / after filter=%d / keywords=%d",
                    scope, raw_count, len(dialogs), len(keywords))
        if not dialogs:
            logger.warning("[kw_search] Нет чатов под scope=%s (всего диалогов %d). "
                           "Проверьте что аккаунт состоит в чатах этого типа.",
                           scope, raw_count)

        total_chats = len(dialogs)
        for i, dialog in enumerate(dialogs):
            stats["chats_searched"] += 1

            for kw in keywords:
                kw = kw.strip()
                if not kw:
                    continue
                try:
                    async for msg in client.iter_messages(dialog.id, search=kw, limit=limit_per_chat):
                        stats["messages_found"] += 1

                        if collect_links:
                            for url in await _extract_links_from_msg(msg, unwrap=unwrap_urls):
                                results.add(url)

                        if collect_usernames:
                            sender = msg.sender
                            if sender and _is_valid_user(sender) and _has_username(sender):
                                if not _has_spam_restriction(sender):
                                    results.add(f"@{sender.username}")

                except FloodWaitError as e:
                    # FloodWait — не молчим как раньше, сохраняем для UI
                    stats["flood_wait_last"] = int(e.seconds)
                    logger.warning("[kw_search] FloodWait %ds на чате %s", e.seconds, getattr(dialog, 'name', dialog.id))
                    await delay.on_flood(e.seconds)
                    break
                except Exception:
                    continue

                await delay.wait()

            # Прогресс на каждой итерации (не раз в 5 — иначе UI висит без движения)
            if progress_cb:
                try:
                    await progress_cb(len(results), stats["chats_searched"], total_chats)
                except TypeError:
                    # back-compat: старая сигнатура без total
                    await progress_cb(len(results), stats["chats_searched"])

    except Exception as e:
        logger.error("[kw_search] error: %s", e)
        stats["error"] = str(e)

    result = list(results)
    stats["total"] = len(result)
    _report_stats(users=len(result))
    return result, stats


# ── Глобальный поиск публичных чатов ─────────────────────────────────────────

# Транслитерация RU → LAT
_TRANSLIT = {
    'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
    'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
    'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
    'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
    'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
}


def _translit_ru(text: str) -> str:
    """Транслитерация кириллицы в латиницу."""
    result = []
    for ch in text.lower():
        result.append(_TRANSLIT.get(ch, ch))
    return ''.join(result)


def _has_cyrillic(text: str) -> bool:
    return bool(re.search(r'[а-яёА-ЯЁ]', text))


def _expand_pool(raw_queries: List[str], translit: bool = True, expand_tokens: bool = True, max_total: int = 200) -> List[str]:
    """Расширяет пул запросов: транслит + токены. Дедупликация."""
    seen = set()
    out = []

    def _add(q):
        key = q.strip().lower()
        if key and key not in seen and len(out) < max_total:
            seen.add(key)
            out.append(q.strip())

    for raw in raw_queries:
        q = raw.strip()
        if not q:
            continue
        _add(q)
        # Транслитерация
        if translit and _has_cyrillic(q):
            lat = _translit_ru(q)
            if lat != q.lower():
                _add(lat)
        # Токенизация (разбить многословный запрос)
        if expand_tokens:
            tokens = [t for t in q.split() if len(t) >= 3 and not t.isdigit()]
            if len(tokens) > 1:
                for token in tokens[:3]:
                    _add(token)
                    if translit and _has_cyrillic(token):
                        _add(_translit_ru(token))
    return out


async def global_search(
    client,
    query: str,
    limit: int = 100,
    translit: bool = True,
    expand_tokens: bool = True,
    min_participants: int = 0,
    progress_cb: Optional[Callable] = None,
) -> Tuple[List[dict], dict]:
    """
    Глобальный поиск публичных чатов/каналов через contacts.SearchRequest.
    Поддерживает пул запросов (через \\n) и расширение (транслит + токены).
    Возвращает (список {name, username, members, type, link, query}, stats).
    """
    # Проверка коннекта перед запросами. У клиентов на 1.4.145 ловили
    # «Cannot send requests while disconnected» × 18 для всех ключевых слов
    # подряд — клиент был отключён, никто перед поиском не поднял коннект.
    try:
        if hasattr(client, "is_connected") and not client.is_connected():
            await client.connect()
    except Exception as _ce:
        logger.warning("[global_search] не смог подключить клиент: %s", _ce)
        return [], {"queries": 0, "found": 0, "filtered": 0, "per_query": [],
                    "total": 0, "error": "client_disconnected"}

    seen_ids: Set[int] = set()
    results: List[dict] = []
    stats = {"queries": 0, "found": 0, "filtered": 0, "per_query": []}
    delay = ProgressiveDelay(base=1.0, step=0.3, max_delay=5.0)

    # Парсим пул: поддержка многострочного ввода
    raw_queries = [line.strip() for line in query.split("\n") if line.strip()]
    if not raw_queries:
        raw_queries = [query.strip()]

    queries = _expand_pool(raw_queries, translit=translit, expand_tokens=expand_tokens)

    # Специфические якоря — из оригинальных многословных запросов берём уникальные
    # токены (исключая обобщённые «работа/подработка/помощь/реклама» и т.п.).
    # Если клиент искал «Работа Актау», якорь = «Актау» (и его транслит «aktau»).
    # Результат глобального поиска проверяется на наличие любого якоря в
    # имени/username/about — иначе одиночный токен «Работа» тянет пол-телеги.
    _GENERIC_STOPWORDS = {
        "работа", "работы", "работу", "подработка", "подработку", "шабашка",
        "шабашки", "помощь", "реклама", "вакансии", "вакансия", "ищу", "куплю",
        "продам", "сдам", "сниму", "услуги", "услуга", "чат", "канал", "группа",
        "rabota", "raboty", "rabotu", "podrabotka", "shabashka", "pomosch",
        "pomoshch", "reklama", "vakansii", "chat", "channel", "group",
    }
    anchors: Set[str] = set()
    for raw in raw_queries:
        q_lc = raw.lower().strip()
        if not q_lc:
            continue
        tokens = [t for t in re.split(r"\s+", q_lc) if len(t) >= 3 and not t.isdigit()]
        # Берём ВСЕ не-generic токены (включая из однословных запросов).
        # Раньше однословный «Омск» сам по себе считался anchor'ом, НО фильтр
        # включался только при ≥2 словах в запросе — и для одиночного «Омск»
        # фильтр не работал → результаты со всей телеги.
        for tok in tokens:
            if tok in _GENERIC_STOPWORDS:
                continue
            anchors.add(tok)
            if translit and _has_cyrillic(tok):
                anchors.add(_translit_ru(tok).lower())
    # Включаем фильтр если есть хотя бы один НЕ-generic anchor.
    # Только-generic запрос («работа», «подработка») — фильтр выключен,
    # иначе бы на них вообще ничего не нашлось.
    _apply_anchor_filter = len(anchors) > 0
    if _apply_anchor_filter:
        logger.info("[global_search] anchor-фильтр активен: %s", sorted(anchors)[:10])

    # Совпадение anchor c токеном через PREFIX-match: anchor должен быть
    # началом токена. Это решает две проблемы сразу:
    #  • Точное совпадение «барнаул» != «барнауле/барнаула/барнаулу» (русские
    #    падежи) — раньше всё резалось, поиск возвращал 0 чатов.
    #  • Substring-match лажал на «омск» ⊂ «томск/минск» — а prefix-match нет:
    #    «омск» — НЕ начало «томск», «барнаул» — начало «барнауле».
    _ANCHOR_TOK_RE = re.compile(r"[a-zа-яёA-ZА-ЯЁ]+")

    def _haystack_matches_anchor(s: str, anchors_set: Set[str]) -> bool:
        for tok in _ANCHOR_TOK_RE.findall((s or "").lower()):
            for a in anchors_set:
                if tok.startswith(a):
                    return True
        return False

    logger.info("[global_search] стартую: %d запросов после расширения, anchor=%s",
                len(queries), "включён" if _apply_anchor_filter else "выключен")

    _verbose_done = False  # для первого запроса печатаем все чаты

    for q in queries:
        if not q:
            continue
        stats["queries"] += 1
        _q_found = 0
        _q_raw_chats = 0  # сколько вернул Telegram до фильтров
        _q_filtered = 0   # сколько отрезали
        try:
            res = await client(ContactsSearchRequest(q=q, limit=min(limit, 100)))
            _raw = getattr(res, 'chats', []) or []
            _q_raw_chats = len(_raw)
            if not _verbose_done:
                _verbose_done = True
                logger.info("[global_search] DEBUG '%s': Telegram вернул %d чатов:", q, len(_raw))
                for _i, _c in enumerate(_raw[:20]):
                    _t = getattr(_c, 'title', '') or '?'
                    _u = getattr(_c, 'username', '') or '-'
                    _m = getattr(_c, 'participants_count', None) or 0
                    _matched = _haystack_matches_anchor(f"{_t} {_u}", anchors) if _apply_anchor_filter else True
                    logger.info("  [%d] '%s' (@%s, %d) anchor=%s", _i+1, _t[:60], _u, _m,
                                "✓" if _matched else "✗ не прошёл")
            for chat in _raw:
                cid = getattr(chat, 'id', 0)
                if cid in seen_ids:
                    continue
                seen_ids.add(cid)

                is_channel = isinstance(chat, Channel)
                is_mega = is_channel and getattr(chat, 'megagroup', False)
                is_broadcast = is_channel and not getattr(chat, 'megagroup', False)
                is_group = isinstance(chat, Chat)

                if is_broadcast:
                    chat_type = "channel"
                elif is_mega or is_group:
                    chat_type = "group"
                else:
                    continue

                username = getattr(chat, 'username', None) or ''
                title = getattr(chat, 'title', '') or str(cid)
                members = getattr(chat, 'participants_count', None) or 0
                verified = bool(getattr(chat, 'verified', False))

                # Фильтр по мин. участникам
                if min_participants > 0 and members < min_participants:
                    stats["filtered"] += 1
                    _q_filtered += 1
                    continue

                # Anchor-фильтр (prefix-match по словам в title+username).
                if _apply_anchor_filter:
                    if not _haystack_matches_anchor(f"{title} {username}", anchors):
                        stats["filtered"] += 1
                        _q_filtered += 1
                        continue

                results.append({
                    "id": cid,
                    "name": title,
                    "username": username,
                    "members": members,
                    "type": chat_type,
                    "verified": verified,
                    "link": f"https://t.me/{username}" if username else "",
                    "query": q,
                })
                _q_found += 1
                stats["found"] += 1

        except FloodWaitError as e:
            logger.warning("[global_search] FloodWait %ds", e.seconds)
            await delay.on_flood(e.seconds)
        except Exception as e:
            # FROZEN_METHOD_INVALID: Telegram заморозил аккаунт — все
            # последующие запросы бесполезны. Один WARNING + ранний выход,
            # вместо ERROR на каждое из 50 ключевых слов.
            _es = str(e)
            if "FROZEN_METHOD_INVALID" in _es or "FrozenMethodInvalid" in _es:
                logger.warning(
                    "[global_search] аккаунт заморожен Telegram (FROZEN_METHOD_INVALID) — поиск остановлен. Замените аккаунт-парсер."
                )
                stats["frozen"] = True
                break
            logger.error("[global_search] error for '%s': %s", q, e)

        # Каждый запрос — что прилетело, что взяли, что отрезали
        logger.info("[global_search] '%s': Telegram=%d, прошло=%d, отфильтровано=%d",
                    q, _q_raw_chats, _q_found, _q_filtered)
        stats["per_query"].append({"query": q, "found": _q_found,
                                   "raw": _q_raw_chats, "filtered": _q_filtered})
        if progress_cb:
            await progress_cb(len(results), stats["queries"], len(queries))
        await delay.wait()

    stats["total"] = len(results)
    logger.info("[global_search] ИТОГ: %d уникальных чатов из %d запросов",
                len(results), stats["queries"])
    return results, stats


async def global_search_multi(
    clients: list,
    query: str,
    limit: int = 100,
    translit: bool = True,
    expand_tokens: bool = True,
    min_participants: int = 0,
    progress_cb: Optional[Callable] = None,
) -> Tuple[List[dict], dict]:
    """Распределённый поиск по N аккаунтам в раунд-робине.

    Каждый акк по очереди берёт следующий запрос из пула. Если акк ловит
    FloodWait/FROZEN — он временно (или насовсем) пропускается, но
    остальные продолжают. Дедуп общий по chat_id. На N аккаунтах поиск идёт
    в N раз быстрее и нагрузка на каждый акк делится.
    """
    if not clients:
        return [], {"queries": 0, "found": 0, "filtered": 0, "per_query": [],
                    "total": 0, "error": "no_clients"}
    if len(clients) == 1:
        # Один клиент — обычный однопоточный путь
        return await global_search(clients[0], query, limit=limit,
                                    translit=translit, expand_tokens=expand_tokens,
                                    min_participants=min_participants,
                                    progress_cb=progress_cb)

    # Подготовка пула запросов и якорей — копия логики из global_search
    raw_queries = [line.strip() for line in query.split("\n") if line.strip()]
    if not raw_queries:
        raw_queries = [query.strip()]
    queries = _expand_pool(raw_queries, translit=translit, expand_tokens=expand_tokens)

    _GENERIC_STOPWORDS = {
        "работа", "работы", "работу", "подработка", "подработку", "шабашка",
        "шабашки", "помощь", "реклама", "вакансии", "вакансия", "ищу", "куплю",
        "продам", "сдам", "сниму", "услуги", "услуга", "чат", "канал", "группа",
        "rabota", "raboty", "rabotu", "podrabotka", "shabashka", "pomosch",
        "pomoshch", "reklama", "vakansii", "chat", "channel", "group",
    }
    anchors: Set[str] = set()
    for raw in raw_queries:
        for tok in [t for t in re.split(r"\s+", raw.lower().strip())
                    if len(t) >= 3 and not t.isdigit()]:
            if tok in _GENERIC_STOPWORDS:
                continue
            anchors.add(tok)
            if translit and _has_cyrillic(tok):
                anchors.add(_translit_ru(tok).lower())
    _apply_anchor_filter = len(anchors) > 0

    _ANCHOR_TOK_RE = re.compile(r"[a-zа-яёA-ZА-ЯЁ]+")
    def _matches(s: str) -> bool:
        for tok in _ANCHOR_TOK_RE.findall((s or "").lower()):
            for a in anchors:
                if tok.startswith(a):
                    return True
        return False

    # Подключаем все клиенты заранее
    for cli in clients:
        try:
            if hasattr(cli, "is_connected") and not cli.is_connected():
                await cli.connect()
        except Exception as e:
            logger.warning("[global_search_multi] connect fail: %s", e)

    seen_ids: Set[int] = set()
    results: List[dict] = []
    stats = {"queries": 0, "found": 0, "filtered": 0, "per_query": [], "accs_used": len(clients)}
    # Состояние по каждому клиенту: floodwait_until/frozen
    cli_state = [{"client": c, "fw_until": 0.0, "frozen": False} for c in clients]

    logger.info("[global_search_multi] %d аккаунтов × %d запросов, anchor=%s",
                len(clients), len(queries), "вкл" if _apply_anchor_filter else "выкл")

    _verbose_first = True  # для первого запроса печатаем все вернувшиеся чаты

    async def _process(client, q):
        """Один запрос на одном клиенте. Возвращает (новых_чатов, отфильтр, raw)."""
        nonlocal results, _verbose_first
        new_n = filt_n = 0
        try:
            res = await client(ContactsSearchRequest(q=q, limit=min(limit, 100)))
        except FloodWaitError as fw:
            return None, None, fw  # сигнал FloodWait — поднимем выше
        except Exception as ex:
            return None, None, ex

        raw = getattr(res, 'chats', []) or []
        if _verbose_first:
            _verbose_first = False
            logger.info("[global_search_multi] DEBUG '%s': Telegram вернул %d чатов:", q, len(raw))
            for _i, _c in enumerate(raw[:20]):
                _t = getattr(_c, 'title', '') or '?'
                _u = getattr(_c, 'username', '') or '-'
                _m = getattr(_c, 'participants_count', None) or 0
                _matched = _matches(f"{_t} {_u}") if _apply_anchor_filter else True
                logger.info("  [%d] '%s' (@%s, %d) anchor=%s", _i+1, _t[:60], _u, _m,
                            "✓" if _matched else "✗ не прошёл")
        for chat in raw:
            cid = getattr(chat, 'id', 0)
            if cid in seen_ids:
                continue
            seen_ids.add(cid)
            is_channel = isinstance(chat, Channel)
            is_mega = is_channel and getattr(chat, 'megagroup', False)
            is_broadcast = is_channel and not getattr(chat, 'megagroup', False)
            is_group = isinstance(chat, Chat)
            if is_broadcast:
                chat_type = "channel"
            elif is_mega or is_group:
                chat_type = "group"
            else:
                continue
            username = getattr(chat, 'username', None) or ''
            title = getattr(chat, 'title', '') or str(cid)
            members = getattr(chat, 'participants_count', None) or 0
            if min_participants > 0 and members < min_participants:
                filt_n += 1
                continue
            if _apply_anchor_filter and not _matches(f"{title} {username}"):
                filt_n += 1
                continue
            results.append({
                "id": cid, "name": title, "username": username,
                "members": members, "type": chat_type,
                "verified": bool(getattr(chat, 'verified', False)),
                "link": f"https://t.me/{username}" if username else "",
                "query": q,
            })
            new_n += 1
        return new_n, filt_n, len(raw)

    # Раунд-робин: по очереди раздаём запросы клиентам
    q_idx = 0
    while q_idx < len(queries):
        for cs in cli_state:
            if q_idx >= len(queries):
                break
            if cs["frozen"]:
                continue
            if cs["fw_until"] > time.time():
                continue
            q = queries[q_idx]
            if not q:
                q_idx += 1
                continue
            stats["queries"] += 1
            new_n, filt_n, raw_or_err = await _process(cs["client"], q)
            if new_n is None:
                # ошибка
                err = raw_or_err
                if isinstance(err, FloodWaitError):
                    # Капаем фоновое ожидание 30 мин: при 24ч-флуде следующий
                    # запрос всё равно достанется другому акку, текущий восстановится.
                    cs["fw_until"] = time.time() + min(int(err.seconds), 1800)
                    logger.warning("[global_search_multi] acc FloodWait %ds, переключаюсь",
                                   err.seconds)
                    # тот же запрос уйдёт следующему акку
                    continue
                _es = str(err)
                if "FROZEN_METHOD_INVALID" in _es or "FrozenMethodInvalid" in _es:
                    cs["frozen"] = True
                    logger.warning("[global_search_multi] acc заморожен, исключаю")
                    continue
                logger.error("[global_search_multi] '%s': %s", q, err)
                q_idx += 1
                continue
            stats["found"] += new_n
            stats["filtered"] += filt_n
            stats["per_query"].append({"query": q, "found": new_n,
                                        "raw": raw_or_err, "filtered": filt_n})
            logger.info("[global_search_multi] '%s': Telegram=%d, прошло=%d, отфильтр=%d",
                        q, raw_or_err, new_n, filt_n)
            if progress_cb:
                await progress_cb(len(results), stats["queries"], len(queries))
            q_idx += 1
            await asyncio.sleep(0.3)  # лёгкая пауза между запросами с одного акка
        # Проверка: остались ли вообще живые акки?
        if all(cs["frozen"] or cs["fw_until"] > time.time() for cs in cli_state):
            logger.warning("[global_search_multi] все акки в FW/заморозке — стоп")
            break

    stats["total"] = len(results)
    _alive = sum(1 for cs in cli_state if not cs["frozen"] and cs["fw_until"] <= time.time())
    logger.info("[global_search_multi] ИТОГ: %d чатов, %d/%d акков активны",
                len(results), _alive, len(clients))
    return results, stats


# ── TGStat API поиск ─────────────────────────────────────────────────────────

async def tgstat_search(
    token: str,
    query: str,
    country: str = "ru",
    language: str = "russian",
    peer_type: str = "all",
    limit: int = 50,
) -> Tuple[List[dict], dict]:
    """Поиск через TGStat API. Возвращает (список чатов, stats)."""
    stats = {"source": "tgstat", "found": 0}
    results: List[dict] = []

    try:
        from core.tgstat import tgstat_channels_search, TGStatSearchParams, tgstat_item_to_row
        params = TGStatSearchParams(
            token=token, country=country, q=query,
            language=language, peer_type=peer_type, limit=min(limit, 100),
        )
        items = await tgstat_channels_search(params)
        for item in items:
            username = (item.get('username') or '').strip().lstrip('@')
            title = item.get('title') or ''
            members = 0
            try:
                members = int(item.get('participants_count') or 0)
            except Exception:
                pass

            results.append({
                "id": int(item.get('id') or 0),
                "name": title,
                "username": username,
                "members": members,
                "type": "channel" if item.get('peer_type') == 'channel' else "group",
                "verified": False,
                "link": f"https://t.me/{username}" if username else "",
                "query": query,
                "source": "tgstat",
            })
        stats["found"] = len(results)
    except Exception as e:
        logger.error("[tgstat] search error: %s", e)
        stats["error"] = str(e)

    stats["total"] = len(results)
    return results, stats


# ── Экспорт ──────────────────────────────────────────────────────────────────

def format_export(items: List[str], fmt: str = "txt") -> str:
    """Форматирует список для скачивания."""
    if fmt == "csv":
        return "item\n" + "\n".join(items)
    elif fmt == "spintax":
        return "{" + "|".join(items) + "}"
    return "\n".join(items)


# ── Утилиты ──────────────────────────────────────────────────────────────────

def _report_stats(**kwargs):
    """Отправка статистики (если модуль доступен)."""
    try:
        from utils.stats_reporter import increment_parsed
        increment_parsed(**kwargs)
    except Exception:
        pass
