"""
core/invite.py — массовый инвайт юзеров в чат/канал.

Принимает список целей (@username, user_id, t.me/...), целевой чат и
аккаунт(ы)-инвайтера. Резолвит цели через get_input_entity, добавляет
батчами через InviteToChannelRequest или AddChatUserRequest в зависимости
от типа группы.

Telegram блокирует mass-invite довольно жёстко:
  - PeerFloodError — акк помечен как спам, инвайты больше не работают;
    сам собой проходит через 24-72 часа, или вечно после второго бана
  - UserPrivacyRestrictedError — у юзера приватность «не разрешать инвайты»,
    самая частая (60-80% людей в РФ); пробросить нельзя, обходных путей нет
  - UserNotMutualContactError — нужно быть взаимными контактами
  - UsersTooMuchError — лимит участников канала
  - UserChannelsTooMuchError — у юзера 500+ каналов, не может больше
  - UserAlreadyParticipantError — уже в чате, не ошибка
  - FloodWaitError — короткая пауза, ждём и продолжаем

Стратегия: батчами по 1-3 юзера за раз (батч больше — выше шанс получить
PeerFloodError), пауза между батчами 30-90 сек, при PeerFlood — стоп
аккаунта, переход на следующий из пула.
"""
import asyncio
import logging
import random
import time
from dataclasses import dataclass, field
from typing import List, Optional

from telethon import errors
from telethon.tl.functions.channels import InviteToChannelRequest
from telethon.tl.functions.messages import AddChatUserRequest
from telethon.tl import types as tt

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


@dataclass
class InviteJob:
    owner_id: int
    accs: list                          # List[Account] — пул инвайтеров
    chat: str                           # @username / t.me/... / chat_id
    targets: List[str]                  # @username / user_id / t.me/... — кого инвайтим
    status: str = "idle"                # idle / running / paused / stopped / done
    stop_flag: bool = False
    started_at: float = 0.0
    finished_at: float = 0.0
    processed: int = 0
    counts: dict = field(default_factory=lambda: {
        "invited": 0,        # успешно инвайтнули
        "privacy": 0,        # UserPrivacyRestrictedError
        "already": 0,        # UserAlreadyParticipant
        "not_mutual": 0,     # UserNotMutualContact
        "too_many_channels": 0,  # UserChannelsTooMuch
        "peer_flood": 0,     # PeerFlood — акк сжёг себя
        "flood_wait": 0,
        "not_found": 0,      # username не существует
        "failed": 0,         # прочие ошибки
    })
    results: dict = field(default_factory=dict)  # target -> status_code
    log_lines: list = field(default_factory=list)
    target_entity: object = None
    # Тайминги
    batch_size: int = 1
    pause_between_min: int = 30
    pause_between_max: int = 70
    # Лимиты на акк
    max_per_acc: int = 20            # подряд инвайтов на одном акке (потом ротация)
    hourly_limit: int = 40           # за час на акк (защита от PeerFlood)
    flood_cooldown_min: int = 60     # сколько минут не используем сожжённый акк
    stop_on_privacy_streak: int = 0  # стоп после N подряд privacy (0 = не останавливать)
    # Какие акки сожжены (PeerFlood) — не используем до cooldown
    burned_phones: set = field(default_factory=set)
    # phone -> ts когда сожгли
    burned_at: dict = field(default_factory=dict)
    # phone -> [timestamps инвайтов] — для часового лимита
    invites_history: dict = field(default_factory=dict)
    # phone -> сколько подряд инвайтнули с этого акка
    consecutive_count: dict = field(default_factory=dict)
    # Подряд privacy-ошибок
    privacy_streak: int = 0
    # Режим работы: "direct" (просто инвайт) или "via_admin" (промоут в админы → инвайт → демоут)
    mode: str = "direct"
    # Главный админ (telethon Account) — должен быть в чате с правом «Добавлять админов»
    main_admin: object = None  # Account
    # Права которые главный админ выдаёт акку-инвайтеру в режиме via_admin
    admin_rights: dict = field(default_factory=lambda: {
        "change_info": False,
        "delete_messages": False,
        "ban_users": False,
        "manage_call": False,
        "pin_messages": False,
        "anonymous": True,
    })
    # Доп опции
    spamblock_skip_limit: int = 1            # сколько подряд спамблок-ошибок пропускать (потом помечаем акк)
    set_spamblock_in_userdb: bool = True     # ставить метку «спамблок» в базе пользователей
    chargeback_limit: int = 999              # лимит «списаний» (когда юзер удалён сразу после инвайта)
    skip_chat_invalid: bool = True           # пропускать ChatInvalidError
    big_floodwait_limit: int = 9999          # макс больших FloodWait в одном чате (потом стоп этого чата)
    leave_after_invite_worker: bool = True   # обычные акки выходят из чата после инвайта
    leave_after_invite_admin: bool = False   # главный админ выходит после инвайта (обычно нет)
    archive_chat: bool = False               # после инвайта добавить в архив + откл уведомления
    delay_after_join_min: int = 7            # пауза после вступления акка в чат, сек
    delay_after_join_max: int = 15
    # Каскадная промоушн в режиме via_admin — распределяет нагрузку между
    # несколькими «seed»-админами, чтобы главный админ не делал N подряд
    # EditAdminRequest и не словил FloodWait.
    cascade_seed_count: int = 3              # сколько акков главный админ промоутит первыми (с правом ADD_ADMINS)
    cascade_promote_delay_min: int = 5       # пауза между промоушнами, сек
    cascade_promote_delay_max: int = 12
    # Пауза между «вступлением → получением админки» (для via_admin) или просто
    # после вступления перед началом инвайтов (для direct).
    delay_after_promote_min: int = 5
    delay_after_promote_max: int = 12
    # Папки для разложки акков по статусам после инвайта
    folder_spamblock: str = "Аккаунты/После инвайта/Спамблок"
    folder_fresh_session: str = "Аккаунты/После инвайта/Слишком свежая сессия для изменения прав"
    folder_search_limit: str = "Аккаунты/После инвайта/Лимит на поиск по номерам"
    folder_floodwait: str = "Аккаунты/После инвайта/Большой FloodWait"
    folder_frozen: str = "Аккаунты/После инвайта/Заморозка"
    folder_dead: str = "Аккаунты/После инвайта/Мёртвые"


def _log(job: InviteJob, msg: str):
    line = f"[invite] {msg}"
    job.log_lines.append(line)
    if len(job.log_lines) > 500:
        job.log_lines = job.log_lines[-300:]
    logger.info(msg)


def humanize_invite_status(code: str) -> str:
    c = str(code or "")
    if c == "invited": return "Приглашён ✅"
    if c == "already_participant": return "Уже в чате"
    if c == "privacy": return "Приватность не позволяет"
    if c == "not_mutual": return "Нужны взаимные контакты"
    if c == "too_many_channels": return "У юзера лимит 500 каналов"
    if c == "peer_flood": return "Аккаунт-инвайтер забанен (PeerFlood)"
    if c.startswith("flood_wait:"): return f"FloodWait {c.split(':',1)[1]}с"
    if c == "not_found": return "Юзер не найден / username не существует"
    if c == "deleted": return "Аккаунт удалён"
    if c == "blocked_target": return "Цель заблокировала акк-инвайтера"
    if c.startswith("failed:"): return f"Ошибка: {c[7:]}"
    return c


async def _resolve_target(client, raw: str):
    """Резолвит @username / user_id / t.me-ссылку в InputUser."""
    s = (raw or "").strip().lstrip("@")
    if not s:
        return None
    # t.me URL → username
    import re as _re
    m = _re.match(r"(?:https?://)?t\.me/([A-Za-z0-9_]+)", s, _re.I)
    if m:
        s = m.group(1)
    # Числовой id
    try:
        return await client.get_input_entity(int(s))
    except (ValueError, TypeError):
        pass
    # Username
    return await client.get_input_entity(s)


def _move_acc_to_folder(acc, target_path: str) -> bool:
    """Физически перемещает .session/.json/wal/shm/journal в указанную папку.
    target_path — относительный путь от корня клиента вида
    «Аккаунты/После инвайта/Спамблок». Если такой папки нет — создаёт.
    Не реконнектит клиента — это делает acc-helper при следующем ensure_connected.
    """
    if not acc or not target_path:
        return False
    try:
        import shutil
        from pathlib import Path
        from config import ACCOUNTS_DIR
        # target_path начинается с «Аккаунты/...» — делим на сегменты
        parts = [p for p in target_path.replace("\\", "/").split("/") if p]
        # Если первый сегмент совпадает с именем ACCOUNTS_DIR — пропускаем его
        if parts and parts[0].lower() == ACCOUNTS_DIR.name.lower():
            parts = parts[1:]
        target_dir = ACCOUNTS_DIR.joinpath(*parts) if parts else ACCOUNTS_DIR
        target_dir.mkdir(parents=True, exist_ok=True)

        phone = acc.phone
        # Сначала отключаем коннект чтобы Windows отпустил .session-lock
        try:
            cli = getattr(acc, "client", None)
            if cli and cli.is_connected():
                # Это sync-функция, поэтому вызываем disconnect синхронно через
                # call_soon_threadsafe из event_loop'а. Безопасный fallback:
                # просто отметить acc.client = None и положиться на retry-цикл
                # shutil.move (Telethon освободит файл за ~секунду).
                pass
        except Exception:
            pass

        moved = 0
        for sub in ACCOUNTS_DIR.iterdir():
            if not sub.is_dir() or sub == target_dir:
                continue
            for ext in (".session", ".json", ".session-wal", ".session-shm", ".session-journal"):
                src = sub / f"{phone}{ext}"
                if not src.exists():
                    continue
                dst = target_dir / src.name
                last_err = None
                for attempt in range(4):
                    try:
                        if dst.exists():
                            try: dst.unlink()
                            except Exception: pass
                        shutil.move(str(src), str(dst))
                        last_err = None
                        moved += 1
                        if ext == ".session":
                            try: acc.session_path = dst
                            except Exception: pass
                        elif ext == ".json":
                            try: acc.json_path = dst
                            except Exception: pass
                        break
                    except Exception as e:
                        last_err = e
                        time.sleep(0.5 * (attempt + 1))
                if last_err:
                    logger.warning("[invite-move] %s: %s -> %s упал: %s", phone, src, dst, last_err)
        if moved:
            try: acc.folder_name = "/".join(parts) if parts else "Аккаунты"
            except Exception: pass
            logger.info("[invite-move] %s: %d файл(ов) -> %s", phone, moved, target_dir)
        return moved > 0
    except Exception as e:
        logger.warning("[invite-move] failed: %s", e)
        return False


async def _retire_acc_during_invite(job, acc, chat_entity_per_acc: dict, target_folder: str,
                                     reason: str, mark_as: str = "dead") -> None:
    """Уводит акк со сцены: выходим из чата (если ещё там) → помечаем
    dead/frozen → переносим .session в нужную папку. Используется при
    FloodWait/Frozen/Dead/Big-FW во время задачи.
    mark_as: "dead" / "frozen" / "spamblock" — что выставить."""
    phone = getattr(acc, "phone", "?")
    # 1. Leave (если был в чате) — даже если опция «leave_after_invite» выкл.
    #    тут всё равно чистим следы у проблемного акка.
    try:
        ent = chat_entity_per_acc.get(phone) if isinstance(chat_entity_per_acc, dict) else None
        if ent is not None and getattr(acc, "client", None):
            from telethon.tl.functions.channels import LeaveChannelRequest
            try:
                await acc.client(LeaveChannelRequest(ent))
                _log(job, f"  🚪 {phone}: вышел из чата ({reason})")
            except Exception as _le:
                _log(job, f"  ⚠️ {phone}: leave не удался — {type(_le).__name__}")
    except Exception:
        pass
    # 2. Метка + персист в account_states.json (переживёт рестарт).
    try:
        if mark_as == "dead":
            acc.dead = True
            acc.dead_reason = reason
        elif mark_as == "frozen":
            acc.frozen = True
            acc.frozen_reason = reason
        elif mark_as == "spamblock":
            acc.spam_blocked = True
            acc.spam_block_note = reason
        try:
            from core.account_state import save_account_state
            save_account_state(
                phone,
                dead=(mark_as == "dead") or None,
                dead_reason=reason if mark_as == "dead" else None,
                frozen=(mark_as == "frozen") or None,
                frozen_reason=reason if mark_as == "frozen" else None,
                spam_blocked=(mark_as == "spamblock") or None,
                spam_block_note=reason if mark_as == "spamblock" else None,
            )
        except Exception:
            pass
    except Exception:
        pass
    # 3. Перенос файлов в указанную папку (sync, в thread).
    try:
        await asyncio.to_thread(_move_acc_to_folder, acc, target_folder)
        _log(job, f"  📁 {phone} → «{target_folder}» ({reason})")
    except Exception as _me:
        _log(job, f"  ⚠️ {phone}: move в «{target_folder}» упал — {type(_me).__name__}")


async def _promote_to_admin(admin_client, channel_entity, target_user_entity, rights_cfg: dict,
                             with_add_admins: bool = False) -> str:
    """Через главного админа повышает указанного юзера в админы канала/группы.
    rights_cfg — словарь boolean-полей.
    with_add_admins=True — даём право промоутить других (для каскадной схемы:
    главный админ → seed-админы → остальные инвайтеры).
    Возвращает 'ok' / код ошибки."""
    try:
        from telethon.tl.functions.channels import EditAdminRequest
        from telethon.tl.types import ChatAdminRights
        rights = ChatAdminRights(
            change_info=bool(rights_cfg.get("change_info", False)),
            post_messages=False,
            edit_messages=False,
            delete_messages=bool(rights_cfg.get("delete_messages", False)),
            ban_users=bool(rights_cfg.get("ban_users", False)),
            invite_users=True,  # это и нужно для инвайта
            pin_messages=bool(rights_cfg.get("pin_messages", False)),
            add_admins=bool(with_add_admins),
            anonymous=bool(rights_cfg.get("anonymous", True)),
            manage_call=bool(rights_cfg.get("manage_call", False)),
            other=False,
        )
        await admin_client(EditAdminRequest(channel_entity, target_user_entity, rights, rank="invite"))
        return "ok"
    except errors.UserCreatorError:
        return "creator_only"
    except errors.AdminsTooMuchError:
        return "admins_too_much"
    except errors.FreshChangeAdminsForbiddenError:
        return "fresh_session"
    except errors.ChatAdminRequiredError:
        return "no_rights"
    except errors.RPCError as e:
        return f"rpc:{type(e).__name__}"
    except Exception as e:
        return f"err:{type(e).__name__}"


async def _demote_admin(admin_client, channel_entity, target_user_entity) -> bool:
    """Снимает все права админа с указанного юзера (no-op если уже не админ)."""
    try:
        from telethon.tl.functions.channels import EditAdminRequest
        from telethon.tl.types import ChatAdminRights
        zero = ChatAdminRights(
            change_info=False, post_messages=False, edit_messages=False,
            delete_messages=False, ban_users=False, invite_users=False,
            pin_messages=False, add_admins=False, anonymous=False,
            manage_call=False, other=False,
        )
        await admin_client(EditAdminRequest(channel_entity, target_user_entity, zero, rank=""))
        return True
    except Exception:
        return False


async def _do_invite(client, target_chat_entity, target_user_entity, skip_chat_invalid: bool = True,
                      ad_config: dict | None = None) -> str:
    """Один инвайт. Возвращает status code.
    skip_chat_invalid=True — ChatInvalidError возвращается как 'chat_invalid_skipped'
    чтобы цикл не падал, а продолжил со следующей целью.
    ad_config — опции анти-детекта (view_profiles / view_photos)."""
    # Anti-detect: имитация просмотра профиля цели перед инвайтом
    if ad_config:
        try:
            if ad_config.get('view_profiles'):
                from utils.anti_detect import view_profile
                await view_profile(client, target_user_entity, module="invite")
            if ad_config.get('view_photos'):
                from utils.anti_detect import view_profile_photo
                await view_profile_photo(client, target_user_entity, module="invite")
        except Exception:
            pass
    try:
        # Channel/Supergroup
        if isinstance(target_chat_entity, (tt.InputPeerChannel, tt.Channel)):
            await client(InviteToChannelRequest(target_chat_entity, [target_user_entity]))
            return "invited"
        # Basic group
        # Для basic group AddChatUserRequest требует chat_id (int) и InputUser
        if isinstance(target_chat_entity, (tt.InputPeerChat, tt.Chat)):
            chat_id = getattr(target_chat_entity, "chat_id", None) or getattr(target_chat_entity, "id", None)
            await client(AddChatUserRequest(chat_id, target_user_entity, fwd_limit=10))
            return "invited"
        # Channel-like fallback — пробуем как канал
        await client(InviteToChannelRequest(target_chat_entity, [target_user_entity]))
        return "invited"
    except errors.UserAlreadyParticipantError:
        return "already_participant"
    except errors.UserPrivacyRestrictedError:
        return "privacy"
    except errors.UserNotMutualContactError:
        return "not_mutual"
    except errors.UserChannelsTooMuchError:
        return "too_many_channels"
    except errors.PeerFloodError:
        return "peer_flood"
    except errors.FloodWaitError as e:
        return f"flood_wait:{e.seconds}"
    except errors.UserBlockedError:
        return "blocked_target"
    except errors.UserDeactivatedError:
        return "deleted"
    except (errors.UsernameNotOccupiedError, errors.UsernameInvalidError):
        return "not_found"
    except errors.UserIdInvalidError:
        return "not_found"
    except errors.ChatIdInvalidError:
        return "chat_invalid_skipped" if skip_chat_invalid else "failed:ChatIdInvalidError"
    except errors.RPCError as e:
        if "ChatInvalid" in type(e).__name__ and skip_chat_invalid:
            return "chat_invalid_skipped"
        return f"failed:{type(e).__name__}"
    except Exception as e:
        if "ChatInvalid" in type(e).__name__ and skip_chat_invalid:
            return "chat_invalid_skipped"
        return f"failed:{type(e).__name__}"


async def run_invite_job(job: InviteJob):
    """Главный цикл: round-robin по аккаунтам, инвайтим targets, обрабатываем
    ошибки с переключением на следующий акк при PeerFlood."""
    job.status = "running"
    job.started_at = time.time()

    if not job.accs:
        _log(job, "❌ Пул аккаунтов пуст")
        job.status = "stopped"
        return
    if not job.targets:
        _log(job, "❌ Список целей пуст")
        job.status = "stopped"
        return

    # Резолвим target_chat через первый живой акк
    chat_entity_per_acc = {}
    for acc in job.accs:
        try:
            from core.client_builder import ensure_connected
            if not await ensure_connected(acc):
                _log(job, f"⚠️ {acc.phone}: не смог подключиться")
                continue
            chat_entity_per_acc[acc.phone] = await _resolve_target(acc.client, job.chat)
        except Exception as e:
            _log(job, f"⚠️ {acc.phone}: чат не резолвится — {type(e).__name__}: {e}")
            chat_entity_per_acc[acc.phone] = None

    valid_phones = [p for p, e in chat_entity_per_acc.items() if e is not None]
    if not valid_phones:
        _log(job, "❌ Никто из акков не смог открыть целевой чат — он приватный? Закинь акк в группу/канал руками")
        job.status = "stopped"
        return

    # === Шаг 1: Явное вступление в чат ===
    # Раньше акки полагались на то что Telegram «как-то сам» добавит при invite.
    # Теперь явно делаем JoinChannelRequest или ImportChatInviteRequest, с
    # рандомной паузой на каждый акк (delay_after_join_min/max).
    from telethon.tl.functions.channels import JoinChannelRequest
    from telethon.tl.functions.messages import ImportChatInviteRequest
    import re as _re_join
    invite_hash = None
    _m = _re_join.search(r"(?:t\.me/(?:joinchat/|\+))([A-Za-z0-9_-]+)", job.chat or "")
    if _m:
        invite_hash = _m.group(1)
    _log(job, f"🚪 Вступление в чат — {len(valid_phones)} акков")
    joined_phones = set()
    for ph in valid_phones:
        if job.stop_flag:
            break
        cand = next((a for a in job.accs if a.phone == ph), None)
        if not cand or not cand.client:
            continue
        try:
            if invite_hash:
                # Приватный чат по invite-ссылке
                try:
                    await cand.client(ImportChatInviteRequest(invite_hash))
                except errors.UserAlreadyParticipantError:
                    pass  # уже там — норм
            else:
                try:
                    await cand.client(JoinChannelRequest(chat_entity_per_acc[ph]))
                except errors.UserAlreadyParticipantError:
                    pass
            joined_phones.add(ph)
            _log(job, f"  ✅ {ph}: в чате")
            # Пауза на каждом акке — растягиваем массовое вступление
            delay = random.uniform(job.delay_after_join_min, max(job.delay_after_join_min, job.delay_after_join_max))
            await asyncio.sleep(delay)
        except errors.FloodWaitError as fw:
            _log(job, f"  ⏳ {ph}: FloodWait {fw.seconds}с при вступлении — пропускаю")
        except Exception as e:
            _log(job, f"  ⚠️ {ph}: вступить не смог — {type(e).__name__}: {e}")
    if not joined_phones:
        _log(job, "🛑 Никто не смог вступить в чат — стоп")
        job.status = "stopped"
        return
    valid_phones = [p for p in valid_phones if p in joined_phones]

    # === Шаг 2: Режим «Через админку» — каскадная промоушн ===
    # Главный админ промоутит первых K акков с правом ADD_ADMINS (seed-админы).
    # Затем seed-админы round-robin промоутят остальных. Распределяет нагрузку
    # по EditAdminRequest на несколько акков → меньше FloodWait.
    admin_chat_entity = None
    if job.mode == "via_admin":
        if not job.main_admin:
            _log(job, "❌ Режим «Через админку» включён, но главный админ не задан")
            job.status = "stopped"
            return
        admin_acc = job.main_admin
        try:
            from core.client_builder import ensure_connected
            if not await ensure_connected(admin_acc):
                _log(job, f"❌ Главный админ {admin_acc.phone}: не подключился")
                job.status = "stopped"
                return
            admin_chat_entity = await _resolve_target(admin_acc.client, job.chat)
            _log(job, f"👑 Главный админ {admin_acc.phone} готов выдавать права")
        except Exception as e:
            _log(job, f"❌ Главный админ не может открыть чат: {e}")
            job.status = "stopped"
            return

        # Helper: одна попытка промоушна с обработкой ошибок и логом
        async def _try_promote(promoter_acc, promoter_chat_entity, target_acc, with_add_admins=False):
            """Возвращает True если промоут удался."""
            try:
                me = await target_acc.client.get_me()
                me_input = await promoter_acc.client.get_input_entity(me.id)
                res = await _promote_to_admin(
                    promoter_acc.client, promoter_chat_entity, me_input,
                    job.admin_rights, with_add_admins=with_add_admins,
                )
                if res == "ok":
                    role = "seed-админ" if with_add_admins else "админ"
                    _log(job, f"  ✅ {target_acc.phone} → {role} (промоутер: {promoter_acc.phone})")
                    return True
                if res == "fresh_session":
                    _log(job, f"  ⚠️ {target_acc.phone}: слишком свежая сессия → переношу в «{job.folder_fresh_session}»")
                    await asyncio.to_thread(_move_acc_to_folder, target_acc, job.folder_fresh_session)
                elif res == "creator_only":
                    _log(job, f"  ❌ {target_acc.phone}: только создатель чата может выдать эти права")
                elif res == "admins_too_much":
                    _log(job, f"  ❌ {target_acc.phone}: лимит админов в чате достигнут")
                    return "admins_too_much"
                else:
                    _log(job, f"  ❌ {target_acc.phone}: {res} (промоутер: {promoter_acc.phone})")
                return False
            except errors.FloodWaitError as fw:
                _log(job, f"  ⏳ {promoter_acc.phone}: FloodWait {fw.seconds}с при промоушне")
                await asyncio.sleep(min(fw.seconds + 5, 600))
                return False
            except Exception as e:
                _log(job, f"  ❌ {target_acc.phone}: {type(e).__name__}: {e}")
                return False

        promoted_phones = set()
        seed_phones = []  # акки которые могут промоутить других
        seed_chat_entity_per_acc = {}  # их view на чат
        cascade_seed = max(1, int(job.cascade_seed_count))
        # --- Шаг 2A: главный админ промоутит первых K с ADD_ADMINS ---
        seed_target_phones = valid_phones[:cascade_seed]
        _log(job, f"⚡ Главный админ промоутит первых {len(seed_target_phones)} акков (seed-админы с ADD_ADMINS)")
        for ph in seed_target_phones:
            if job.stop_flag:
                break
            cand = next((a for a in job.accs if a.phone == ph), None)
            if not cand:
                continue
            res = await _try_promote(admin_acc, admin_chat_entity, cand, with_add_admins=True)
            if res == "admins_too_much":
                break
            if res is True:
                promoted_phones.add(ph)
                seed_phones.append(ph)
                seed_chat_entity_per_acc[ph] = chat_entity_per_acc.get(ph)
            # Пауза между промоушнами
            await asyncio.sleep(random.uniform(job.cascade_promote_delay_min, job.cascade_promote_delay_max))

        # --- Шаг 2B: seed-админы round-robin промоутят остальных ---
        rest_target_phones = valid_phones[cascade_seed:]
        if rest_target_phones and seed_phones:
            _log(job, f"⚡ Каскад: {len(seed_phones)} seed-админ(ов) промоутят остальные {len(rest_target_phones)} акк(ов)")
            seed_idx = 0
            for ph in rest_target_phones:
                if job.stop_flag:
                    break
                cand = next((a for a in job.accs if a.phone == ph), None)
                if not cand:
                    continue
                # Round-robin промоутер
                promoter_phone = seed_phones[seed_idx % len(seed_phones)]
                seed_idx += 1
                promoter = next((a for a in job.accs if a.phone == promoter_phone), None)
                p_chat = seed_chat_entity_per_acc.get(promoter_phone)
                if not promoter or p_chat is None:
                    continue
                res = await _try_promote(promoter, p_chat, cand, with_add_admins=False)
                if res == "admins_too_much":
                    break
                if res is True:
                    promoted_phones.add(ph)
                await asyncio.sleep(random.uniform(job.cascade_promote_delay_min, job.cascade_promote_delay_max))
        elif rest_target_phones and not seed_phones:
            # Не получилось seed-админов — fallback: главный админ доделает остальных
            _log(job, "⚠️ Seed-админов нет, главный админ промоутит остальных сам (без распределения)")
            for ph in rest_target_phones:
                if job.stop_flag:
                    break
                cand = next((a for a in job.accs if a.phone == ph), None)
                if not cand:
                    continue
                res = await _try_promote(admin_acc, admin_chat_entity, cand, with_add_admins=False)
                if res == "admins_too_much":
                    break
                if res is True:
                    promoted_phones.add(ph)
                await asyncio.sleep(random.uniform(job.cascade_promote_delay_min, job.cascade_promote_delay_max))

        if not promoted_phones:
            _log(job, "🛑 Никого не удалось сделать админом — стоп")
            job.status = "stopped"
            return
        valid_phones = [p for p in valid_phones if p in promoted_phones]
        _log(job, f"🎯 В режиме «через админку»: {len(valid_phones)} акков-инвайтеров готовы")

    # === Шаг 3: Пауза перед началом инвайтов (даём акку «осесть» в роли админа) ===
    if job.delay_after_promote_max > 0:
        delay_settle = random.uniform(job.delay_after_promote_min, job.delay_after_promote_max)
        _log(job, f"⏳ Пауза {int(delay_settle)}с перед началом инвайтов")
        await asyncio.sleep(delay_settle)

    _log(job, f"🚀 Старт инвайта (режим={job.mode}): {len(job.targets)} целей, {len(valid_phones)} акков-инвайтера")

    acc_idx = 0
    last_used_phone = None
    ti = 0  # курсор по targets, может НЕ инкрементироваться при retry-нужных статусах
    retry_count_per_target = {}  # защита от вечного цикла на одной цели
    MAX_RETRIES_PER_TARGET = 3
    while ti < len(job.targets):
        target = job.targets[ti]
        if job.stop_flag:
            _log(job, "🛑 Остановлено клиентом")
            break

        # Стоп после N подряд privacy ошибок (если включено)
        if job.stop_on_privacy_streak and job.privacy_streak >= job.stop_on_privacy_streak:
            _log(job, f"🛑 {job.privacy_streak} подряд privacy — остановка (вкл. в настройках)")
            break

        now = time.time()

        # Снимаем burned-флаг с акков у которых прошёл cooldown
        for ph in list(job.burned_phones):
            if (now - job.burned_at.get(ph, 0)) >= job.flood_cooldown_min * 60:
                job.burned_phones.discard(ph)
                _log(job, f"♻️ {ph}: cooldown истёк, возвращаю в пул")

        # Чистим историю инвайтов старше часа (для hourly_limit)
        for ph, ts_list in list(job.invites_history.items()):
            job.invites_history[ph] = [t for t in ts_list if now - t < 3600]

        # Берём следующий доступный акк
        acc = None
        attempts = 0
        max_attempts = len(valid_phones) * 2
        while attempts < max_attempts:
            candidate_phone = valid_phones[acc_idx % len(valid_phones)]
            acc_idx += 1
            attempts += 1
            # Сожжён?
            if candidate_phone in job.burned_phones:
                continue
            # Часовой лимит достигнут?
            if len(job.invites_history.get(candidate_phone, [])) >= job.hourly_limit:
                continue
            # Достигли подряд-лимита на этом акке — переключаемся
            if (last_used_phone == candidate_phone and
                job.consecutive_count.get(candidate_phone, 0) >= job.max_per_acc):
                # сбросим счётчик когда уйдём, и попробуем следующий
                continue
            cand = next((a for a in job.accs if a.phone == candidate_phone), None)
            if not cand or cand.dead or getattr(cand, "frozen", False):
                continue
            acc = cand
            break

        if not acc:
            _log(job, "⏳ Все акки заняты лимитами/cooldown — жду 60с и продолжу")
            await asyncio.sleep(60)
            # Перепроверяем после паузы
            for ph in list(job.burned_phones):
                if (time.time() - job.burned_at.get(ph, 0)) >= job.flood_cooldown_min * 60:
                    job.burned_phones.discard(ph)
            for ph in list(job.consecutive_count):
                # Сбрасываем consecutive после паузы — даём шанс акку снова
                job.consecutive_count[ph] = 0
            # Берём первый живой акк после reset
            for cp in valid_phones:
                if cp in job.burned_phones: continue
                cand = next((a for a in job.accs if a.phone == cp), None)
                if cand and not cand.dead and not getattr(cand, "frozen", False):
                    acc = cand; break
            if not acc:
                _log(job, "🛑 Нет доступных акков после паузы — стоп")
                break

        chat_ent = chat_entity_per_acc.get(acc.phone)
        # Резолвим юзера через тот же акк
        try:
            user_ent = await _resolve_target(acc.client, target)
        except (errors.UsernameNotOccupiedError, errors.UsernameInvalidError):
            job.results[target] = "not_found"
            job.counts["not_found"] += 1
            job.processed += 1
            _log(job, f"{ti+1}/{len(job.targets)} {target} → не найден")
            ti += 1
            continue
        except Exception as e:
            job.results[target] = f"failed:resolve_{type(e).__name__}"
            job.counts["failed"] += 1
            job.processed += 1
            _log(job, f"{ti+1}/{len(job.targets)} {target} → resolve failed: {type(e).__name__}")
            ti += 1
            continue

        if user_ent is None:
            job.results[target] = "not_found"
            job.counts["not_found"] += 1
            job.processed += 1
            ti += 1
            continue

        # Делаем инвайт
        status = await _do_invite(acc.client, chat_ent, user_ent, skip_chat_invalid=job.skip_chat_invalid,
                                  ad_config=getattr(job, 'ad_config', None))
        job.results[target] = status
        job.processed += 1

        # Учёт истории на акке (для hourly_limit + consecutive)
        job.invites_history.setdefault(acc.phone, []).append(time.time())
        if last_used_phone == acc.phone:
            job.consecutive_count[acc.phone] = job.consecutive_count.get(acc.phone, 0) + 1
        else:
            job.consecutive_count[acc.phone] = 1
        last_used_phone = acc.phone

        if status == "invited":
            job.counts["invited"] += 1
            job.privacy_streak = 0
            try:
                from utils.stats_reporter import increment_invites
                increment_invites(1)
            except Exception:
                pass
            _log(job, f"{ti+1}/{len(job.targets)} ✅ {target} ← {acc.phone} (на этом акке подряд: {job.consecutive_count[acc.phone]}/{job.max_per_acc})")
        elif status == "already_participant":
            job.counts["already"] += 1
            job.privacy_streak = 0
            _log(job, f"{ti+1}/{len(job.targets)} ↩ {target} уже в чате")
        elif status == "privacy":
            job.counts["privacy"] += 1
            job.privacy_streak += 1
            _log(job, f"{ti+1}/{len(job.targets)} 🔒 {target} приватность (подряд: {job.privacy_streak})")
        elif status == "not_mutual":
            job.counts["not_mutual"] += 1
            job.privacy_streak = 0
        elif status == "too_many_channels":
            job.counts["too_many_channels"] += 1
            job.privacy_streak = 0
        elif status == "peer_flood":
            job.counts["peer_flood"] += 1
            job.burned_phones.add(acc.phone)
            job.burned_at[acc.phone] = time.time()
            # Считаем подряд-спамблоки на этом акке. После N (spamblock_skip_limit)
            # — переносим в папку «После инвайта/Спамблок» и больше не трогаем.
            sb_key = f"_sb_{acc.phone}"
            sb_count = job.consecutive_count.get(sb_key, 0) + 1
            job.consecutive_count[sb_key] = sb_count
            if sb_count > job.spamblock_skip_limit:
                _log(job, f"🚫 {acc.phone}: {sb_count} спамблоков подряд — переношу в «{job.folder_spamblock}»")
                await asyncio.to_thread(_move_acc_to_folder, acc, job.folder_spamblock)
                # И помечаем acc как dead для остальной логики этой задачи
                try: acc.dead = True; acc.dead_reason = "spamblock"
                except Exception: pass
            _log(job, f"🔥 PeerFlood на {acc.phone} — акк сжёг лимиты, cooldown {job.flood_cooldown_min} мин")
            # Сразу выходим из чата на этом акке (если включена опция leave).
            # Раньше акк сидел в чате до конца задачи (часами), теперь как только
            # «сожгли» — leave. Снижает риск что админ увидит «лишних» участников.
            if job.leave_after_invite_worker:
                try:
                    ent = chat_entity_per_acc.get(acc.phone)
                    if ent is not None and acc.client:
                        from telethon.tl.functions.channels import LeaveChannelRequest
                        await acc.client(LeaveChannelRequest(ent))
                        _log(job, f"🚪 {acc.phone}: вышел из чата (после peer_flood)")
                except Exception as _le:
                    _log(job, f"  ⚠️ {acc.phone}: leave не удался — {type(_le).__name__}")
            # Откатываем счётчик и result-запись, retry того же target другим акком
            job.processed = max(0, job.processed - 1)
            job.results.pop(target, None)
            retry_count_per_target[target] = retry_count_per_target.get(target, 0) + 1
            if retry_count_per_target[target] >= MAX_RETRIES_PER_TARGET:
                _log(job, f"⚠️ {target}: {retry_count_per_target[target]} ретраев — пропускаю")
                job.counts["failed"] += 1
                job.results[target] = "failed:max_retries"
                ti += 1
            # иначе НЕ инкрементируем ti — на следующей итерации тот же target,
            # уже без сожжённого акка (он в burned_phones)
            continue
        elif status.startswith("flood_wait:"):
            job.counts["flood_wait"] += 1
            try:
                fw = int(status.split(":", 1)[1])
            except Exception:
                fw = 30
            # Большой FloodWait (по умолчанию 1 час+) — Telegram реально
            # «потушил» акк надолго. Не ждём — выводим из задачи и переносим
            # в папку «Большой FloodWait», задача продолжается без него.
            big_fw_threshold = max(3600, int(getattr(job, "big_floodwait_limit", 9999)))
            if fw >= big_fw_threshold and big_fw_threshold > 0:
                _log(job, f"💤 {acc.phone}: FloodWait {fw}с (>{big_fw_threshold}с) — увожу в «{job.folder_floodwait}»")
                job.burned_phones.add(acc.phone)
                job.burned_at[acc.phone] = time.time()
                await _retire_acc_during_invite(job, acc, chat_entity_per_acc, job.folder_floodwait,
                                                 reason=f"FloodWait_{fw}s", mark_as="dead")
                # Откатываем счётчик и пробуем тот же target другим акком
                job.processed = max(0, job.processed - 1)
                job.results.pop(target, None)
                continue
            _log(job, f"⏳ FloodWait {fw}с — пауза")
            await asyncio.sleep(min(fw + 5, 600))
            # Ретраить тот же target — НЕ инкрементируем ti
            job.processed = max(0, job.processed - 1)
            job.results.pop(target, None)
            retry_count_per_target[target] = retry_count_per_target.get(target, 0) + 1
            if retry_count_per_target[target] >= MAX_RETRIES_PER_TARGET:
                _log(job, f"⚠️ {target}: {retry_count_per_target[target]} ретраев — пропускаю")
                job.counts["failed"] += 1
                job.results[target] = "failed:max_retries"
                ti += 1
            continue
        elif status == "not_found":
            job.counts["not_found"] += 1
        elif status == "chat_invalid_skipped":
            # Пропущено — не считаем ни успехом ни ошибкой
            _log(job, f"{ti+1}/{len(job.targets)} ⊘ {target} ChatInvalidError — пропуск")
        else:
            # Распознавание плохих состояний акк-инвайтера и ретайр.
            # Frozen / dead / session-revoked — акк больше не может инвайтить,
            # его надо вывести из задачи и переместить в правильную папку.
            ename = status.split(":", 1)[1] if status.startswith("failed:") else ""
            if "Frozen" in ename or "FROZEN" in ename:
                _log(job, f"❄ {acc.phone}: Telegram заморозил акк во время инвайта — увожу")
                job.burned_phones.add(acc.phone)
                await _retire_acc_during_invite(job, acc, chat_entity_per_acc, job.folder_frozen,
                                                 reason=ename, mark_as="frozen")
                # target не виноват — пробуем другим акком
                job.processed = max(0, job.processed - 1)
                job.results.pop(target, None)
                continue
            if any(x in ename for x in ("AuthKeyUnregistered", "SessionRevoked",
                                          "UserDeactivated", "UserDeactivatedBan")):
                _log(job, f"💀 {acc.phone}: акк мёртв ({ename}) — увожу")
                job.burned_phones.add(acc.phone)
                await _retire_acc_during_invite(job, acc, chat_entity_per_acc, job.folder_dead,
                                                 reason=ename, mark_as="dead")
                job.processed = max(0, job.processed - 1)
                job.results.pop(target, None)
                continue
            if "ChatAdminRequired" in ename or "UserNotInChat" in ename:
                # Акк потерял права (демоутнули или вывалился из чата).
                # Не помечаем dead/frozen — просто пробуем другим акком.
                _log(job, f"⚠️ {acc.phone}: потерял права/выпал из чата ({ename}) — пробую другим акком")
                job.burned_phones.add(acc.phone)
                job.processed = max(0, job.processed - 1)
                job.results.pop(target, None)
                continue
            job.counts["failed"] += 1
            _log(job, f"{ti+1}/{len(job.targets)} ❌ {target} → {status}")

        # Двигаем курсор только при успехе/окончательной ошибке
        ti += 1

        # Пауза между инвайтами (рандом)
        if not job.stop_flag and ti < len(job.targets):
            pause = random.uniform(job.pause_between_min, job.pause_between_max)
            await asyncio.sleep(pause)

    # === Режим via_admin: снимаем права у инвайтеров после работы ===
    if job.mode == "via_admin" and job.main_admin and admin_chat_entity is not None:
        _log(job, "👑 Снимаю админ-права у акков-инвайтеров…")
        for ph in valid_phones:
            cand = next((a for a in job.accs if a.phone == ph), None)
            if not cand:
                continue
            try:
                me = await cand.client.get_me()
                me_input = await job.main_admin.client.get_input_entity(me.id)
                await _demote_admin(job.main_admin.client, admin_chat_entity, me_input)
            except Exception as e:
                _log(job, f"  ⚠️ {ph}: demote {type(e).__name__}")

    # === Выход из чата после инвайта (если включено) ===
    if job.leave_after_invite_worker:
        _log(job, "🚪 Акки-инвайтеры выходят из чата…")
        for ph in valid_phones:
            cand = next((a for a in job.accs if a.phone == ph), None)
            if not cand or not cand.client:
                continue
            try:
                ent = chat_entity_per_acc.get(ph)
                if ent is None: continue
                from telethon.tl.functions.channels import LeaveChannelRequest
                await cand.client(LeaveChannelRequest(ent))
            except Exception:
                pass
    if job.leave_after_invite_admin and job.mode == "via_admin" and job.main_admin and admin_chat_entity is not None:
        try:
            from telethon.tl.functions.channels import LeaveChannelRequest
            await job.main_admin.client(LeaveChannelRequest(admin_chat_entity))
        except Exception:
            pass

    # Архивация чата + отключение уведомлений у акков-инвайтеров.
    # Полезно если акки остались в чате (leave_after_invite=False).
    if job.archive_chat:
        _log(job, "📁 Архивирую чат и отключаю уведомления у акков-инвайтеров…")
        for ph in valid_phones:
            cand = next((a for a in job.accs if a.phone == ph), None)
            if not cand or not cand.client: continue
            ent = chat_entity_per_acc.get(ph)
            if ent is None: continue
            try:
                from telethon.tl.functions.folders import EditPeerFoldersRequest
                from telethon.tl.functions.account import UpdateNotifySettingsRequest
                from telethon.tl.types import InputPeerNotifySettings, InputFolderPeer
                # Mute notifications
                try:
                    await cand.client(UpdateNotifySettingsRequest(
                        peer=ent,
                        settings=InputPeerNotifySettings(mute_until=2 ** 31 - 1, silent=True)
                    ))
                except Exception: pass
                # Move to archive folder (id=1)
                try:
                    input_peer = await cand.client.get_input_entity(ent)
                    await cand.client(EditPeerFoldersRequest([
                        InputFolderPeer(peer=input_peer, folder_id=1)
                    ]))
                except Exception: pass
            except Exception as _ae:
                logger.debug("[invite] archive %s failed: %s", ph, _ae)

    job.status = "stopped" if job.stop_flag else "done"
    job.finished_at = time.time()
    _log(job, f"✅ Готово: ✅{job.counts['invited']} ↩{job.counts['already']} 🔒{job.counts['privacy']} ❌{job.counts['failed']} 🔥{job.counts['peer_flood']}")
