"""
warmup.py -- Account warmup engine.
Accounts chat with each other in natural conversations.
Progression: every 4 days +5 peers, 21 days optimal.
"""
import json
import time
import random
import asyncio
import logging
import uuid
from pathlib import Path
from typing import List, Dict, Optional, Tuple

from models import Account, WarmupPair, WarmupJob, WarmupSchedule

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

# ── Trigger blacklist ────────────────────────────────────────────────────────

_BUILTIN_BLACKLIST = [
    "нужно", "нужны", "требуется", "тренироваться", "срочно",
    "ищем", "ищу", "вакансия", "задача", "работа",
    "разнорабочий", "заработок", "подработка", "шабашка", "халтура",
    "рубль", "рублей", "деньги", "доход",
    "оплата", "выплата", "плачу", "заплачу", "зарплата",
    "доставка", "курьер", "помощь", "помощник",
    "обучаем", "одежда", "проезд", "машина", "чай", "кофе", "лс",
    "бабки", "сумма", "темка", "поднять", "кадры", "документы",
]


def load_trigger_blacklist() -> List[str]:
    """Load blacklist from data/тригера.txt, fallback to builtin."""
    try:
        from config import DATA_DIR
        p = DATA_DIR / "тригера.txt"
        if p.exists():
            lines = p.read_text(encoding="utf-8").strip().splitlines()
            words = [w.strip().lower() for w in lines if w.strip()]
            if words:
                return words
    except Exception as e:
        logger.debug("[warmup] blacklist load error: %s", e)
    return list(_BUILTIN_BLACKLIST)


def check_warmup_triggers(text: str, blacklist: List[str]) -> List[str]:
    """Check text for trigger words. Returns list of found triggers."""
    low = text.lower()
    found = []
    for word in blacklist:
        if word in low:
            found.append(word)
    return found


# ── Schedule / Day limits ────────────────────────────────────────────────────

STAGES = {
    "light":  {"label": "Легкий старт", "icon": "🌱"},
    "warmup": {"label": "Разогрев",     "icon": "🔥"},
    "active": {"label": "Активный",     "icon": "⚡"},
    "boost":  {"label": "Усиленный",    "icon": "🚀"},
    "stable": {"label": "Стабильный",   "icon": "✅"},
    "done":   {"label": "Готов",         "icon": "🎯"},
}


def get_day_limits(day: int, accounts_count: int = 0) -> Tuple[int, int, str]:
    """Returns (max_peers, msgs_per_day, stage) for given day.

    Все аккаунты участвуют каждый день:
    - peers растёт по расписанию (5, 10, 15, 20, 25, 30)
    - msgs_per_day = max(accounts_count * 2, peers * 2) — каждый акк пишет минимум 2 раза
    """
    period = (day - 1) // 4  # 0-based, every 4 days
    peers = min(5 + period * 5, 30)
    # Каждый акк пишет минимум 2 сообщения в день
    min_all_participate = accounts_count * 2 if accounts_count > 0 else 0
    msgs = max(peers * 2, peers + 3, min_all_participate)

    if day <= 4:
        stage = "light"
    elif day <= 8:
        stage = "warmup"
    elif day <= 12:
        stage = "active"
    elif day <= 16:
        stage = "boost"
    elif day <= 21:
        stage = "stable"
    else:
        stage = "done"

    return peers, msgs, stage


def update_schedule(schedule: WarmupSchedule, accounts_count: int = 0):
    """Update schedule based on elapsed time.

    День прогрева — календарный: Day 1 = день старта, Day 2 = следующая
    календарная дата (а не +24ч от момента запуска). Раньше rolling-24h
    приводил к тому, что прогрев на следующий день после старта в 22:51
    оставался Day 1 до 22:51 — клиент видел вчерашний день и думал что
    счётчик сломан.
    """
    if schedule.started_at <= 0:
        schedule.started_at = time.time()
    from datetime import datetime as _dt
    started_date = _dt.fromtimestamp(schedule.started_at).date()
    today_date = _dt.now().date()
    days_passed = (today_date - started_date).days
    schedule.current_day = max(1, days_passed + 1)
    peers, msgs, stage = get_day_limits(schedule.current_day, accounts_count)
    schedule.max_peers_today = peers
    # Если юзер задал override — используем его, иначе авто-расчёт.
    override = getattr(schedule, "msgs_per_day_override", 0) or 0
    schedule.msgs_per_day = override if override > 0 else msgs
    schedule.stage = stage


# ── Account age ──────────────────────────────────────────────────────────────

def _user_id_to_age_seconds(user_id: int) -> int:
    """Эмпирически — Telegram user_id растёт примерно линейно по времени.
    Опорные точки (rough — могут быть ±3-6 мес):
       100M  ≈ 2014-01
       500M  ≈ 2017-04
       1B    ≈ 2020-04
       2B    ≈ 2021-09
       5B    ≈ 2022-08
       6B    ≈ 2023-04
       7B    ≈ 2024-01

    Возвращает секунды с момента примерной даты регистрации.
    Полезно как кросс-чек когда метаданные продавца врут.
    """
    if not user_id or user_id <= 0:
        return 0
    # Кусочно-линейная интерполяция между опорными точками
    points = [
        (100_000_000,    "2014-01-01"),
        (500_000_000,    "2017-04-01"),
        (1_000_000_000,  "2020-04-01"),
        (2_000_000_000,  "2021-09-01"),
        (5_000_000_000,  "2022-08-01"),
        (6_000_000_000,  "2023-04-01"),
        (7_000_000_000,  "2024-01-01"),
    ]
    from datetime import datetime as _dt
    if user_id < points[0][0]:
        # Очень старый — до 2014, точнее не определить
        ts = int(_dt.strptime("2013-08-01", "%Y-%m-%d").timestamp())
    elif user_id >= points[-1][0]:
        # Линейная экстраполяция: ~250M user_id в месяц после 2024
        diff = user_id - points[-1][0]
        months_after = diff / 250_000_000
        ts = int(_dt.strptime(points[-1][1], "%Y-%m-%d").timestamp() + months_after * 30 * 86400)
    else:
        # Между двумя точками — линейно интерполируем дату
        for i in range(len(points) - 1):
            id_lo, d_lo = points[i]
            id_hi, d_hi = points[i + 1]
            if id_lo <= user_id < id_hi:
                ts_lo = _dt.strptime(d_lo, "%Y-%m-%d").timestamp()
                ts_hi = _dt.strptime(d_hi, "%Y-%m-%d").timestamp()
                frac = (user_id - id_lo) / (id_hi - id_lo)
                ts = int(ts_lo + frac * (ts_hi - ts_lo))
                break
        else:
            ts = 0
    if ts <= 0:
        return 0
    return max(0, int(time.time() - ts))


def get_account_age(acc: Account) -> dict:
    """Get account age in days/hours/minutes.

    Источник определяется в порядке надёжности:
      1. user_id_estimate — расчёт по Telegram user_id (объективно — Telegram сам выдал ID)
      2. register_time — из JSON-метаданных продавца (могут врать)
      3. session_created — из JSON-метаданных
      4. file_date — дата .session-файла (часто = дата покупки, не регистрации)
    """
    ts = 0
    source = "unknown"

    # 1. user_id — самый объективный источник если он есть
    user_id = getattr(acc, "user_id", 0) or 0
    if user_id > 0:
        elapsed = _user_id_to_age_seconds(int(user_id))
        if elapsed > 0:
            return {
                "days": elapsed // 86400,
                "hours": (elapsed % 86400) // 3600,
                "minutes": (elapsed % 3600) // 60,
                "total_seconds": elapsed,
                "source": "user_id_estimate",
            }

    # 2-4. Fallback на JSON-метаданные / file mtime (могут быть ненадёжны)
    if getattr(acc, "register_time", 0) and acc.register_time > 0:
        ts = acc.register_time
        source = "register_time (из JSON)"
    elif getattr(acc, "session_created", ""):
        try:
            from datetime import datetime
            dt = datetime.strptime(acc.session_created[:10], "%Y-%m-%d")
            ts = int(dt.timestamp())
            source = "session_created (из JSON)"
        except Exception:
            pass

    if ts <= 0:
        # Дата файла — самый ненадёжный источник: при копировании могут пересоздаться
        if acc.session_path and acc.session_path.exists():
            ts = int(acc.session_path.stat().st_mtime)
            source = "file_date (дата .session)"

    if ts <= 0:
        return {"days": 0, "hours": 0, "minutes": 0, "total_seconds": 0, "source": source}

    elapsed = max(0, int(time.time() - ts))
    days = elapsed // 86400
    hours = (elapsed % 86400) // 3600
    minutes = (elapsed % 3600) // 60
    return {
        "days": days,
        "hours": hours,
        "minutes": minutes,
        "total_seconds": elapsed,
        "source": source,
    }


def format_account_age(acc: Account) -> str:
    """Human-readable account age."""
    a = get_account_age(acc)
    if a["days"] > 0:
        return f"{a['days']}д {a['hours']}ч"
    elif a["hours"] > 0:
        return f"{a['hours']}ч {a['minutes']}мин"
    elif a["minutes"] > 0:
        return f"{a['minutes']}мин"
    return "—"


# ── Pairing ──────────────────────────────────────────────────────────────────

_PAIRS_FILE = None


def _get_pairs_file() -> Path:
    global _PAIRS_FILE
    if _PAIRS_FILE is None:
        from config import DATA_DIR
        _PAIRS_FILE = DATA_DIR / "warmup_pairs.json"
    return _PAIRS_FILE


def save_pairs(pairs: List[WarmupPair]):
    """Persist pairs to disk."""
    try:
        data = []
        for p in pairs:
            data.append({
                "phone_a": p.phone_a,
                "phone_b": p.phone_b,
                "conversation_id": p.conversation_id,
                "topic": p.topic,
                "messages_exchanged": p.messages_exchanged,
                "last_message_at": p.last_message_at,
                "last_sender": p.last_sender,
                "state": p.state,
                "entity_a_id": p.entity_a_id,
                "entity_b_id": p.entity_b_id,
            })
        p = _get_pairs_file()
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception as e:
        logger.warning("[warmup] save_pairs error: %s", e)


def load_pairs() -> List[WarmupPair]:
    """Load pairs from disk."""
    try:
        p = _get_pairs_file()
        if p.exists():
            data = json.loads(p.read_text(encoding="utf-8"))
            return [WarmupPair(**d) for d in data]
    except Exception as e:
        logger.warning("[warmup] load_pairs error: %s", e)
    return []


# Использует unified Account.is_confirmed_dead из models.py — единый источник
# истины. CONFIRMED_DEAD_REASONS оставлен как re-export для совместимости с
# UI-кодом (web/static/app.js использует тот же набор для бейджа).
from models import _CONFIRMED_DEAD_REASONS as CONFIRMED_DEAD_REASONS  # noqa: E402


def _is_confirmed_dead(acc) -> bool:
    return bool(getattr(acc, "is_confirmed_dead", False))


def build_warmup_pairs(
    accounts: Dict[str, Account],
    max_peers: int = 5,
) -> List[WarmupPair]:
    """Build random pairs from warmup accounts.

    Each account gets up to max_peers partners.
    Returns list of unique pairs.
    """
    warmup_phones = [
        phone for phone, acc in accounts.items()
        if getattr(acc, "warmup", False)
        and not _is_confirmed_dead(acc)
        and not acc.frozen
        and getattr(acc, "folder_name", "") in ("Прогрев", "Warmup")
    ]

    if len(warmup_phones) < 2:
        logger.info("[warmup] Need at least 2 accounts, got %d", len(warmup_phones))
        return []

    pairs = []
    seen = set()
    # Счётчики партнёров на акк — O(1) вместо перебора всего seen каждый раз
    peers_count = {p: 0 for p in warmup_phones}
    # Каждый акк должен получить минимум 2 партнёра (даже если max_peers=1)
    min_peers = min(max(2, max_peers), max(1, len(warmup_phones) - 1))
    accounts_shuffled = list(warmup_phones)
    random.shuffle(accounts_shuffled)

    # Первый проход — каждому акку гарантируем min_peers партнёров
    for phone in accounts_shuffled:
        partners = [p for p in warmup_phones if p != phone]
        random.shuffle(partners)
        for partner in partners:
            if peers_count[phone] >= min_peers:
                break
            key = tuple(sorted([phone, partner]))
            if key in seen:
                continue
            seen.add(key)
            peers_count[phone] += 1
            peers_count[partner] += 1
            pairs.append(WarmupPair(
                phone_a=key[0], phone_b=key[1],
                conversation_id=str(uuid.uuid4())[:8],
                topic="", state="idle",
            ))

    # Второй проход — добираем до max_peers
    if max_peers > min_peers:
        for phone in accounts_shuffled:
            partners = [p for p in warmup_phones if p != phone]
            random.shuffle(partners)
            for partner in partners:
                if peers_count[phone] >= max_peers:
                    break
                key = tuple(sorted([phone, partner]))
                if key in seen:
                    continue
                seen.add(key)
                peers_count[phone] += 1
                peers_count[partner] += 1
                pairs.append(WarmupPair(
                    phone_a=key[0], phone_b=key[1],
                    conversation_id=str(uuid.uuid4())[:8],
                    topic="", state="idle",
                ))

    logger.info("[warmup] Built %d pairs from %d accounts (max_peers=%d, min_peers=%d)",
                len(pairs), len(warmup_phones), max_peers, min_peers)
    return pairs


# ── Message generation ───────────────────────────────────────────────────────

async def read_last_incoming(client, entity, sender_phone: str) -> str:
    """Читает последнее входящее сообщение от собеседника.
    Возвращает текст или пустую строку. Не падает при ошибках."""
    try:
        async for msg in client.iter_messages(entity, limit=1):
            if msg and msg.text and not getattr(msg, 'out', False):
                return msg.text[:300]
            break
    except Exception as e:
        logger.debug("[warmup] read_last_incoming %s: %s", sender_phone, e)
    return ""


async def generate_message(
    pair: WarmupPair,
    blacklist: List[str],
    api_key: str = "",
    gen_mode: str = "auto",
    incoming_text: str = "",
) -> Tuple[str, str]:
    """Generate a warmup message for the pair.

    Returns (text, source) where source is 'ai' or 'template'.
    """
    from core.warmup_messages import (
        generate_template_message, get_random_topic,
    )

    # Determine phase
    if pair.state == "idle":
        pair.state = "greeting"
        pair.topic = get_random_topic()

    phase = pair.state
    is_response = pair.messages_exchanged > 0 and pair.last_sender != ""

    # Pick topic if needed
    if not pair.topic:
        pair.topic = get_random_topic()

    # AI вызываем ТОЛЬКО если юзер явно выбрал режим "ai" или "ai_smart".
    # gen_mode="auto" и "templates" — НЕ дёргают AI вообще, даже если ключ настроен.
    # Так юзер сам решает использовать AI или нет — без сюрпризов «вдруг начало
    # тратить $ из настроек хотя я не нажимал».
    if gen_mode in ("ai", "ai_smart"):
        try:
            from core.ai_engine import generate_dialog_reply, ai_status as _ai_status
            if _ai_status().get("enabled"):
                # Persona на акк — берём фиксированную для phone, чтоб разные акки
                # звучали по-разному. Для базового MVP — стабильный seed по phone.
                persona_seed = (pair.from_phone or "default")
                personas = [
                    "Парень 28 лет, программист, общительный, иногда шутит, любит игры и кино.",
                    "Девушка 24 года, маркетолог, много путешествует, открытая, эмоциональная.",
                    "Мужик 35 лет, менеджер, прямолинейный, без эмоций, по делу.",
                    "Девушка 30 лет, фрилансер, спокойная, любит готовить и читать.",
                    "Парень 22 года, студент, разговорчивый, использует сленг, без знаков препинания иногда.",
                ]
                idx = sum(ord(c) for c in str(persona_seed)) % len(personas)
                persona = personas[idx]
                # История диалога — собираем последние 4 сообщения если есть
                history = []
                if incoming_text:
                    history.append({"role": "user", "content": incoming_text})
                # Темы — из настроек юзера
                from config import DATA_DIR
                import json as _json
                try:
                    user_settings_path = DATA_DIR / "user_settings.json"
                    if user_settings_path.exists():
                        _us = _json.loads(user_settings_path.read_text(encoding="utf-8"))
                        cfg_ai = _us.get("ai") or {}
                        custom_topics = (cfg_ai.get("dialog_topics") or "").strip()
                        if custom_topics:
                            import random as _r
                            topics_list = [t.strip() for t in custom_topics.split("\n") if t.strip()]
                            if topics_list:
                                topic = _r.choice(topics_list)
                            else:
                                topic = pair.topic or ""
                        else:
                            topic = pair.topic or ""
                        custom_prompt = cfg_ai.get("custom_dialog_prompt") or ""
                    else:
                        topic = pair.topic or ""
                        custom_prompt = ""
                except Exception:
                    topic = pair.topic or ""
                    custom_prompt = ""
                ai_text = await generate_dialog_reply(persona, history, topic, custom_system=custom_prompt)
                if ai_text:
                    triggers = check_warmup_triggers(ai_text, blacklist)
                    if not triggers:
                        return ai_text, "ai_engine"
                    logger.debug("[warmup] AI text had triggers: %s", triggers)
        except Exception as e:
            logger.debug("[warmup] ai_engine failed: %s — fallback to legacy/template", e)

    # Legacy: старый Gemini-only путь (для обратной совместимости).
    if gen_mode in ("ai", "auto") and api_key:
        try:
            from core.warmup_ai import generate_warmup_reply
            context_parts = [f"Фаза: {phase}", f"Тема: {pair.topic}", f"Обменов: {pair.messages_exchanged}"]
            if incoming_text:
                context_parts.append(f"Собеседник написал: \"{incoming_text}\"")
                context_parts.append("Ответь естественно именно на это сообщение, продолжая диалог.")
            context = ". ".join(context_parts)
            ai_text = await generate_warmup_reply(
                context=context,
                topic=pair.topic,
                blacklist=blacklist,
                api_key=api_key,
            )
            if ai_text:
                triggers = check_warmup_triggers(ai_text, blacklist)
                if not triggers:
                    return ai_text, "ai"
                logger.debug("[warmup] AI text had triggers: %s", triggers)
        except Exception as e:
            logger.debug("[warmup] AI generation failed: %s", e)

    # Fallback to templates
    text = generate_template_message(phase, pair.topic, is_response)
    # Safety check
    for _ in range(5):
        triggers = check_warmup_triggers(text, blacklist)
        if not triggers:
            break
        text = generate_template_message(phase, pair.topic, is_response)
    return text, "template"


# ── Username management ──────────────────────────────────────────────────────

async def ensure_username(acc: Account) -> Optional[str]:
    """Ensure account has a username. If not, generate and set one.
    Returns username or None on failure."""
    if acc.username:
        return acc.username

    client = acc.client
    if not client:
        return None

    try:
        from telethon.tl.functions.account import UpdateUsernameRequest
        import string

        # Приоритет 1: AI-пул юзернеймов (если сгенерирован через /api/ai/regenerate_pools).
        # Если юзер настроил AI и сгенерил пул — берём оттуда, иначе fallback на статичный.
        try:
            from core.ai_generator import pool_usernames as _pool_usernames
            ai_unames = _pool_usernames()
        except Exception:
            ai_unames = []
        if ai_unames:
            random.shuffle(ai_unames)
            for username in ai_unames[:15]:
                try:
                    await client(UpdateUsernameRequest(username=username))
                    acc.username = username
                    logger.info("[warmup] Set username @%s for %s (ai-pool)", username, acc.phone)
                    return username
                except Exception:
                    continue

        # Реалистичный юзернейм: <name>_<digits/letters> или варианты с точкой/без _.
        # Раньше было `user_XXXXX` для ВСЕХ — Telegram палил это регексом
        # `^user_[a-z0-9]{7}$` как фингерпринт бот-сети (incident 2026-05-11).
        # Теперь — рандом из пула реалистичных имён + случайный формат суффикса.
        name_pool = [
            "anna", "max", "ivan", "alex", "kate", "nick", "lena", "tom", "sam",
            "lisa", "den", "pavel", "olya", "kris", "dima", "yulia", "andre",
            "vlad", "kirill", "polina", "sasha", "egor", "tanya", "misha",
            "darya", "fedor", "tasha", "boris", "fox", "ace", "ray", "luna",
            "neo", "milo", "moon", "sky", "leo", "nika", "rita", "vika",
            "alina", "marin", "ksu", "stas", "artur", "rina", "zhanna",
            "valya", "klim", "matvey", "savva", "yana", "roma", "kostya",
            "petya", "vova", "kolya", "alyona", "natasha",
        ]
        digits = string.digits
        chars = string.ascii_lowercase + string.digits
        for _ in range(15):
            name = random.choice(name_pool)
            fmt = random.choice([
                "name_short",        # anna_88
                "name_year",         # max_2024
                "name_letters",      # kate_xy
                "name_dot_short",    # ivan.99
                "name_concat",       # luna77
                "name_underscore",   # leo_petrov  (имитация фамилии)
                "name_suffix_long",  # vika_2024xy
            ])
            if fmt == "name_short":
                username = f"{name}_{random.randint(10, 99)}"
            elif fmt == "name_year":
                username = f"{name}_{random.randint(1985, 2024)}"
            elif fmt == "name_letters":
                username = f"{name}_{''.join(random.choices(string.ascii_lowercase, k=2))}"
            elif fmt == "name_dot_short":
                username = f"{name}{random.choice('._')}{random.randint(10, 999)}"
            elif fmt == "name_concat":
                username = f"{name}{random.randint(10, 999)}"
            elif fmt == "name_underscore":
                second = random.choice(name_pool)
                username = f"{name}_{second}"
            else:
                username = f"{name}_{random.randint(10, 99)}{''.join(random.choices(string.ascii_lowercase, k=2))}"
            # TG требует 5-32 chars, начинается с буквы, _ нельзя в конце
            if len(username) < 5 or len(username) > 32:
                continue
            try:
                await client(UpdateUsernameRequest(username=username))
                acc.username = username
                logger.info("[warmup] Set username @%s for %s", username, acc.phone)
                return username
            except Exception:
                continue

        logger.warning("[warmup] Failed to set username for %s after 15 attempts", acc.phone)
        return None
    except Exception as e:
        logger.warning("[warmup] ensure_username %s: %s", acc.phone, e)
        return None


# ── Entity resolution ────────────────────────────────────────────────────────

async def resolve_entity(client, phone: str, username: str = "", cached_id: int = 0):
    """Resolve Telegram entity. Priority: cached_id → @username → phone."""
    # 1. Cached ID (fastest)
    if cached_id:
        try:
            return await client.get_input_entity(cached_id)
        except Exception:
            pass

    # 2. Username (most reliable)
    if username:
        try:
            return await client.get_input_entity(username)
        except Exception:
            pass
        try:
            return await client.get_input_entity("@" + username.lstrip("@"))
        except Exception:
            pass

    # 3. Phone number (fallback)
    try:
        return await client.get_input_entity("+" + phone.lstrip("+"))
    except Exception:
        pass
    try:
        return await client.get_input_entity(phone)
    except Exception as e:
        # Это ожидаемая ситуация в прогреве (юзер удалился) — DEBUG, не WARNING.
        logger.debug("[warmup] Cannot resolve entity for %s (@%s): %s", phone, username, e)
        return None


# ── Send message ─────────────────────────────────────────────────────────────

async def subscribe_to_channel(acc: Account, channel: str) -> bool:
    """Подписывается на канал по юзернейму/инвайт-ссылке. Возвращает True если успех."""
    try:
        client = acc.client
        if not client:
            return False
        from telethon.tl.functions.channels import JoinChannelRequest
        from telethon.tl.functions.messages import ImportChatInviteRequest

        link = channel.strip().lstrip("@")
        # Invite-ссылка
        if "joinchat/" in link or link.startswith("+") or "t.me/+" in link:
            hash_part = link.split("/")[-1].lstrip("+")
            try:
                await client(ImportChatInviteRequest(hash_part))
                return True
            except Exception as e:
                logger.debug("[warmup] invite %s: %s", channel, e)
                return False
        # Обычный юзернейм
        try:
            await client(JoinChannelRequest(link))
            return True
        except Exception as e:
            logger.debug("[warmup] join %s: %s", channel, e)
            return False
    except Exception as e:
        logger.warning("[warmup] subscribe_to_channel %s: %s", acc.phone, e)
        return False


async def place_reactions_in_channel(acc: Account, channel: str, count: int = 1) -> int:
    """Ставит реакции на последние N постов канала. Возвращает кол-во поставленных."""
    try:
        client = acc.client
        if not client:
            return 0
        from telethon.tl.functions.messages import SendReactionRequest
        from telethon.tl.types import ReactionEmoji
        from core.warmup_defaults import REACTION_EMOJIS

        link = channel.strip().lstrip("@")
        try:
            entity = await client.get_entity(link)
        except Exception:
            return 0

        placed = 0
        msgs = []
        try:
            async for m in client.iter_messages(entity, limit=10):
                if m and m.id:
                    msgs.append(m)
        except Exception:
            return 0

        if not msgs:
            return 0

        # Случайные N сообщений из последних 10
        targets = random.sample(msgs, min(count, len(msgs)))
        for m in targets:
            try:
                emoji = random.choice(REACTION_EMOJIS)
                await client(SendReactionRequest(
                    peer=entity, msg_id=m.id,
                    reaction=[ReactionEmoji(emoticon=emoji)]
                ))
                placed += 1
                await asyncio.sleep(random.uniform(3, 8))
            except Exception as e:
                logger.debug("[warmup] reaction %s: %s", channel, e)
        return placed
    except Exception as e:
        logger.warning("[warmup] place_reactions %s: %s", acc.phone, e)
        return 0


async def send_chat_message(acc: Account, chat: str) -> bool:
    """Пишет короткое безопасное сообщение в чате. Возвращает True если отправлено."""
    try:
        client = acc.client
        if not client:
            return False
        from core.warmup_defaults import CHAT_MESSAGES

        link = chat.strip().lstrip("@")
        try:
            entity = await client.get_entity(link)
        except Exception as e:
            logger.debug("[warmup] chat entity %s: %s", chat, e)
            return False

        text = random.choice(CHAT_MESSAGES)
        # Typing
        try:
            async with client.action(entity, 'typing'):
                await asyncio.sleep(random.uniform(2, 5))
        except Exception:
            await asyncio.sleep(random.uniform(2, 5))
        await client.send_message(entity, text)
        acc.touch()
        return True
    except Exception as e:
        logger.warning("[warmup] send_chat_message %s: %s", acc.phone, e)
        return False


async def refresh_warmup_group_members(
    creator_and_phone: Tuple,
    others_clients_and_phones: List[Tuple],
    chat_id: int,
    invite_link: str = "",
) -> Tuple[int, str]:
    """Догоняет участников: проверяет кто из warmup-акков НЕ в чате прогрева
    и подключает их через invite-ссылку. Используется когда чат создавался
    до фикса bulk-invite (часть акков отвалилась по privacy → нужно дозаходить).

    Возвращает (joined, info).
    """
    if not chat_id:
        return 0, "chat_id не задан"
    try:
        from telethon.tl.functions.messages import ExportChatInviteRequest, ImportChatInviteRequest
        creator_client, _creator_phone = creator_and_phone

        # Пробуем получить invite-ссылку если её нет
        invite_hash = ""
        if invite_link:
            if "+" in invite_link:
                invite_hash = invite_link.split("+", 1)[1].split("?")[0].split("/")[0]
            elif "joinchat/" in invite_link:
                invite_hash = invite_link.split("joinchat/", 1)[1].split("?")[0].split("/")[0]
        if not invite_hash:
            try:
                ent = await creator_client.get_input_entity(int(chat_id))
                invite = await creator_client(ExportChatInviteRequest(ent))
                invite_link = getattr(invite, "link", "") or ""
                if "+" in invite_link:
                    invite_hash = invite_link.split("+", 1)[1].split("?")[0].split("/")[0]
                elif "joinchat/" in invite_link:
                    invite_hash = invite_link.split("joinchat/", 1)[1].split("?")[0].split("/")[0]
            except Exception as e:
                return 0, f"Не удалось получить invite-ссылку: {e}"

        if not invite_hash:
            return 0, "invite-ссылка не получена"

        # Снимаем актуальный список участников
        participant_ids = set()
        try:
            ent = await creator_client.get_input_entity(int(chat_id))
            async for p in creator_client.iter_participants(ent, limit=1000):
                participant_ids.add(p.id)
        except Exception as e:
            logger.warning("[warmup] iter_participants(refresh): %s", e)

        joined = 0
        skipped_already = 0
        for cli, ph in others_clients_and_phones:
            try:
                me = await cli.get_me()
                if not me:
                    continue
                if me.id in participant_ids:
                    skipped_already += 1
                    continue
                try:
                    await cli(ImportChatInviteRequest(invite_hash))
                    joined += 1
                    await asyncio.sleep(random.uniform(0.8, 1.8))
                except Exception as e:
                    msg = str(e).lower()
                    if "already" in msg:
                        skipped_already += 1
                        continue
                    logger.debug("[warmup] %s refresh-join: %s", ph, e)
            except Exception:
                continue

        info = f"Подключено новых: {joined}, уже в чате: {skipped_already + 1}, ссылка: {invite_link}"
        return joined, info
    except Exception as e:
        logger.warning("[warmup] refresh_warmup_group_members: %s", e)
        return 0, f"Ошибка: {e}"


async def create_warmup_group(clients_and_phones: List[Tuple], title: str) -> Tuple[Optional[int], Optional[int], str]:
    """Создаёт приватную супергруппу для прогрева, добавляет туда все аккаунты.

    Если кого-то не удалось добавить через InviteToChannelRequest (privacy
    settings блокируют добавление в группы) — они сами заходят по invite-ссылке.
    Это критично: без fallback'а из 21 акка в группу попадало 8-9, остальные
    не могли участвовать в сценарии own_chat.

    Возвращает (channel_id, access_hash, info_message).
    """
    if len(clients_and_phones) < 2:
        return None, None, "Нужно минимум 2 аккаунта"
    try:
        from telethon.tl.functions.channels import CreateChannelRequest, InviteToChannelRequest
        from telethon.tl.functions.messages import ExportChatInviteRequest, ImportChatInviteRequest
        creator_client, creator_phone = clients_and_phones[0]

        result = await creator_client(CreateChannelRequest(
            title=title, about="", megagroup=True
        ))
        channel = None
        for ch in getattr(result, "chats", []):
            channel = ch
            break
        if not channel:
            return None, None, "Не удалось создать группу"

        # Получаем invite-ссылку сразу — пригодится для тех, кого не пустит InviteTo.
        invite_link = ""
        invite_hash = ""
        try:
            invite = await creator_client(ExportChatInviteRequest(channel))
            invite_link = getattr(invite, "link", "") or ""
            if "+" in invite_link:
                invite_hash = invite_link.split("+", 1)[1].split("?")[0].split("/")[0]
            elif "joinchat/" in invite_link:
                invite_hash = invite_link.split("joinchat/", 1)[1].split("?")[0].split("/")[0]
        except Exception as e:
            logger.warning("[warmup] ExportChatInvite: %s", e)

        # Собираем entity остальных + сохраняем (cli, ph) для fallback'а
        user_entities = []
        non_creator = []
        for cli, ph in clients_and_phones[1:]:
            try:
                me = await cli.get_me()
                if not me:
                    continue
                try:
                    ent = await creator_client.get_input_entity(me.id)
                except Exception:
                    if me.username:
                        ent = await creator_client.get_input_entity(me.username)
                    else:
                        non_creator.append((cli, ph))
                        continue
                user_entities.append(ent)
                non_creator.append((cli, ph))
            except Exception:
                continue

        invited_via_bulk = 0
        if user_entities:
            try:
                await creator_client(InviteToChannelRequest(channel=channel, users=user_entities))
                invited_via_bulk = len(user_entities)
            except Exception as e:
                logger.warning("[warmup] InviteToChannel: %s", e)

        # Проверяем кто реально в чате (privacy-restricted могли отвалиться).
        # Если invite_hash есть — те, кого нет среди участников, заходят сами.
        joined_via_link = 0
        if invite_hash and non_creator:
            try:
                # Получаем актуальный список участников
                participant_ids = set()
                try:
                    async for p in creator_client.iter_participants(channel, limit=1000):
                        participant_ids.add(p.id)
                except Exception as e:
                    logger.debug("[warmup] iter_participants: %s", e)

                for cli, ph in non_creator:
                    try:
                        me = await cli.get_me()
                        if not me:
                            continue
                        if me.id in participant_ids:
                            continue  # уже в чате
                        try:
                            await cli(ImportChatInviteRequest(invite_hash))
                            joined_via_link += 1
                            await asyncio.sleep(random.uniform(0.8, 1.8))
                        except Exception as e:
                            msg = str(e).lower()
                            if "already" in msg:
                                continue
                            logger.debug("[warmup] %s join via link: %s", ph, e)
                    except Exception as e:
                        logger.debug("[warmup] %s join fallback: %s", ph, e)
            except Exception as e:
                logger.debug("[warmup] join-link fallback err: %s", e)

        total_in_chat = 1 + invited_via_bulk  # консервативная оценка
        if joined_via_link:
            total_in_chat += joined_via_link
        info = f"Группа '{title}' создана, участников: ~{total_in_chat}"
        if joined_via_link:
            info += f" (по ссылке зашли {joined_via_link})"
        # invite_link сохраняется через side-channel (атрибут модуля), api-обёртка
        # подбирает и пишет в user_settings — нужно чтобы при пересоздании или
        # «догнать участников» переиспользовать ту же ссылку.
        try:
            globals()["_LAST_GROUP_INVITE_LINK"] = invite_link or ""
        except Exception:
            pass
        return channel.id, getattr(channel, "access_hash", 0), info
    except Exception as e:
        logger.warning("[warmup] create_warmup_group: %s", e)
        return None, None, f"Ошибка: {e}"


_LAST_GROUP_INVITE_LINK = ""


def get_last_group_invite_link() -> str:
    return _LAST_GROUP_INVITE_LINK or ""


async def create_warmup_channel(clients_and_phones: List[Tuple], title: str) -> Tuple[Optional[int], Optional[int], str]:
    """Создаёт приватный broadcast-канал, приглашает аккаунты и даёт им права админов
    (чтобы все могли публиковать посты).

    Возвращает (channel_id, access_hash, info_message).
    """
    if len(clients_and_phones) < 2:
        return None, None, "Нужно минимум 2 аккаунта"
    try:
        from telethon.tl.functions.channels import (
            CreateChannelRequest, InviteToChannelRequest, EditAdminRequest,
        )
        from telethon.tl.types import ChatAdminRights

        creator_client, creator_phone = clients_and_phones[0]

        result = await creator_client(CreateChannelRequest(
            title=title, about="", broadcast=True, megagroup=False
        ))
        channel = None
        for ch in getattr(result, "chats", []):
            channel = ch
            break
        if not channel:
            return None, None, "Не удалось создать канал"

        # Права админа (могут публиковать и реагировать)
        admin_rights = ChatAdminRights(
            change_info=False, post_messages=True, edit_messages=True,
            delete_messages=True, ban_users=False, invite_users=False,
            pin_messages=True, add_admins=False, anonymous=False,
            manage_call=False, other=False, manage_topics=False,
        )

        user_entities = []
        invited = 0
        promoted = 0
        for cli, ph in clients_and_phones[1:]:
            try:
                me = await cli.get_me()
                if not me:
                    continue
                try:
                    ent = await creator_client.get_input_entity(me.id)
                except Exception:
                    if me.username:
                        ent = await creator_client.get_input_entity(me.username)
                    else:
                        continue
                user_entities.append(ent)
            except Exception:
                continue

        if user_entities:
            try:
                await creator_client(InviteToChannelRequest(channel=channel, users=user_entities))
                invited = len(user_entities)
            except Exception as e:
                logger.warning("[warmup] InviteToChannel: %s", e)

            # Делаем всех админами с правом публиковать
            for ent in user_entities:
                try:
                    await creator_client(EditAdminRequest(
                        channel=channel, user_id=ent, admin_rights=admin_rights, rank="редактор"
                    ))
                    promoted += 1
                    await asyncio.sleep(random.uniform(1.0, 2.5))
                except Exception as e:
                    logger.debug("[warmup] EditAdmin: %s", e)

        info = f"Канал '{title}' создан. Приглашено: {invited}, админов: {promoted + 1}"
        return channel.id, getattr(channel, "access_hash", 0), info
    except Exception as e:
        logger.warning("[warmup] create_warmup_channel: %s", e)
        return None, None, f"Ошибка: {e}"


async def create_own_chat(clients_and_phones: List[Tuple], title: str) -> Tuple[Optional[int], str]:
    """Создаёт групповой чат и добавляет туда аккаунты.

    clients_and_phones: [(telethon_client, phone), ...] — первый клиент = создатель.
    Возвращает (chat_id, info_message).
    """
    if len(clients_and_phones) < 2:
        return None, "Нужно минимум 2 аккаунта"
    try:
        from telethon.tl.functions.messages import CreateChatRequest
        from telethon.errors import UserPrivacyRestrictedError

        creator_client, creator_phone = clients_and_phones[0]
        # Собираем entity остальных
        user_entities = []
        skipped = []
        for cli, ph in clients_and_phones[1:]:
            try:
                me = await cli.get_me()
                if me:
                    # Creator resolve by ID
                    try:
                        ent = await creator_client.get_input_entity(me.id)
                        user_entities.append(ent)
                    except Exception:
                        try:
                            if me.username:
                                ent = await creator_client.get_input_entity(me.username)
                                user_entities.append(ent)
                            else:
                                skipped.append(ph)
                        except Exception:
                            skipped.append(ph)
            except Exception:
                skipped.append(ph)

        if not user_entities:
            return None, "Не удалось получить entity участников"

        result = await creator_client(CreateChatRequest(
            users=user_entities, title=title
        ))
        # Извлекаем chat_id из результата
        chat_id = None
        try:
            for u in result.updates.chats:
                chat_id = u.id
                break
        except Exception:
            pass

        info = f"Чат '{title}' создан, добавлено {len(user_entities)} участников"
        if skipped:
            info += f" (пропущено {len(skipped)}: {', '.join(skipped[:5])})"
        return chat_id, info
    except Exception as e:
        logger.warning("[warmup] create_own_chat error: %s", e)
        return None, f"Ошибка: {e}"


async def add_contacts_bulk(hub_acc: Account, target_phones: List[str], ctx, log_fn=None) -> int:
    """Добавляет список аккаунтов в контакты хаб-аккаунта.
    Хаб-акк должен быть подключён. Возвращает сколько реально добавил."""
    log = log_fn or (lambda m: logger.info("[warmup-hub] %s", m))
    try:
        from telethon.tl.functions.contacts import AddContactRequest
        from telethon.tl.types import InputUser
    except Exception as e:
        log(f"⚠ contacts import: {e}")
        return 0

    added = 0
    for phone in target_phones:
        target = ctx.accounts.get(phone)
        if not target or not target.client:
            continue
        try:
            me = await target.client.get_me()
            if not me:
                continue
            fname = (getattr(me, "first_name", "") or "")[:30]
            lname = (getattr(me, "last_name", "") or "")[:30]
            ph = getattr(me, "phone", "") or phone
            # Ensure hub has the target's entity
            try:
                ent = await hub_acc.client.get_input_entity(me.id)
            except Exception:
                try:
                    if me.username:
                        ent = await hub_acc.client.get_input_entity(me.username)
                    else:
                        continue
                except Exception:
                    continue
            await hub_acc.client(AddContactRequest(
                id=ent, first_name=fname or "Friend", last_name=lname,
                phone=ph, add_phone_privacy_exception=False,
            ))
            added += 1
            await asyncio.sleep(random.uniform(1.5, 3.5))
        except Exception as e:
            log(f"⚠ add_contact {phone}: {e}")
    if added:
        log(f"📇 Хаб добавил {added} акков в контакты")
    return added


async def reply_in_own_chat(
    acc: Account, own_chat_id: int,
    reply_to_other: bool = True,
    id_format: bool = False,
    acc_num: int = 0,
    msg_num: int = 1,
) -> Tuple[Optional[int], str]:
    """Пишет в own_chat с reply на последнее сообщение другого акка.
    Возвращает (tuple (user_id которому ответил, сам текст сообщения)) или (None, "")."""
    try:
        client = acc.client
        if not client:
            return None, ""
        from core.warmup_defaults import DIARY_MESSAGES

        try:
            entity = await client.get_input_entity(int(own_chat_id))
        except Exception:
            return None, ""

        try:
            me = await client.get_me()
            my_id = me.id if me else 0
        except Exception:
            my_id = 0

        # Читаем последние 10 сообщений
        reply_to_id = None
        replied_sender_id = None
        if reply_to_other:
            try:
                async for m in client.iter_messages(entity, limit=10):
                    if not m or m.out:
                        continue
                    sender_id = getattr(m.from_id, "user_id", 0) if m.from_id else 0
                    if sender_id and sender_id != my_id:
                        reply_to_id = m.id
                        replied_sender_id = sender_id
                        break
            except Exception:
                pass

        # Текст — либо ID-формат "00101", либо дневниковое
        if id_format and acc_num > 0:
            text = f"{acc_num:03d}{msg_num:02d}"
        else:
            text = random.choice(DIARY_MESSAGES)

        try:
            async with client.action(entity, 'typing'):
                await asyncio.sleep(random.uniform(2, 4))
        except Exception:
            await asyncio.sleep(random.uniform(2, 4))

        if reply_to_id:
            await client.send_message(entity, text, reply_to=reply_to_id)
        else:
            await client.send_message(entity, text)
        acc.touch()
        return replied_sender_id, text
    except Exception as e:
        logger.warning("[warmup-chat-reply] %s: %s", acc.phone, e)
        return None, ""


async def send_warmup_message(acc: Account, entity, text: str) -> bool:
    """Send a warmup message with typing simulation."""
    try:
        client = acc.client
        if not client:
            return False

        # Simulate typing (2-5 seconds)
        typing_duration = random.uniform(2.0, 5.0)
        try:
            async with client.action(entity, 'typing'):
                await asyncio.sleep(typing_duration)
        except Exception:
            await asyncio.sleep(typing_duration)

        await client.send_message(entity, text)
        acc.touch()
        return True
    except Exception as e:
        # «User deleted / Too many requests» — нормальные сценарии warmup.
        # Не WARNING чтобы не засорять remote_logs.
        logger.debug("[warmup] Send failed %s: %s", acc.phone, e)
        return False


# ── Main warmup loop ─────────────────────────────────────────────────────────

async def run_warmup_job(job: WarmupJob, ctx):
    """Main warmup loop. Called as an async task."""
    from core.client_builder import ensure_connected
    from datetime import datetime

    blacklist = load_trigger_blacklist()
    api_key = ctx.user_settings.get("gemini_api_key", "")
    gen_mode = job.config.get("gen_mode", "auto")

    job.status = "running"
    job.pause_event.set()

    def _log(msg: str):
        ts = datetime.now().strftime("%H:%M:%S")
        line = f"[{ts}] {msg}"
        job.log_lines.append(line)
        if len(job.log_lines) > 200:
            job.log_lines = job.log_lines[-150:]
        logger.info("[warmup] %s", msg)

    _log(f"Прогрев запущен: {len(job.pairs)} пар, план {job.schedule.total_days} дней")

    def _count_active_warmup_accs():
        """Единый фильтр активных warmup-акков. Транзиентные dead-флаги
        (моргнул прокси) не отбрасываем — иначе клиент видит то 21 акк, то 1."""
        return [p for p, a in ctx.accounts.items()
                if getattr(a, "warmup", False) and not _is_confirmed_dead(a)
                and not a.frozen
                and getattr(a, "folder_name", "") in ("Прогрев", "Warmup")]

    # Счётчик для автоподхвата новых акков
    last_accounts_count = len(_count_active_warmup_accs())

    # Состояние хаб-режима
    warmup_mode = job.config.get("warmup_mode", "classic")  # classic | hub | chat_flow
    hub_setup_done = False
    last_hub_setup_day = 0
    _stats_prev_total = job.total_messages_sent

    while not job.stop_flag:
        # Wait if paused
        await job.pause_event.wait()
        if job.stop_flag:
            break

        # Сообщить серверной статистике сколько прогревных сообщений прибавилось.
        _delta = job.total_messages_sent - _stats_prev_total
        if _delta > 0:
            try:
                from utils.stats_reporter import increment_warmup_msgs
                increment_warmup_msgs(_delta)
            except Exception:
                pass
            _stats_prev_total = job.total_messages_sent

        # Текущее кол-во warmup аккаунтов (тот же фильтр что и в build_warmup_pairs)
        current_accounts = _count_active_warmup_accs()
        accounts_count = len(current_accounts)

        # Update schedule (с учётом кол-ва акков для msgs_per_day)
        prev_day = job.schedule.current_day
        update_schedule(job.schedule, accounts_count=accounts_count)
        if job.schedule.current_day != prev_day:
            try:
                from core.warmup_resume import update_current_day
                update_current_day(job.schedule.current_day)
            except Exception:
                pass

        # Check if done
        if job.schedule.stage == "done":
            _log("Прогрев завершён! Аккаунты готовы к работе.")
            job.status = "finished"
            if job.schedule.auto_move_when_done:
                await _auto_move_to_working(ctx, _log)
            try:
                from core.warmup_resume import clear_snapshot
                clear_snapshot()
            except Exception:
                pass
            break

        # Автоподхват новых акков — если в папке Прогрев добавились новые
        if accounts_count != last_accounts_count:
            _log(f"🔄 Обнаружено изменение аккаунтов: {last_accounts_count} → {accounts_count}, пересобираю пары")
            peers, msgs, stage = get_day_limits(job.schedule.current_day, accounts_count)
            job.pairs = build_warmup_pairs(ctx.accounts, max_peers=peers)
            save_pairs(job.pairs)
            last_accounts_count = accounts_count
            # При изменении состава акков — переинициализировать хаб
            hub_setup_done = False

        # Хаб-режим: при первом запуске / новом дне — хаб добавляет всех в контакты
        if warmup_mode == "hub" and current_accounts and (not hub_setup_done or last_hub_setup_day != job.schedule.current_day):
            # Хаб — либо явно указанный в конфиге, либо первый warmup акк
            configured_hub = job.config.get("hub_phone", "")
            if configured_hub and configured_hub in current_accounts:
                hub_phone = configured_hub
            else:
                hub_phone = current_accounts[0]
            hub_acc = ctx.accounts.get(hub_phone)
            if hub_acc and await ensure_connected(hub_acc):
                others = [p for p in current_accounts if p != hub_phone]
                # Подключаем остальных чтобы хаб мог получить их entity (через username)
                for op in others[:min(len(others), 5)]:
                    oa = ctx.accounts.get(op)
                    if oa:
                        await ensure_connected(oa)
                added = await add_contacts_bulk(hub_acc, others, ctx, _log)
                _log(f"📇 Хаб {hub_phone} ({added}/{len(others)} контактов)")
            hub_setup_done = True
            last_hub_setup_day = job.schedule.current_day

        # Check active hours (с опцией работы до 23:59)
        now = datetime.now()
        work_until_midnight = job.config.get("work_until_midnight", False)
        manual_next_day = job.config.get("manual_next_day", False)
        start_h = job.config.get("active_hours_start", 9)
        end_h = 24 if work_until_midnight else job.config.get("active_hours_end", 22)
        now_hour = now.hour
        if not (start_h <= now_hour < end_h):
            await asyncio.sleep(60)
            continue

        # Ручной запуск дня: если включён manual_next_day и день "исчерпан",
        # ждём пока клиент не нажмёт кнопку (job.config["next_day_requested"])
        today_start = time.time() - (time.time() % 86400)
        if manual_next_day:
            day_finished = job.messages_today >= job.schedule.msgs_per_day
            if day_finished and not job.config.get("next_day_requested", False):
                # Ждём ручного старта
                await asyncio.sleep(30)
                continue
            if job.config.get("next_day_requested", False):
                job.messages_today = 0
                job.schedule.started_at -= 86400  # "сдвигаем" старт на день назад → current_day+1
                job.config["next_day_requested"] = False
                _log(f"🌅 Ручной запуск дня {job.schedule.current_day + 1}")

        # Daily message reset (автоматический)
        if not manual_next_day and job.last_day_reset < today_start:
            job.messages_today = 0
            job.last_day_reset = time.time()
            # Rebuild pairs for new day
            peers, msgs, stage = get_day_limits(job.schedule.current_day, accounts_count)
            _log(f"День {job.schedule.current_day}/{job.schedule.total_days} — "
                 f"{STAGES[stage]['icon']} {STAGES[stage]['label']} "
                 f"({peers} партнёров, {msgs} сообщ/день, {accounts_count} акк.)")
            job.pairs = build_warmup_pairs(ctx.accounts, max_peers=peers)
            save_pairs(job.pairs)

        # Check daily limit
        if job.messages_today >= job.schedule.msgs_per_day:
            await asyncio.sleep(300)  # wait 5 min, check again
            continue

        # ── Выбор сценария: DM / channels / reactions / own_chat / diary_* ──
        scenarios = (job.config.get("scenarios") or {})
        all_scenarios = ("dm", "channels", "reactions", "own_chat", "diary_post", "diary_subscribe", "diary_react")
        enabled = [s for s in all_scenarios if scenarios.get(s)]

        # Авто-включение сценариев когда инфраструктура уже создана:
        # • есть warmup_own_chat_id → подключаем own_chat (хаб-чат бы пустовал)
        # • есть warmup_diaries → подключаем дневниковые сценарии
        # Раньше эти сценарии требовали явных чекбоксов scenarios.own_chat/diaries —
        # клиенты создавали хаб-чат/дневник, не отмечали галки и неделями видели
        # «вступил по ссылке» без сообщений и тишину в дневниках.
        if "own_chat" not in enabled and (ctx.user_settings or {}).get("warmup_own_chat_id"):
            enabled.append("own_chat")
        diaries = (ctx.user_settings or {}).get("warmup_diaries") or {}
        if diaries:
            for s in ("diary_post", "diary_subscribe", "diary_react"):
                if s not in enabled:
                    enabled.append(s)

        if not enabled:
            enabled = ["dm"]  # дефолт

        # Однократный лог активных сценариев — на следующем тике клиент видит,
        # что софт реально знает про hub-чат и дневник.
        prev_enabled = job.config.get("_logged_enabled") or []
        if list(enabled) != list(prev_enabled):
            _log("🎯 Активные сценарии: " + ", ".join(enabled))
            job.config["_logged_enabled"] = list(enabled)

        # Распределение по типу прогрева. Дневники активируются с дня 4+
        day = max(1, int(getattr(job, "current_day", 1)))
        if warmup_mode == "chat_flow":
            weights = {"dm": 3, "channels": 2, "reactions": 3, "own_chat": 12,
                       "diary_post": 10 if day >= 4 else 0,
                       "diary_subscribe": 6 if day >= 4 else 0,
                       "diary_react": 8 if day >= 5 else 0}
        elif warmup_mode == "hub":
            # В hub-режиме приоритет — общий чат, ЛС-парам отводим минимум.
            # Иначе клиент видит «все вступили в чат, но никто не пишет».
            weights = {"dm": 2, "channels": 2, "reactions": 3, "own_chat": 10,
                       "diary_post": 8 if day >= 4 else 0,
                       "diary_subscribe": 5 if day >= 4 else 0,
                       "diary_react": 7 if day >= 5 else 0}
        else:
            weights = {"dm": 8, "channels": 2, "reactions": 3, "own_chat": 5,
                       "diary_post": 8 if day >= 4 else 0,
                       "diary_subscribe": 5 if day >= 4 else 0,
                       "diary_react": 7 if day >= 5 else 0}
        picks = [s for s in enabled for _ in range(weights.get(s, 1))]
        if not picks:
            picks = ["dm"]
        scenario = random.choice(picks)

        if scenario != "dm":
            ok = await _run_auxiliary_scenario(job, ctx, scenario, _log)
            # Пауза между вспомогательными действиями
            await asyncio.sleep(random.uniform(
                job.config.get("min_delay", 60),
                job.config.get("max_delay", 300),
            ))
            continue

        # Process one pair exchange
        active_pairs = [p for p in job.pairs if p.state not in ("done", "goodbye_sent")]
        if not active_pairs:
            # All pairs done for today, wait
            await asyncio.sleep(600)
            continue

        pair = random.choice(active_pairs)

        # Determine sender and receiver
        if not pair.last_sender or pair.last_sender == pair.phone_b:
            sender_phone = pair.phone_a
            receiver_phone = pair.phone_b
        else:
            sender_phone = pair.phone_b
            receiver_phone = pair.phone_a

        sender = ctx.accounts.get(sender_phone)
        receiver = ctx.accounts.get(receiver_phone)

        if not sender or not receiver:
            pair.state = "done"
            continue

        # Quiet hours: если у sender'а сейчас локальная ночь — не пишем.
        # Раньше акки писали 24/7, что палится антифрод-метрикой «активность
        # вне человеческих часов». Границы quiet hours юзер настраивает в UI,
        # дефолт 23-07. Часы определяются по country-code номера.
        try:
            from core.quiet_hours import is_quiet_hours, seconds_until_end_of_quiet
            qh_enabled = bool(job.config.get("quiet_hours_enabled", True))
            qh_start = int(job.config.get("quiet_hours_start", 23))
            qh_end = int(job.config.get("quiet_hours_end", 7))
            if qh_enabled and is_quiet_hours(sender_phone, qh_start, qh_end):
                # До конца quiet — спим с верхней границей 30 мин, потом цикл повторится
                # и снова перепроверится (на случай если юзер выключил quiet hours).
                wait = min(seconds_until_end_of_quiet(sender_phone, qh_start, qh_end), 1800)
                _log(f"😴 {sender_phone}: тихие часы, пропуск ({wait//60} мин)")
                await asyncio.sleep(max(60, wait))
                continue
        except Exception:
            pass

        # Connect sender
        if not await ensure_connected(sender):
            _log(f"⚠ {sender_phone} не подключається, пропуск")
            pair.state = "done"
            continue

        # Ensure receiver has username (for entity resolution)
        if not receiver.username:
            if not await ensure_connected(receiver):
                _log(f"⚠ {receiver_phone} не подключается, пропуск")
                pair.state = "done"
                continue
            uname = await ensure_username(receiver)
            if uname:
                _log(f"✅ Установлен @{uname} для {receiver_phone}")

        # Advance conversation state
        if pair.state == "idle":
            pair.state = "greeting"
            pair.topic = ""  # will be set in generate_message

        # Check if conversation should end (4-10 exchanges)
        target_exchanges = random.randint(4, 10)
        if pair.messages_exchanged >= target_exchanges:
            pair.state = "goodbye"

        # Resolve receiver entity (username → cached_id → phone)
        cached_id = pair.entity_b_id if sender_phone == pair.phone_a else pair.entity_a_id
        receiver_username = getattr(receiver, "username", "") or ""
        entity = await resolve_entity(sender.client, receiver_phone, receiver_username, cached_id)
        if not entity:
            _log(f"⚠ Не найден entity {receiver_phone} (@{receiver_username})")
            pair.state = "done"
            continue

        # Читаем последнее входящее от собеседника (для контекстного ответа)
        incoming = ""
        if job.config.get("reply_to_incoming", True) and pair.messages_exchanged > 0:
            incoming = await read_last_incoming(sender.client, entity, sender_phone)

        # Generate message (учитываем входящий текст)
        text, source = await generate_message(pair, blacklist, api_key, gen_mode, incoming_text=incoming)

        # Cache entity ID
        try:
            eid = getattr(entity, 'user_id', 0) or getattr(entity, 'chat_id', 0)
            if sender_phone == pair.phone_a:
                pair.entity_b_id = eid
            else:
                pair.entity_a_id = eid
        except Exception:
            pass

        # Send
        ok = await send_warmup_message(sender, entity, text)
        if ok:
            pair.messages_exchanged += 1
            pair.last_message_at = time.time()
            pair.last_sender = sender_phone
            job.total_messages_sent += 1
            job.messages_today += 1
            src_tag = "AI" if source == "ai" else "шаблон"
            _log(f"{sender_phone} → {receiver_phone}: {text[:50]}... ({src_tag})")

            # Transition after goodbye
            if pair.state == "goodbye":
                pair.state = "done"
        else:
            _log(f"⚠ Не удалось отправить {sender_phone} → {receiver_phone}")

        # Delay before next message.
        # Per-account jitter: каждый акк получает стабильный множитель 0.7..1.3
        # от своего phone (детерминированный по сумме цифр, не зависит от
        # PYTHONHASHSEED). Это рассинхронизирует тайминги — раньше все акки
        # шли в одном диапазоне 60-300с и образовывали temporal cluster
        # (fingerprint для антиспама TG).
        jit = 0.7 + (sum(ord(c) for c in str(sender_phone or "")) % 60) / 100.0
        min_d = int(job.config.get("min_delay", 60) * jit)
        max_d = int(job.config.get("max_delay", 300) * jit)

        # Burst pattern: sometimes faster
        if job.messages_today % job.config.get("burst_size", 3) == 0:
            # Burst pause
            pause = random.uniform(
                int(job.config.get("burst_pause_min", 600) * jit),
                int(job.config.get("burst_pause_max", 1800) * jit),
            )
            _log(f"⏸ Пауза {int(pause/60)} мин (burst)")
            await asyncio.sleep(pause)
        else:
            delay = random.uniform(min_d, max_d)
            await asyncio.sleep(delay)

    job.status = job.status if job.status == "finished" else "stopped"
    save_pairs(job.pairs)
    _log(f"Прогрев остановлен. Всего сообщений: {job.total_messages_sent}")


def _ensure_account_state(job: WarmupJob, phone: str) -> dict:
    """Вернёт/создаст состояние для аккаунта. Сбрасывает дневные счётчики при смене дня."""
    st = job.account_state.setdefault(phone, {
        "channels_joined": [],
        "chats_joined": [],
        "reactions_today": 0,
        "chat_msgs_today": 0,
        "subs_today": 0,
        "last_reset_day": 0,
    })
    if st.get("last_reset_day", 0) != job.schedule.current_day:
        st["reactions_today"] = 0
        st["chat_msgs_today"] = 0
        st["subs_today"] = 0
        st["last_reset_day"] = job.schedule.current_day
    return st


def _pick_warmup_account(ctx, job: WarmupJob, filter_fn=None) -> Optional[str]:
    """Выбирает случайный живой прогреваемый аккаунт, опционально с фильтром.
    Также применяет quiet hours filter — акк не выбирается в свои локальные
    ночные часы (по country-code номера). Если ВСЕ акки в quiet — fallback
    отключается и берём любого (чтобы цикл не остановился насмерть на час)."""
    candidates = [
        phone for phone, acc in ctx.accounts.items()
        if getattr(acc, "warmup", False) and not acc.dead and not acc.frozen
        and getattr(acc, "folder_name", "") in ("Прогрев", "Warmup")
    ]
    if filter_fn:
        candidates = [p for p in candidates if filter_fn(p)]
    if not candidates:
        return None
    # Quiet hours filter
    try:
        from core.quiet_hours import is_quiet_hours
        qh_enabled = bool(job.config.get("quiet_hours_enabled", True))
        qh_start = int(job.config.get("quiet_hours_start", 23))
        qh_end = int(job.config.get("quiet_hours_end", 7))
        if qh_enabled:
            awake = [p for p in candidates if not is_quiet_hours(p, qh_start, qh_end)]
            if awake:
                candidates = awake
            # Если все в quiet — оставляем как есть (не блокируем тик навсегда)
    except Exception:
        pass
    return random.choice(candidates)


async def _run_auxiliary_scenario(job: WarmupJob, ctx, scenario: str, log_fn) -> bool:
    """Выполняет один вспомогательный сценарий (channels/reactions/chats).
    Возвращает True если действие выполнено."""
    from core.client_builder import ensure_connected

    cfg = job.config

    # Quiet hours pre-filter применяется в _pick_warmup_account через
    # обёртку: если у акка сейчас локальная ночь — он не выбирается на тике.
    # Подробности — в core.quiet_hours.

    if scenario == "channels":
        chan_list = cfg.get("channels_list") or []
        if not chan_list:
            from core.warmup_defaults import DEFAULT_CHANNELS
            chan_list = DEFAULT_CHANNELS
        per_day = cfg.get("channels_per_day", 3)

        def can_subscribe(phone):
            st = _ensure_account_state(job, phone)
            return st["subs_today"] < per_day

        phone = _pick_warmup_account(ctx, job, can_subscribe)
        if not phone:
            return False
        acc = ctx.accounts.get(phone)
        if not acc or not await ensure_connected(acc):
            return False
        st = _ensure_account_state(job, phone)
        # Канал, на который ещё не подписан
        available = [c for c in chan_list if c not in st["channels_joined"]]
        if not available:
            return False
        channel = random.choice(available)
        ok = await subscribe_to_channel(acc, channel)
        if ok:
            st["channels_joined"].append(channel)
            st["subs_today"] += 1
            job.total_messages_sent += 1  # учитываем как активность
            log_fn(f"📢 {phone} подписался на @{channel}")
            return True
        return False

    if scenario == "reactions":
        per_day = cfg.get("reactions_per_day", 5)

        def can_react(phone):
            st = _ensure_account_state(job, phone)
            return st["reactions_today"] < per_day and len(st["channels_joined"]) > 0

        phone = _pick_warmup_account(ctx, job, can_react)
        if not phone:
            return False
        acc = ctx.accounts.get(phone)
        if not acc or not await ensure_connected(acc):
            return False
        st = _ensure_account_state(job, phone)
        channel = random.choice(st["channels_joined"])
        placed = await place_reactions_in_channel(acc, channel, count=random.randint(1, 3))
        if placed:
            st["reactions_today"] += placed
            job.total_messages_sent += placed
            log_fn(f"👍 {phone} поставил {placed} реакций в @{channel}")
            return True
        return False

    if scenario == "own_chat":
        # Subgroups: если юзер разбил акков на подгруппы — выбираем чат
        # СПЕЦИФИЧНЫЙ для этого акка. Иначе — legacy одиночный warmup_own_chat_id.
        phone = _pick_warmup_account(ctx, job)
        if not phone:
            return False
        try:
            from core.warmup_groups import get_own_chat_for_phone
            chat_info = get_own_chat_for_phone(ctx.user_settings or {}, phone)
            own_chat_id = int(chat_info["chat_id"]) if chat_info else 0
        except Exception:
            own_chat_id = int((ctx.user_settings or {}).get("warmup_own_chat_id", 0) or 0)
        if not own_chat_id:
            return False
        acc = ctx.accounts.get(phone)
        if not acc or not await ensure_connected(acc):
            return False

        # Номер аккаунта — порядковый индекс среди warmup акков
        warmup_phones = sorted([p for p, a in ctx.accounts.items()
                                if getattr(a, "warmup", False) and not a.dead and not a.frozen])
        acc_num = (warmup_phones.index(phone) + 1) if phone in warmup_phones else 1

        # Состояние — счётчик сообщений для ID-формата
        st = _ensure_account_state(job, phone)
        st.setdefault("chat_msg_counter", 0)
        st["chat_msg_counter"] += 1

        id_format = bool(cfg.get("id_format", False))
        # 60% — reply на чужое, 40% — новое сообщение
        reply = random.random() < 0.6
        replied_user_id, text = await reply_in_own_chat(
            acc, int(own_chat_id),
            reply_to_other=reply,
            id_format=id_format,
            acc_num=acc_num,
            msg_num=st["chat_msg_counter"],
        )
        if text:
            job.total_messages_sent += 1
            tag = "reply" if replied_user_id else "new"
            log_fn(f"📝 {phone} [#{acc_num:03d}] в свой чат ({tag}): {text[:30]}")

            # Дубль в ЛС после reply (если включено и был reply)
            if replied_user_id and cfg.get("duplicate_dm_after_reply", True) and random.random() < 0.4:
                # Найти phone получателя по user_id
                target_phone = None
                for pp, aa in ctx.accounts.items():
                    try:
                        cli = aa.client
                        if cli:
                            me = await cli.get_me()
                            if me and me.id == replied_user_id:
                                target_phone = pp
                                break
                    except Exception:
                        pass
                if target_phone:
                    target_acc = ctx.accounts.get(target_phone)
                    if target_acc:
                        try:
                            ent = await acc.client.get_input_entity(replied_user_id)
                            await asyncio.sleep(random.uniform(8, 20))
                            await acc.client.send_message(ent, text)
                            acc.touch()
                            job.total_messages_sent += 1
                            log_fn(f"📩 {phone} → {target_phone} в ЛС (дубль reply)")
                        except Exception as e:
                            logger.debug("[warmup] dup dm %s→%s: %s", phone, target_phone, e)
            return True
        return False

    if scenario == "own_channel":
        own_channel_id = (ctx.user_settings or {}).get("warmup_own_channel_id", 0)
        if not own_channel_id:
            return False
        from core.warmup_defaults import CHANNEL_POSTS, REACTION_EMOJIS
        from telethon.tl.functions.messages import SendReactionRequest
        from telethon.tl.types import ReactionEmoji

        phone = _pick_warmup_account(ctx, job)
        if not phone:
            return False
        acc = ctx.accounts.get(phone)
        if not acc or not await ensure_connected(acc):
            return False
        try:
            entity = await acc.client.get_input_entity(int(own_channel_id))
        except Exception as e:
            logger.debug("[warmup] own_channel entity %s: %s", phone, e)
            return False

        # Читаем последний пост канала
        last_posts = []
        try:
            async for m in acc.client.iter_messages(entity, limit=10):
                if m and m.id:
                    last_posts.append(m)
        except Exception:
            pass

        # 30% — пост, 70% — реакция (если есть на что реагировать)
        action = "post" if (random.random() < 0.3 or not last_posts) else "react"

        if action == "post":
            text = random.choice(CHANNEL_POSTS)
            try:
                await acc.client.send_message(entity, text)
                acc.touch()
                job.total_messages_sent += 1
                log_fn(f"📣 {phone} пост в свой канал: {text[:50]}...")
                return True
            except Exception as e:
                logger.debug("[warmup] own_channel post %s: %s", phone, e)
                return False
        else:
            # Выбираем не-свой пост (чужого автора)
            try:
                me = await acc.client.get_me()
                my_id = me.id if me else 0
            except Exception:
                my_id = 0
            candidates = [m for m in last_posts if not getattr(m, 'out', False)
                          and getattr(m, 'from_id', None)
                          and getattr(m.from_id, 'user_id', 0) != my_id]
            if not candidates:
                candidates = last_posts
            if not candidates:
                return False
            target = random.choice(candidates[:5])
            try:
                emoji = random.choice(REACTION_EMOJIS)
                await acc.client(SendReactionRequest(
                    peer=entity, msg_id=target.id,
                    reaction=[ReactionEmoji(emoticon=emoji)]
                ))
                job.total_messages_sent += 1
                log_fn(f"👍 {phone} реакция {emoji} на свой пост")
                return True
            except Exception as e:
                logger.debug("[warmup] own_channel react %s: %s", phone, e)
                return False

    # ── Сценарии дневников: пост в свой канал, подписка на чужой, реакция ──
    if scenario in ("diary_post", "diary_subscribe", "diary_react"):
        from core.warmup_diary import (
            post_diary_entry, share_diary_link_in_chat, subscribe_to_diary_via_link,
            react_on_recent_diary_post, get_post_quota_for_day, find_unsubscribed_diaries,
        )
        diaries = (ctx.user_settings or {}).get("warmup_diaries") or {}
        if not diaries:
            return False  # дневники ещё не созданы

        if scenario == "diary_post":
            # Выбираем акк у которого есть дневник И сегодня ещё есть квота
            phones_with_diary = [
                p for p in diaries.keys()
                if (acc := ctx.accounts.get(p)) and getattr(acc, "warmup", False)
                and not _is_confirmed_dead(acc) and not acc.frozen
            ]
            if not phones_with_diary:
                return False
            phone = random.choice(phones_with_diary)
            diary = diaries[phone]
            # Проверяем дневную квоту постов (сбрасываем счётчик при смене дня)
            day = max(1, int(getattr(job, "current_day", 1)))
            if int(diary.get("posts_today_day", 0)) != day:
                diary["posts_today"] = 0
                diary["posts_today_day"] = day
            quota = get_post_quota_for_day(day)
            posts_today = int(diary.get("posts_today", 0))
            if posts_today >= quota:
                return False
            acc = ctx.accounts.get(phone)
            if not acc or not await ensure_connected(acc):
                return False

            text = await post_diary_entry(acc.client, phone, diary)
            if text:
                diary["posts_today"] = posts_today + 1
                ctx.user_settings.setdefault("warmup_diaries", {})[phone] = diary
                # Шарим ссылку в общий чат акка (подгруппы) если это первые 3 поста +
                # чат есть + ещё не шарили. С subgroups берём чат конкретного акка.
                try:
                    from core.warmup_groups import get_own_chat_for_phone
                    own_chat = get_own_chat_for_phone(ctx.user_settings or {}, phone)
                    own_chat_id = int(own_chat["chat_id"]) if own_chat else 0
                except Exception:
                    own_chat_id = int((ctx.user_settings or {}).get("warmup_own_chat_id", 0) or 0)
                if own_chat_id and diary.get("post_idx", 0) <= 3 and not diary.get("link_shared"):
                    if await share_diary_link_in_chat(acc.client, phone, own_chat_id, diary.get("invite_link", "")):
                        diary["link_shared"] = True
                        ctx.user_settings["warmup_diaries"][phone] = diary
                        log_fn(f"🔗 {phone} запостил ссылку дневника в чат")
                job.total_messages_sent += 1
                log_fn(f"📝 {phone} пост в дневник: {text[:40]}")
                return True
            return False

        if scenario == "diary_subscribe":
            # Выбираем акк у которого есть дневник, и подписываемся на чужой
            phones_with_diary = [
                p for p in diaries.keys()
                if (acc := ctx.accounts.get(p)) and getattr(acc, "warmup", False)
                and not _is_confirmed_dead(acc) and not acc.frozen
            ]
            if not phones_with_diary:
                return False
            phone = random.choice(phones_with_diary)
            diary = diaries[phone]
            unsubscribed = find_unsubscribed_diaries(phone, diary, diaries)
            if not unsubscribed:
                return False
            target_phone, target_diary = random.choice(unsubscribed)
            acc = ctx.accounts.get(phone)
            if not acc or not await ensure_connected(acc):
                return False
            ok = await subscribe_to_diary_via_link(acc.client, phone, target_diary.get("invite_link", ""))
            if ok:
                subscribed_to = list(diary.get("subscribed_to") or [])
                if target_phone not in subscribed_to:
                    subscribed_to.append(target_phone)
                diary["subscribed_to"] = subscribed_to
                ctx.user_settings.setdefault("warmup_diaries", {})[phone] = diary
                log_fn(f"➕ {phone} подписался на дневник {target_phone}")
                return True
            return False

        if scenario == "diary_react":
            # Выбираем акк, и ставим реакцию на пост в одном из подписанных дневников
            phones_with_diary = [
                p for p in diaries.keys()
                if (acc := ctx.accounts.get(p)) and getattr(acc, "warmup", False)
                and not _is_confirmed_dead(acc) and not acc.frozen
            ]
            if not phones_with_diary:
                return False
            phone = random.choice(phones_with_diary)
            diary = diaries[phone]
            subscribed = list(diary.get("subscribed_to") or [])
            if not subscribed:
                return False
            target_phone = random.choice(subscribed)
            target_diary = diaries.get(target_phone)
            if not target_diary:
                return False
            acc = ctx.accounts.get(phone)
            if not acc or not await ensure_connected(acc):
                return False
            ok = await react_on_recent_diary_post(acc.client, phone, target_diary)
            if ok:
                job.total_messages_sent += 1
                log_fn(f"❤ {phone} реакция в дневнике {target_phone}")
                return True
            return False

    return False


async def _auto_move_to_working(ctx, log_fn):
    """Move warmup accounts to working folder."""
    try:
        from config import ACCOUNTS_DIR, USERS_DIR
        import shutil

        warmup_dir = ACCOUNTS_DIR / "Прогрев"
        moved = 0
        for phone, acc in list(ctx.accounts.items()):
            if getattr(acc, "folder_name", "") in ("Прогрев", "Warmup"):
                if acc.session_path and acc.session_path.exists():
                    dest = USERS_DIR / acc.session_path.name
                    try:
                        shutil.move(str(acc.session_path), str(dest))
                        # Move JSON too
                        if acc.json_path and acc.json_path.exists():
                            shutil.move(str(acc.json_path), str(USERS_DIR / acc.json_path.name))
                        acc.folder_name = USERS_DIR.name
                        acc.session_path = dest
                        acc.warmup = False
                        moved += 1
                    except Exception as e:
                        logger.warning("[warmup] Move %s failed: %s", phone, e)

        if moved:
            log_fn(f"✅ Перемещено {moved} аккаунтов в '{USERS_DIR.name}'")
    except Exception as e:
        logger.warning("[warmup] auto_move error: %s", e)
