"""
web/app.py — Flask web application for TelePush.
Авторизация по лицензионному ключу, все API endpoints.
"""
import os
import sys
import time
import json
import secrets
import logging
import asyncio
import collections
import re
import threading
from pathlib import Path
from datetime import timedelta, datetime
from functools import wraps
from typing import Dict, Optional

from flask import (
    Flask, request, session, jsonify, redirect,
    url_for, render_template, Response, make_response,
)

from config import (
    TELEPUSH_VERSION, TELEPUSH_SERVER_URL, WEB_SESSION_HOURS, WEB_MAX_UPLOAD_MB,
    WEB_ASYNC_TIMEOUT, API_RATE_LIMIT_PER_MIN, DATA_DIR, EXPORT_DIR, IS_TEMP_LAUNCH,
)

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

# ── Flask App ────────────────────────────────────────────────────────────────

app = Flask(
    __name__,
    template_folder=str(Path(__file__).parent / "templates"),
    static_folder=str(Path(__file__).parent / "static"),
)

# Persistent secret key — от него зависит валидность куки сессии.
# Если файл пропадёт между запусками, все сессии сбрасываются, и клиент видит
# форму логина. Файл лежит в DATA_DIR и НЕ трогается апдейтером (см. SKIP_FILES).
_secret_file = DATA_DIR / ".flask_secret"
_secret = ""
_secret_existed = False
if _secret_file.exists():
    _secret = _secret_file.read_text().strip()
    _secret_existed = True
if not _secret:
    _secret = secrets.token_hex(32)
    _secret_file.parent.mkdir(parents=True, exist_ok=True)
    _secret_file.write_text(_secret)
app.secret_key = _secret
# Диагностика: лог первых 8 символов хеша секрета, чтобы видеть меняется ли
# он между рестартами. Сам секрет в лог не пишем.
try:
    import hashlib as _hl
    _sec_fp = _hl.sha256(_secret.encode()).hexdigest()[:8]
    logger.info("[auth] flask_secret fp=%s existed=%s license.json=%s",
                _sec_fp, _secret_existed,
                (DATA_DIR / "license.json").exists())
except Exception:
    pass
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=WEB_SESSION_HOURS)
app.config["TEMPLATES_AUTO_RELOAD"] = True
app.config["MAX_CONTENT_LENGTH"] = WEB_MAX_UPLOAD_MB * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"


# ── Маркеры версии для лаунчера ────────────────────────────────────────────
# PyInstaller вшивает main.py внутрь exe, поэтому код из main.py не обновляется
# при hot-update через ZIP (у клиента остаётся тот main.py который был на момент
# сборки exe). А web/app.py — обычный модуль в _internal/, обновляется каждым
# апдейтом. Поэтому «stamp» делаем именно здесь — при импорте модуля (Flask старт).
#
# Что чиним: лаунчер читает .tp_ver для определения установленной версии.
# save_ver() у клиентов часто молча падает (антивирусы блокируют dot-файлы в
# Downloads/, Controlled Folder Access), и в .tp_ver остаётся «1.1.0» (версия
# лаунчера ещё от первой установки). Каждый запуск лаунчер видит 1.1.0 < X,
# считает «надо обновлять», качает 61 МБ. После установки программа пишет актуальную
# версию отсюда — со снятием read-only и ретраями.
def _stamp_version_markers_on_import():
    try:
        import stat as _st
        if getattr(sys, 'frozen', False):
            _app_dir = Path(sys.executable).parent
        else:
            _app_dir = Path(__file__).parent.parent
        _root = _app_dir.parent if _app_dir.name.lower() == "app" else _app_dir
        _targets = [
            _root / ".tp_ver",
            _root / ".version",
            _app_dir / ".version",
            _app_dir / "_internal" / ".version",
        ]
        _written = 0
        _failed = []
        for _t in _targets:
            try:
                _t.parent.mkdir(parents=True, exist_ok=True)
                # Если уже содержит актуальную версию — OK
                try:
                    if _t.exists() and _t.read_text(encoding="utf-8").strip() == TELEPUSH_VERSION:
                        _written += 1
                        continue
                except Exception:
                    pass
                # Снимаем read-only
                if _t.exists():
                    try: _t.chmod(_t.stat().st_mode | _st.S_IWRITE)
                    except Exception: pass
                # Пробуем перезаписать
                try:
                    _t.write_text(TELEPUSH_VERSION, encoding="utf-8")
                    _written += 1
                    continue
                except Exception as _we:
                    # Запись упала (антивирус / Controlled Folder Access).
                    # Попытка 2: удалить файл и создать заново (delete+create иногда
                    # работает когда modify запрещен).
                    try:
                        _t.unlink(missing_ok=True)
                        _t.write_text(TELEPUSH_VERSION, encoding="utf-8")
                        _written += 1
                        logger.info("[version-stamp] %s: write упал, сработал delete+create", _t.name)
                        continue
                    except Exception as _we2:
                        # Попытка 3 (критично для .tp_ver!): если совсем никак,
                        # УДАЛЯЕМ старый файл чтобы лаунчер пошёл читать следующий
                        # в его fallback-цепочке (BASE/.version, APP_DIR/.version).
                        # Это РЕГУЛЯРНАЯ ситуация для папок Downloads/Controlled
                        # Folder Access, не warning — info.
                        try:
                            _t.unlink(missing_ok=True)
                            logger.info("[version-stamp] %s: write read-only, fallback на следующий маркер", _t.name)
                        except Exception as _ue:
                            _failed.append(f"{_t.name}: {_we2} / unlink: {_ue}")
            except Exception as _ve:
                _failed.append(f"{_t.name}: {_ve}")
        if _failed:
            # Полный отказ во ВСЕХ маркерах — действительно проблемно, остаётся warning.
            if _written == 0:
                logger.warning("[version-stamp] не записано ни одного маркера: %s", "; ".join(_failed))
            else:
                logger.debug("[version-stamp] частично записано %d/%d (skipped: %s)",
                             _written, len(_targets), "; ".join(_failed))
        else:
            logger.info("[version-stamp] записана версия %s в %d/%d маркеров",
                        TELEPUSH_VERSION, _written, len(_targets))
    except Exception as _me:
        logger.debug("[version-stamp] общий фейл: %s", _me)


_stamp_version_markers_on_import()


# ── Decorators ───────────────────────────────────────────────────────────────

def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not session.get("logged_in"):
            # API endpoints return JSON error, pages redirect
            if request.path.startswith("/api/"):
                return jsonify({"error": "unauthorized"}), 401
            return redirect(url_for("login"))
        return f(*args, **kwargs)
    return decorated


# ── Rate limiter ─────────────────────────────────────────────────────────────

_rate_buckets = collections.defaultdict(list)

@app.errorhandler(Exception)
def _catch_all_exceptions(err):
    """Глобальный перехват — логирует traceback на сервер, клиенту 500."""
    import traceback as _tb
    from werkzeug.exceptions import HTTPException
    if isinstance(err, HTTPException):
        return err
    try:
        logger.error("Unhandled exception at %s:\n%s", request.path, _tb.format_exc())
    except Exception:
        pass
    if request.path.startswith("/api/"):
        return jsonify({"error": "Внутренняя ошибка сервера", "detail": str(err)[:200]}), 500
    return "Internal error", 500


@app.before_request
def _rate_limit():
    if request.method == "POST" and request.path.startswith("/api/"):
        uid = session.get("uid", request.remote_addr)
        now = time.time()
        bucket = _rate_buckets[uid]
        _rate_buckets[uid] = [t for t in bucket if now - t < 60]
        if len(_rate_buckets[uid]) >= API_RATE_LIMIT_PER_MIN:
            return jsonify({"error": "Too many requests"}), 429
        _rate_buckets[uid].append(now)


@app.before_request
def _access_check():
    """Block access by expired license OR module restrictions."""
    if not session.get("logged_in"):
        return

    # Always allowed paths
    always_ok = ("/api/status", "/api/profile", "/api/settings", "/api/logs",
                 "/api/renew", "/api/update", "/api/ticket", "/logout", "/login", "/static",
                 "/api/accounts")
    for prefix in always_ok:
        if request.path.startswith(prefix):
            return
    if request.path == "/" or request.path == "/panel":
        return

    # Expired check
    if session.get("license_expired"):
        if request.method == "POST" and request.path.startswith("/api/"):
            return jsonify({"error": "Лицензия истекла. Продлите ключ для продолжения работы.", "expired": True}), 403
        return

    # Module access check
    if request.path.startswith("/api/"):
        allowed_modules = session.get("allowed_modules", [])
        if not allowed_modules:
            return  # Нет ограничений (старый ключ / pro)

        # Маппинг path → module
        path_module_map = {
            "/api/spam/": "spam", "/api/dm/": "dm", "/api/join/": "join",
            "/api/multi/": "multi", "/api/proxy/": "proxy",
            "/api/parser/": "parser", "/api/warmup/": "warmup",
            "/api/profile/update": "profiles", "/api/profile/avatar": "profiles",
            "/api/profile/fill": "profiles", "/api/profile/generate": "profiles",
            "/api/autoresponder/": "autoreply", "/api/templates/": "templates",
            "/api/analytics/": "analytics",
            "/api/tg/send": "tg_messenger", "/api/tg/edit": "tg_messenger",
            "/api/tg/delete": "tg_messenger", "/api/tg/forward": "tg_messenger",
            "/api/tg/send_file": "tg_messenger",
            "/api/reauth/": "reauth",
        }
        for prefix, module in path_module_map.items():
            if request.path.startswith(prefix):
                if module not in allowed_modules:
                    return jsonify({
                        "error": "Модуль недоступен в вашем тарифе",
                        "forbidden_module": module,
                    }), 403
                break


# ── Helpers ──────────────────────────────────────────────────────────────────

def _get_state():
    import state
    return state

def _get_ctx():
    """Возвращает OwnerCtx текущего юзера."""
    uid = session.get("uid")
    if not uid:
        return None
    st = _get_state()
    if not st:
        return None
    with st.owners_lock:
        ctx = st.owners.get(uid)
        if ctx is None:
            from models import OwnerCtx
            ctx = OwnerCtx()
            # Restore saved settings from disk — КРИТИЧНО:
            # если load упадёт, оставляем флаг settings_loaded=False чтобы
            # save не перетёр файл дефолтами
            ctx.temp["_settings_loaded_ok"] = False
            ctx.temp["_settings_file_existed"] = _settings_file().exists()
            try:
                saved = _load_user_settings()
                if saved:
                    ctx.user_settings.update(saved)
                    ctx.temp["_settings_loaded_ok"] = True
                    # Явное сообщение клиенту чтобы он видел в консоли что настройки подхватились
                    _sp = ctx.user_settings
                    logger.debug(
                        "\u2705 \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0437\u0430\u0433\u0440\u0443\u0436\u0435\u043d\u044b \u2014 "
                        "\u0442\u0430\u0439\u043c\u0438\u043d\u0433\u0438 \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438 %s-%s \u0441\u0435\u043a, "
                        "DM %s-%s \u0441\u0435\u043a (\u0432\u0441\u0435\u0433\u043e %d \u043a\u043b\u044e\u0447\u0435\u0439)",
                        int(_sp.get('spam_min_delay', 6)), int(_sp.get('spam_max_delay', 14)),
                        int(_sp.get('dm_min_delay', 35)), int(_sp.get('dm_max_delay', 90)),
                        len(saved),
                    )
                else:
                    # Файла нет или пустой — норма для первого запуска.
                    # Разрешаем save чтобы создать файл.
                    ctx.temp["_settings_loaded_ok"] = True
                    if ctx.temp["_settings_file_existed"]:
                        logger.warning(
                            "\u26a0 \u0424\u0430\u0439\u043b \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442 \u043d\u043e \u043f\u0443\u0441\u0442\u043e\u0439 (%s) \u2014 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u044b \u0434\u0435\u0444\u043e\u043b\u0442\u044b 6-14\u0441. "
                            "\u0417\u0430\u0434\u0430\u0439 \u0442\u0430\u0439\u043c\u0438\u043d\u0433\u0438 \u0432 \u0420\u0430\u0437\u0434\u0435\u043b\u0435 \u00ab\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u00bb \u0437\u0430\u043d\u043e\u0432\u043e \u0438 \u043d\u0430\u0436\u043c\u0438 \u00ab\u0421\u043e\u0445\u0440\u0430\u043d\u0438\u0442\u044c\u00bb.",
                            _settings_file(),
                        )
                    else:
                        logger.info(
                            "\u2139 \u0424\u0430\u0439\u043b \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d (\u043f\u0435\u0440\u0432\u044b\u0439 \u0437\u0430\u043f\u0443\u0441\u043a?) \u2014 "
                            "\u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u044b \u0434\u0435\u0444\u043e\u043b\u0442\u043d\u044b\u0435 \u0442\u0430\u0439\u043c\u0438\u043d\u0433\u0438 6-14\u0441 \u0434\u043b\u044f \u0440\u0430\u0441\u0441\u044b\u043b\u043a\u0438. "
                            "\u0415\u0441\u043b\u0438 \u0445\u043e\u0447\u0435\u0448\u044c \u0434\u0440\u0443\u0433\u0438\u0435 \u2014 \u043e\u0442\u043a\u0440\u043e\u0439 \u00ab\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u00bb."
                        )
            except Exception as e:
                # ЛОАДЕР УПАЛ — НЕ ДАЁМ перетирать файл дефолтами
                logger.error(
                    "\u274c \u041e\u0428\u0418\u0411\u041a\u0410 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043a (%s) \u2014 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0435 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u043e \u0434\u043e \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0430",
                    e,
                )
            st.owners[uid] = ctx
        return ctx

def _set_job_auth_error(job, acc, kind: str):
    """Если ensure_connected вернул False — показываем юзеру в UI конкретную причину.
    Иначе он видит 0/N «Стоп» и думает что прога сломалась."""
    if getattr(acc, "dead", False):
        job.current_step = "❌ Аккаунт мёртвый (сессия отозвана). Пересоздайте сессию."
        logger.warning("[%s] %s — dead, задача не запущена", kind, acc.phone)
    elif getattr(acc, "frozen", False):
        job.current_step = "❄️ Аккаунт заморожен Telegram. Замените аккаунт."
        logger.warning("[%s] %s — frozen, задача не запущена", kind, acc.phone)
    else:
        unauth = getattr(acc, "unauth_count", 0)
        job.current_step = (
            f"❗ Аккаунт не авторизован ({unauth}/5). "
            "Проверьте прокси или пересоздайте сессию."
        )
        logger.warning(
            "[%s] %s — не авторизован (%d/5), задача не запущена. "
            "Проверь прокси или пересоздай сессию.",
            kind, acc.phone, unauth,
        )


_BUSY_STALE_SEC = 30 * 60  # job без UI-апдейта 30 мин считаем зависшим/завершённым


def _job_alive(job) -> bool:
    """Job реально работает, а не висит после краша/паузы.
    paused → акк фактически свободен (юзер забыл нажать stop, аккаунт не занят).
    running без UI-апдейта 30+ мин → зависший корутин, тоже не считаем."""
    if not job or getattr(job, "status", None) != "running":
        return False
    last = getattr(job, "ui_last_update_at", 0) or getattr(job, "started_at", 0) or 0
    if last and (time.time() - last) > _BUSY_STALE_SEC:
        return False
    return True


def _get_account_busy(ctx, phone):
    """Определяет занят ли аккаунт и в каком модуле."""
    # Single jobs
    if _job_alive(ctx.job) and ctx.job.acc.phone == phone:
        return "Подписка"
    if _job_alive(ctx.spam_job) and ctx.spam_job.acc.phone == phone:
        return "Рассылка"
    usj = getattr(ctx, "user_spam_job", None)
    if _job_alive(usj) and usj.acc.phone == phone:
        return "DM"
    if getattr(ctx, "warmup_job", None) and ctx.warmup_job.status == "running":
        acc = ctx.accounts.get(phone)
        if acc and getattr(acc, "warmup", False):
            return "Прогрев"
    # Multi jobs
    multi_jobs = ctx.temp.get("multi_jobs") or []
    for mj in multi_jobs:
        if not _job_alive(mj) or mj.acc.phone != phone:
            continue
        mtype = getattr(mj, "type", "spam")
        if mtype == "join":
            return "Мульти-подписка"
        elif mtype == "dm":
            return "Мульти-DM"
        return "Мульти-рассылка"
    return None


_cached_lm = None
def _get_license_manager():
    global _cached_lm
    if _cached_lm is None:
        from utils.license import LicenseManager
        _cached_lm = LicenseManager()
    return _cached_lm


_AUTOLOAD_TRIGGERED = False
_AUTOLOAD_LOCK = threading.Lock()


def _trigger_autoload_if_needed(reason: str):
    """Догоняем autoload если main.py пропустил его (license была invalid при
    старте). Запускаем один раз за процесс — повторно не дёргаем чтобы не
    спорить с асинхронным main-loop. Если ctx уже наполнен или диск пустой —
    тоже выходим без шума."""
    global _AUTOLOAD_TRIGGERED
    with _AUTOLOAD_LOCK:
        if _AUTOLOAD_TRIGGERED:
            return
        # Если ctx уже наполнен — autoload уже был, не повторяем
        try:
            st = _get_state()
            if st and any(c.accounts for c in (st.owners or {}).values()):
                _AUTOLOAD_TRIGGERED = True
                return
        except Exception:
            pass
        # Проверяем что на диске реально есть session-файлы — иначе нет смысла
        try:
            from config import ACCOUNTS_DIR as _AD
            from config import USERS_DIR as _UD
            _has = False
            for _root in (_AD, _UD):
                if not _root or not _root.exists():
                    continue
                for _p in _root.rglob("*.session"):
                    if _p.is_file():
                        _has = True
                        break
                if _has:
                    break
            if not _has:
                _AUTOLOAD_TRIGGERED = True
                return
        except Exception:
            pass
        _AUTOLOAD_TRIGGERED = True
    # Запускаем в event_loop фоном — не блокируем HTTP-запрос
    async def _run():
        try:
            from core.scheduler import autoload_all_users
            await autoload_all_users()
            logger.info("[autoload] dogonjano (%s) — аккаунты подняты", reason)
        except Exception as e:
            logger.warning("[autoload] dogonjano (%s) failed: %s", reason, e)
    try:
        st = _get_state()
        loop = getattr(st, "event_loop", None) if st else None
        if loop and not loop.is_closed():
            asyncio.run_coroutine_threadsafe(_run(), loop)
            logger.info("[autoload] триггер из %s — запускаю догон", reason)
    except Exception as e:
        logger.debug("[autoload] schedule error: %s", e)


def _run_async(coro, timeout=None):
    """Выполняет coroutine в event loop из Flask-потока."""
    if timeout is None:
        timeout = WEB_ASYNC_TIMEOUT
    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if not loop or loop.is_closed():
        return None
    future = asyncio.run_coroutine_threadsafe(coro, loop)
    try:
        return future.result(timeout=timeout)
    except (TimeoutError, asyncio.TimeoutError):
        # cancel() шлёт запрос в loop — корутина получит CancelledError при следующем await
        future.cancel()
        logger.warning("[_run_async] timeout %.1fs, coroutine cancelled", timeout)
        return None
    except Exception as e:
        logger.warning("[_run_async] error: %s", e)
        return None


async def _build_client_for_phone(uid, phone, require_proxy=True):
    """Обёртка: создаёт клиент с правильными session_path, dev, proxy.

    require_proxy=True (default): если у клиента в настройках есть хоть один
    прокси, но все мёртвые — отказываемся подключаться напрямую. Клиент
    жаловался: добавлял аккаунт по номеру, прокси были «мёртвые», и Telegram
    прислал уведомление о входе с IP Польши (реальный IP сервера), а не с
    прокси. Теперь в такой ситуации сразу возвращаем ошибку."""
    from core.client_builder import build_client, load_device, load_proxy
    from config import USERS_DIR

    phone_clean = str(phone).lstrip("+").strip()
    USERS_DIR.mkdir(parents=True, exist_ok=True)
    session_path = USERS_DIR / f"{phone_clean}"

    dev = load_device(phone_clean)
    proxy = load_proxy()

    if require_proxy and proxy is None:
        # Проверяем: если в списке прокси что-то есть — значит клиент хочет через
        # прокси, но все мёртвые. Бросаем ошибку чтобы не было неожиданного
        # direct-подключения.
        try:
            _plist = _load_proxies()
            if _plist:
                alive = sum(1 for p in _plist if p.get("alive"))
                total = len(_plist)
                raise RuntimeError(
                    f"Прокси не работает: {alive}/{total} живых. "
                    "Добавь рабочий прокси или нажми «Проверить все» в настройках."
                )
        except RuntimeError:
            raise
        except Exception:
            pass

    client = await build_client(session_path, dev, proxy, phone=phone_clean)
    return client


# ── Auth ─────────────────────────────────────────────────────────────────────

@app.route("/login")
def login():
    if session.get("logged_in"):
        return redirect(url_for("panel"))
    # Авто-логин. После первой активации ключ+HWID уже в license.json.
    # Не делаем блокирующий lm.check() — сетевой вызов может подвесить/упасть
    # после рестарта и клиент увидит форму логина, хотя ключ валиден.
    # Вместо этого поднимаем сессию из кэшированных данных, а свежую
    # проверку делаем в фоне (обновит expires/modules для следующего входа).
    lm = _get_license_manager()
    if lm.is_valid():
        key = lm.get_key()
        cached = lm._state or {}
        session["logged_in"] = True
        session["uid"] = lm.get_uid()
        session["license_key"] = key
        session["license_expires"] = cached.get("expires", "")
        session["key_type"] = cached.get("key_type", "pro")
        session["allowed_modules"] = cached.get("modules", [])
        session["allowed_features"] = cached.get("features", {})
        session.pop("license_expired", None)
        session.pop("license_expired_reason", None)
        session["_lic_check"] = time.time()
        session.permanent = True
        logger.debug("Auto-login (cached): %s...", key[:16])

        # Фоновая валидация — если сервер скажет invalid, сессия слетит
        # на следующем запросе через _refresh_license_background.
        def _bg_check(_k=key):
            try:
                lm.check(_k)
            except Exception as _e:
                logger.debug("BG license check failed: %s", _e)
        threading.Thread(target=_bg_check, daemon=True).start()

        # Если main.py не успел поднять акки (license при старте была невалидна
        # или проверка зависла) — догоняем здесь. Проверяем что ctx пустой и на
        # диске реально есть .session, иначе ничего не делаем.
        _trigger_autoload_if_needed("auto-login")

        return redirect(url_for("panel"))
    return render_template("login.html")


@app.route("/auth/license", methods=["POST"])
def auth_license():
    data = request.json or {}
    key = data.get("key", "").strip()
    if not key:
        return jsonify({"ok": False, "error": "Введите ключ"}), 400

    from utils.license import LicenseManager
    lm = LicenseManager()
    result = lm.activate(key)

    if result.get("valid"):
        uid = lm.get_uid()
        session["logged_in"] = True
        session["uid"] = uid
        session["license_key"] = key
        session["license_expires"] = result.get("expires", "")
        session["key_type"] = result.get("key_type", "pro")
        session["allowed_modules"] = result.get("modules", [])
        session["allowed_features"] = result.get("features", {})
        session.pop("license_expired", None)
        session.pop("license_expired_reason", None)
        session["_lic_check"] = time.time()
        session.permanent = True
        # При первой активации main.py уже прошёл проверку license и пропустил
        # autoload (was invalid). Догоняем здесь — иначе клиент увидит панель
        # с 0 аккаунтов и подумает что всё сломалось.
        _trigger_autoload_if_needed("post-activation")
        return jsonify({"ok": True, "redirect": url_for("panel")})
    return jsonify({"ok": False, "error": result.get("error", "Неверный ключ")}), 403


def _cancel_all_jobs(ctx) -> int:
    """Помечает все активные джобы как cancelled. Возвращает количество."""
    if not ctx:
        return 0
    cancelled = 0
    job_attrs = ("job", "spam_job", "user_spam_job", "warmup_job")
    for attr in job_attrs:
        j = getattr(ctx, attr, None)
        if j and getattr(j, "status", None) in ("running", "paused"):
            try:
                j.status = "cancelled"
                if hasattr(j, "stop_flag"):
                    j.stop_flag = True
                if hasattr(j, "pause_event"):
                    try:
                        j.pause_event.set()
                    except Exception:
                        pass
                cancelled += 1
            except Exception:
                logger.debug("cancel %s failed", attr, exc_info=True)
    # Multi-jobs
    try:
        for mj in list(ctx.temp.get("multi_jobs") or []):
            if getattr(mj, "status", None) in ("running", "paused"):
                mj.status = "cancelled"
                if hasattr(mj, "stop_flag"):
                    mj.stop_flag = True
                cancelled += 1
    except Exception:
        pass
    return cancelled


@app.route("/logout")
def logout():
    uid = session.get("uid")
    if uid:
        st = _get_state()
        ctx = st.owners.get(uid) if st else None
        # Автосохранение настроек при выходе
        if ctx and ctx.user_settings:
            try:
                _save_user_settings(ctx.user_settings)
            except Exception as e:
                logger.debug("Logout settings save failed: %s", e)
        # Отмена активных джобов
        if ctx:
            n = _cancel_all_jobs(ctx)
            if n:
                logger.info("User uid=%s logout — %d jobs cancelled", uid, n)
    session.clear()
    return redirect(url_for("login"))


# ── License re-check middleware ──────────────────────────────────────────────

@app.before_request
def _license_recheck():
    if request.path in ("/login", "/auth/license", "/static"):
        return
    if request.path.startswith("/static/"):
        return
    if not session.get("logged_in"):
        return
    last = session.get("_lic_check", 0)
    # Проверяем КАЖДЫЕ 2 часа — независимо от текущего состояния expired.
    # Раньше если однажды была транзиентная ошибка (сервер недоступен, сеть
    # моргнула, HWID не матчился в моменте) — мы ставили expired=True и
    # никогда больше не проверяли. Клиент с ключом на 30 дней навсегда
    # получал «Срок лицензии истёк» в сессии. Теперь: если сервер сейчас
    # говорит valid — снимаем флаг. Если валидно — значит живое.
    if time.time() - last > 7200:  # 2 часа
        from utils.license import LicenseManager
        lm = LicenseManager()
        r = lm.check(session.get("license_key"))
        if r.get("valid"):
            # Реально валиден — снимаем ложный expired если он был
            if session.get("license_expired"):
                logger.info("[lic] Снимаем ложный «expired» — сервер подтвердил что ключ живой")
            session.pop("license_expired", None)
            session.pop("license_expired_reason", None)
        else:
            # Сервер сказал invalid. Различаем: «реально истёк» vs «временный сбой».
            # Если ошибка сетевая — не блокируем (grace period уже отработал в lm.check).
            _err = (r.get("error") or "").lower()
            _transient = any(k in _err for k in ("недоступ", "timeout", "сеть", "connection"))
            if _transient and session.get("_lic_check"):
                # Был валиден раньше + сейчас сервер недоступен — оставляем как было,
                # не ставим expired. Клиент продолжит работать.
                logger.info("[lic] Транзиентная ошибка «%s» — не блокируем", _err[:80])
            else:
                uid = session.get("uid")
                if uid and not session.get("license_expired"):
                    st = _get_state()
                    ctx = st.owners.get(uid) if st else None
                    if ctx:
                        n = _cancel_all_jobs(ctx)
                        if n:
                            logger.warning("License expired uid=%s — %d jobs cancelled", uid, n)
                session["license_expired"] = True
                session["license_expired_reason"] = r.get("error", "License expired")
        session["_lic_check"] = time.time()


# ── Pages ────────────────────────────────────────────────────────────────────

@app.route("/favicon.ico")
def favicon():
    return app.send_static_file("favicon.ico")


@app.route("/")
@login_required
def index():
    return redirect(url_for("panel"))


@app.route("/panel")
@login_required
def panel():
    resp = make_response(render_template("panel.html", config={"TELEPUSH_VERSION": TELEPUSH_VERSION}))
    # HTML с панелью никогда не кешируем — иначе после обновления WebView2 может
    # показать старый HTML со старыми ссылками на style.css/app.js (без cache-buster).
    resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
    resp.headers["Pragma"] = "no-cache"
    return resp


# ── Telegram Web (отдельное окно) ────────────────────────────────────────────

@app.route("/tg-web")
@login_required
def tg_web():
    """Полноэкранный Telegram-клиент. Открывается в отдельном pywebview-окне
    через /api/tg/open_window. Использует ctx.active как акк, поэтому перед
    открытием API сетит правильный phone."""
    ctx = _get_ctx()
    phone = ctx.active if ctx else ""
    return render_template("tg_web.html", phone=phone)


@app.route("/api/tg/open_window", methods=["POST"])
@login_required
def api_tg_open_window():
    """Открывает /tg-web в отдельном pywebview-окне на этом же localhost. Если
    pywebview недоступен (запуск без gui) — отдаём URL, чтобы фронт мог открыть
    через window.open() как fallback."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phone = (data.get("phone") or "").strip()
    if phone and phone in ctx.accounts:
        ctx.active = phone

    st = _get_state()
    port = getattr(st, "web_port", None) if st else None
    if not port:
        try:
            from config import WEB_PORT as _wp
            port = _wp
        except Exception:
            port = 8090
    target_url = f"http://localhost:{port}/tg-web"

    # Пытаемся открыть как pywebview-окно. Если pywebview не запущен (например,
    # клиент юзает софт через браузер), вернём URL и фронт откроет window.open.
    opened = False
    try:
        import webview
        if getattr(webview, "windows", None):
            try:
                webview.create_window(
                    f"TelePush — +{ctx.active}",
                    target_url,
                    width=1200,
                    height=820,
                    min_size=(900, 600),
                    resizable=True,
                    background_color="#14141e",
                )
                opened = True
                logger.info("[tg-web] открыто отдельное окно для +%s", ctx.active)
            except Exception as e:
                logger.warning("[tg-web] create_window упал: %s — fallback на window.open", e)
    except ImportError:
        pass

    return jsonify({"ok": True, "url": target_url, "opened": opened, "phone": ctx.active})


# ── API: Status ──────────────────────────────────────────────────────────────

@app.route("/api/onboarding/mark_done", methods=["POST"])
@login_required
def api_onboarding_mark_done():
    try:
        DATA_DIR.mkdir(parents=True, exist_ok=True)
        (DATA_DIR / "onboarding_done.flag").write_text("1", encoding="utf-8")
    except Exception as e:
        logger.debug("[onboarding] cannot write flag: %s", e)
    return jsonify({"ok": True})


@app.route("/api/status")
@login_required
def api_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})
    # Догон autoload: фронт дёргает /api/status каждые пару секунд, удобно
    # ловить тут случай «акки на диске есть, а в ctx 0» (main.py пропустил
    # autoload из-за невалидной license). Триггер срабатывает один раз за процесс.
    if not ctx.accounts:
        _trigger_autoload_if_needed("api/status")
    result = {
        "ok": True,
        "active": ctx.active,
        "accounts_count": len(ctx.accounts),
    }
    # Critical: клиент запустил exe из архива без распаковки (WinRAR/7z temp).
    # Все данные (аккаунты, настройки) лягут в temp и удалятся при закрытии архива.
    if IS_TEMP_LAUNCH:
        result["temp_launch_warning"] = True
        try:
            from config import _CLIENT_ROOT as _cr
            result["temp_launch_path"] = str(_cr)
        except Exception:
            pass
    if session.get("license_expired"):
        result["license_expired"] = True
        result["expired_reason"] = session.get("license_expired_reason", "")
    result["key_type"] = session.get("key_type", "pro")
    result["allowed_modules"] = session.get("allowed_modules", [])
    result["allowed_features"] = session.get("allowed_features", {})
    # Флаг «онбординг уже показывался» — хранится на диске чтобы не зависеть
    # от webview-localStorage (который может чиститься в private_mode).
    try:
        _onb_flag = DATA_DIR / "onboarding_done.flag"
        result["onboarding_done"] = _onb_flag.exists()
    except Exception:
        result["onboarding_done"] = False
    # Проверяем обновления (кеш 60 сек чтобы не спамить сервер)
    try:
        _upd_cache = getattr(app, '_update_cache', None)
        _upd_ts = getattr(app, '_update_cache_ts', 0)
        if not _upd_cache or (time.time() - _upd_ts) > 60:
            from utils.updater import check_update
            _upd_cache = check_update()
            app._update_cache = _upd_cache
            app._update_cache_ts = time.time()
        if _upd_cache.get("available"):
            result["update_available"] = True
            result["update_version"] = _upd_cache.get("version", "")
            result["update_changelog"] = _upd_cache.get("changelog", "")
    except Exception:
        pass
    # Jobs
    if ctx.job:
        j = ctx.job
        result["join_job"] = {
            "status": j.status, "phone": j.acc.phone,
            "processed": j.processed, "total": j.total,
            "pct": int(j.processed / max(j.total, 1) * 100),
            "current_step": j.current_step,
            "counts": dict(j.counts),
        }
    if ctx.spam_job:
        s = ctx.spam_job
        result["spam_job"] = {
            "status": s.status, "phone": s.acc.phone,
            "processed": s.processed, "total": s.total,
            "pct": int(s.processed / max(s.total, 1) * 100),
            "current_step": s.current_step,
            "counts": dict(s.counts),
        }
    if getattr(ctx, "user_spam_job", None):
        s = ctx.user_spam_job
        result["user_spam_job"] = {
            "status": s.status, "phone": s.acc.phone,
            "processed": s.processed, "total": s.total,
            "pct": int(s.processed / max(s.total, 1) * 100),
            "current_step": s.current_step,
            "counts": dict(s.counts),
        }
    # Warmup job
    if getattr(ctx, "warmup_job", None):
        w = ctx.warmup_job
        from core.warmup import STAGES
        sched = w.schedule
        result["warmup_job"] = {
            "status": w.status,
            "day": sched.current_day,
            "total_days": sched.total_days,
            "stage": sched.stage,
            "stage_label": STAGES.get(sched.stage, {}).get("label", ""),
            "stage_icon": STAGES.get(sched.stage, {}).get("icon", ""),
            "pairs_count": len(w.pairs),
            "total_messages": w.total_messages_sent,
            "messages_today": w.messages_today,
            "msgs_limit": sched.msgs_per_day,
            "peers_limit": sched.max_peers_today,
            "pct": int(sched.current_day / max(sched.total_days, 1) * 100),
        }
    # Multi jobs
    multi_jobs = getattr(ctx, "temp", {}).get("multi_jobs", [])
    result["multi_jobs"] = []
    for mj in multi_jobs:
        if mj.status in ("finished", "stopped") and (time.time() - mj.started_at) > 300:
            continue
        result["multi_jobs"].append({
            "type": getattr(mj, "type", "spam"),
            "phone": mj.acc.phone,
            "status": mj.status,
            "processed": mj.processed,
            "total": mj.total,
            "pct": int(mj.processed / max(mj.total, 1) * 100),
            "current_step": mj.current_step,
            "counts": dict(mj.counts) if isinstance(getattr(mj, "counts", None), dict) else {},
        })
    # Парсер — тоже активная задача, показываем на главной
    parser = (getattr(ctx, "temp", {}) or {}).get("parser") or {}
    if parser.get("status") == "running":
        result["parser_job"] = {
            "status": "running",
            "mode": parser.get("mode", "parser"),
            "found": parser.get("progress", 0),
            "scanned": parser.get("scanned", 0),
            "elapsed": int(time.time() - parser.get("started_at", time.time())),
            "status_text": parser.get("status_text", ""),
        }
    return jsonify(result)


# ── API: Dashboard ──────────────────────────────────────────────────────────

_APP_START_TS = time.time()


@app.route("/api/dashboard")
@login_required
def api_dashboard():
    """Сводка для вкладки «Главная»: аккаунты, задачи, сегодня, события, здоровье."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})

    # Аккаунты по статусам
    acc_total = len(ctx.accounts)
    acc_ok = acc_fw = acc_dead = 0
    for acc in ctx.accounts.values():
        try:
            if getattr(acc, "dead", False) or getattr(acc, "frozen", False):
                acc_dead += 1
            elif getattr(acc, "floodwait_until", 0) > time.time() or getattr(acc, "quota_paused_until", 0) > time.time():
                acc_fw += 1
            else:
                acc_ok += 1
        except Exception:
            pass

    # Задачи по типам
    jobs = {"total": 0, "spam": 0, "dm": 0, "join": 0, "monitor": 0, "warmup": 0}
    try:
        if ctx.job and getattr(ctx.job, "status", "") == "running":
            jobs["join"] += 1; jobs["total"] += 1
        if ctx.spam_job and ctx.spam_job.status == "running":
            jobs["spam"] += 1; jobs["total"] += 1
        _usj = getattr(ctx, "user_spam_job", None)
        if _usj and _usj.status == "running":
            jobs["dm"] += 1; jobs["total"] += 1
        _wj = getattr(ctx, "warmup_job", None)
        if _wj and _wj.status == "running":
            jobs["warmup"] += 1; jobs["total"] += 1
        _mjs = getattr(ctx, "monitor_jobs", None) or {}
        for mj in _mjs.values():
            if getattr(mj, "status", "") == "running":
                jobs["monitor"] += 1; jobs["total"] += 1
    except Exception:
        pass

    # Сегодня (по history.sqlite, все runs с started_at >= 00:00)
    today = {"joins": 0, "chat_sends": 0, "dm_sends": 0}
    try:
        import sqlite3 as _s
        from core.history import _HIST_PATH
        from datetime import datetime as _dt
        _midnight = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp()
        if _HIST_PATH.exists():
            _c = _s.connect(str(_HIST_PATH), timeout=5)
            _c.row_factory = _s.Row
            rows = _c.execute(
                "SELECT kind, counts_json FROM runs WHERE started_at >= ?", (_midnight,)
            ).fetchall()
            for r in rows:
                try:
                    counts = json.loads(r["counts_json"] or "{}")
                except Exception:
                    counts = {}
                if r["kind"] == "join":
                    today["joins"] += int(counts.get("joined", 0) or 0)
                elif r["kind"] == "spam":
                    today["chat_sends"] += int(counts.get("sent", counts.get("ok", 0)) or 0)
                elif r["kind"] == "dm":
                    today["dm_sends"] += int(counts.get("sent", counts.get("ok", 0)) or 0)
            _c.close()
    except Exception as e:
        logger.debug("dashboard today: %s", e)

    # Последние события из UI-лог-буфера
    events = []
    try:
        _buf = getattr(app, "_log_buffer", None)
        if _buf is not None:
            import time as _t
            for l in list(_buf)[-10:]:
                events.append({
                    "time": _t.strftime("%H:%M:%S"),
                    "level": l.get("level", "INFO"),
                    "message": (l.get("message", "") or "")[:180],
                })
    except Exception:
        pass
    events.reverse()

    # Здоровье
    from config import TELEPUSH_VERSION as _VER
    up_sec = int(time.time() - _APP_START_TS)
    if up_sec < 60:
        uptime = f"{up_sec}с"
    elif up_sec < 3600:
        uptime = f"{up_sec // 60}м"
    elif up_sec < 86400:
        uptime = f"{up_sec // 3600}ч {(up_sec % 3600) // 60}м"
    else:
        uptime = f"{up_sec // 86400}д {(up_sec % 86400) // 3600}ч"

    license_days = None
    try:
        _exp = session.get("license_expires") or session.get("license_expires_at")
        if _exp:
            from datetime import datetime as _dt
            # Принимаем форматы: "2026-05-01", "2026-05-01 12:34:56", ISO-8601
            _s = str(_exp).strip().replace("Z", "")
            _dt_exp = None
            for _fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
                try:
                    _dt_exp = _dt.strptime(_s.split(".")[0], _fmt)
                    break
                except Exception:
                    pass
            if _dt_exp is None:
                try:
                    _dt_exp = _dt.fromisoformat(_s.split(".")[0])
                except Exception:
                    pass
            if _dt_exp is not None:
                license_days = max(0, (_dt_exp - _dt.now()).days)
    except Exception as e:
        logger.debug("dashboard license parse: %s", e)

    _upd_cache = getattr(app, "_update_cache", None) or {}
    server_ok = True
    try:
        import requests as _r
        from config import TELEPUSH_SERVER_URL as _SU
        _r.get(_SU + "/api/version", timeout=3)
    except Exception:
        server_ok = False

    errors_today = 0
    try:
        _buf = getattr(app, "_log_buffer", None)
        if _buf is not None:
            for l in _buf:
                if l.get("level") == "ERROR":
                    errors_today += 1
    except Exception:
        pass

    health = {
        "version": _VER,
        "uptime": uptime,
        "license_days": license_days,
        "server_ok": server_ok,
        "update_available": bool(_upd_cache.get("available")),
        "update_version": _upd_cache.get("version", ""),
        "errors_today": errors_today,
    }

    return jsonify({
        "ok": True,
        "accounts": {"total": acc_total, "ok": acc_ok, "floodwait": acc_fw, "dead": acc_dead},
        "jobs": jobs,
        "today": today,
        "events": events,
        "health": health,
    })


# ── API: Account tags ───────────────────────────────────────────────────────

@app.route("/api/account_tags")
@login_required
def api_account_tags():
    """Теги по аккаунтам + список всех уникальных тегов."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})
    tags_map = {}
    try:
        tags_map = dict(ctx.user_settings.get("account_tags", {}) or {})
    except Exception:
        pass
    all_tags = set()
    for phone, lst in tags_map.items():
        if isinstance(lst, list):
            for t in lst:
                if t:
                    all_tags.add(str(t))
    return jsonify({
        "ok": True,
        "tags": tags_map,
        "all_tags": sorted(all_tags),
    })


@app.route("/api/account_tags/set", methods=["POST"])
@login_required
def api_account_tags_set():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})
    data = request.json or {}
    phone = (data.get("phone") or "").lstrip("+").strip()
    tags = data.get("tags") or []
    if not isinstance(tags, list):
        return jsonify({"ok": False, "error": "tags должен быть списком"})
    # Нормализуем: strip, уберём пустые/дубли, ограничим длину
    norm = []
    seen = set()
    for t in tags:
        s = str(t).strip()[:30]
        if s and s.lower() not in seen:
            seen.add(s.lower())
            norm.append(s)
        if len(norm) >= 6:
            break
    if not phone:
        return jsonify({"ok": False, "error": "phone обязателен"})
    try:
        m = dict(ctx.user_settings.get("account_tags", {}) or {})
        if norm:
            m[phone] = norm
        else:
            m.pop(phone, None)
        ctx.user_settings["account_tags"] = m
        _save_user_settings(ctx.user_settings, force=True)
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)})
    return jsonify({"ok": True, "tags": norm})


# ── API: Profile ────────────────────────────────────────────────────────────

@app.route("/api/profile")
@login_required
def api_profile():
    """Данные профиля: лицензия, HWID, версия, аккаунты."""
    ctx = _get_ctx()
    lm = _get_license_manager()
    key = session.get("license_key", "")
    # Mask key: show first 3 + last 4
    masked = key[:7] + "****-****-" + key[-4:] if len(key) > 12 else key

    import platform
    from config import TELEPUSH_VERSION

    # Snapshot — ctx.accounts может меняться в других потоках, без copy будет RuntimeError
    _accs_snapshot = list(ctx.accounts.items()) if ctx else []
    accs_total = len(_accs_snapshot)
    accs_alive = sum(1 for _p, a in _accs_snapshot if not a.dead)
    accs_busy = sum(1 for p, _a in _accs_snapshot if _get_account_busy(ctx, p))

    return jsonify({
        "key": masked,
        "key_full": key,
        "hwid": lm.hwid[:16] + "..." if lm.hwid else "",
        "expires": session.get("license_expires", ""),
        "pc_name": platform.node(),
        "version": TELEPUSH_VERSION,
        "uid": session.get("uid", 0),
        "accounts_total": accs_total,
        "accounts_alive": accs_alive,
        "accounts_busy": accs_busy,
        "site_url": TELEPUSH_SERVER_URL,
        "key_type": session.get("key_type", "pro"),
        "modules": session.get("allowed_modules", []),
        "features": session.get("allowed_features", {}),
    })


@app.route("/api/profile/reset_hwid", methods=["POST"])
@login_required
def api_profile_reset_hwid():
    """Сброс привязки HWID через клиент."""
    import requests as _req
    key = session.get("license_key", "")
    if not key:
        return jsonify({"ok": False, "error": "Ключ не найден"}), 400
    try:
        resp = _req.post(
            f"{TELEPUSH_SERVER_URL}/api/license/reset_hwid",
            json={"key": key}, timeout=10,
        )
        data = resp.json()
        if data.get("ok"):
            session.clear()
            return jsonify({"ok": True, "msg": "Привязка сброшена. Войдите заново."})
        return jsonify({"ok": False, "error": data.get("error", "Ошибка сервера")})
    except Exception as e:
        logger.warning("Reset HWID error: %s", e)
        return jsonify({"ok": False, "error": "Сервер недоступен"}), 503


# ── API: Renewal request ────────────────────────────────────────────────────

@app.route("/api/renew", methods=["POST"])
@login_required
def api_renew():
    """Клиент запрашивает продление — уведомление админу в Telegram."""
    key = session.get("license_key", "")
    uid = session.get("uid", 0)
    data = request.json or {}
    tg_username = (data.get("tg_username") or "").strip().lstrip("@")
    if not key:
        return jsonify({"ok": False, "error": "Ключ не найден"}), 400

    import requests as _req
    lm = _get_license_manager()

    # Синхронно — клиент должен видеть отправился ли запрос на продление
    try:
        try:
            _req.post(
                f"{TELEPUSH_SERVER_URL}/api/renew_request",
                json={"key": key, "hwid": lm.hwid, "uid": uid, "tg_username": tg_username},
                timeout=10,
            )
        except _req.exceptions.SSLError as ssl_err:
            logger.warning("Renew SSL verify failed (%s), retrying without", ssl_err)
            _req.post(
                f"{TELEPUSH_SERVER_URL}/api/renew_request",
                json={"key": key, "hwid": lm.hwid, "uid": uid, "tg_username": tg_username},
                timeout=10, verify=False,
            )
    except Exception as e:
        logger.warning("Renew request error: %s", e)
        return jsonify({"ok": False, "error": f"Не удалось связаться с сервером: {e}"}), 502

    return jsonify({"ok": True})


# ── API: Job control ─────────────────────────────────────────────────────────

@app.route("/api/job/<action>", methods=["POST"])
@login_required
def api_job_action(action):
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 404
    data = request.json or {}
    job_type = data.get("type", "spam")

    if job_type == "join":
        job = ctx.job
    elif job_type == "user_spam":
        job = getattr(ctx, "user_spam_job", None)
    else:
        job = ctx.spam_job

    if not job:
        return jsonify({"error": "no active job"}), 404

    if action == "pause":
        job.status = "paused"
        if job.pause_event.is_set():
            job.pause_event.clear()
    elif action == "resume":
        job.status = "running"
        if not job.pause_event.is_set():
            job.pause_event.set()
    elif action == "stop":
        job.stop_flag = True
        job.pause_event.set()
        job.status = "stopped"
    else:
        return jsonify({"error": "unknown action"}), 400

    return jsonify({"ok": True, "status": job.status})


# ── API: Update check ────────────────────────────────────────────────────────

@app.route("/api/update/check")
@login_required
def api_update_check():
    try:
        from utils.updater import check_update, _parse_version
        result = check_update()
        # Если версия < 1.0.12 — updater сломан, нужно качать с сайта
        if result.get("available") and _parse_version(TELEPUSH_VERSION) < (1, 0, 12):
            result["force_download"] = True
            result["download_url"] = TELEPUSH_SERVER_URL
        return jsonify(result)
    except Exception:
        return jsonify({"available": False})


# ── API: Broadcast (объявления от админа сервера) ────────────────────────────

@app.route("/api/broadcast/check")
@login_required
def api_broadcast_check():
    """Проксирует /api/broadcast с лиц-сервера. Кешируем 5 минут чтобы не спамить."""
    try:
        _bc_cache = getattr(app, '_broadcast_cache', None)
        _bc_ts = getattr(app, '_broadcast_cache_ts', 0)
        if not _bc_cache or (time.time() - _bc_ts) > 300:
            import requests as _req
            r = _req.get(f"{TELEPUSH_SERVER_URL}/api/broadcast", timeout=8)
            if r.status_code == 200:
                _bc_cache = r.json()
                app._broadcast_cache = _bc_cache
                app._broadcast_cache_ts = time.time()
            else:
                _bc_cache = {"announcement": None}
        return jsonify(_bc_cache or {"announcement": None})
    except Exception:
        return jsonify({"announcement": None})


# ── API: Diagnostics ─────────────────────────────────────────────────────────
# Структурированный snapshot для саппорта. Клиент кликает «Скопировать
# диагностику» — содержимое летит в буфер обмена и вставляется в обращение.
# Без этого саппорт раз 10 переспрашивает «какая версия / сколько аккаунтов / был ли spam-блок».

@app.route("/api/diag")
@login_required
def api_diag():
    import platform as _pf
    diag = {
        "version": TELEPUSH_VERSION,
        "platform": _pf.platform(),
        "python": _pf.python_version(),
        "ts": datetime.now().isoformat(timespec="seconds"),
    }
    # Лицензия (без полного ключа — только маска).
    try:
        lm = _get_license_manager()
        if lm:
            diag["license_status"] = "active" if lm.is_valid() else "invalid"
            k = getattr(lm, "license_key", "") or ""
            if k:
                diag["license_key_mask"] = k[:8] + "..." + k[-4:] if len(k) > 12 else k
            diag["hwid_mask"] = (getattr(lm, "hwid", "") or "")[:12] + "..."
            try:
                exp = lm.expires_at() if hasattr(lm, "expires_at") else None
                if exp:
                    diag["license_expires"] = exp
            except Exception:
                pass
    except Exception as e:
        diag["license_error"] = str(e)[:120]
    # Аккаунты.
    try:
        st = _get_state()
        uid = session.get("uid", 0)
        ctx = st.owners.get(uid) if st else None
        if ctx:
            accs = list(ctx.accounts.values()) if hasattr(ctx, "accounts") else []
            diag["accounts_total"] = len(accs)
            diag["accounts_dead"] = sum(1 for a in accs if getattr(a, "dead", False))
            diag["accounts_frozen"] = sum(1 for a in accs if getattr(a, "frozen", False))
            diag["accounts_floodwait"] = sum(1 for a in accs if getattr(a, "floodwait", 0))
            diag["accounts_busy"] = sum(1 for a in accs if getattr(a, "busy", ""))
            # Активные задачи.
            jobs = []
            for jname in ("join_job", "spam_job", "dm_job", "monitor_job", "warmup_job", "parser_job", "invite_job", "autoreply_job"):
                j = getattr(ctx, jname, None)
                if j and getattr(j, "status", "") in ("running", "paused"):
                    jobs.append({
                        "type": jname.replace("_job", ""),
                        "status": j.status,
                        "processed": getattr(j, "processed", 0),
                        "total": getattr(j, "total", 0),
                    })
            diag["active_jobs"] = jobs
    except Exception as e:
        diag["accounts_error"] = str(e)[:120]
    # Прокси.
    try:
        from utils.proxy_pool import load_proxies
        proxies = load_proxies()
        diag["proxies_total"] = len(proxies)
        diag["proxies_alive"] = sum(1 for p in proxies if p.get("alive") is True)
    except Exception:
        pass
    # Папка с данными.
    try:
        from config import DATA_DIR as _DD
        if _DD.exists():
            diag["data_dir"] = str(_DD)
            # Размер сессий-папки.
            try:
                from config import ACCOUNTS_DIR as _AD
                if _AD.exists():
                    total_b = sum(p.stat().st_size for p in _AD.rglob("*") if p.is_file())
                    diag["accounts_dir_size_mb"] = round(total_b / 1024 / 1024, 1)
            except Exception:
                pass
    except Exception:
        pass
    # Последние 10 ERROR из логов.
    try:
        from config import LOG_DIR as _LD
        log_file = _LD / "telepush.log" if _LD.exists() else None
        if log_file and log_file.exists():
            with open(str(log_file), "r", encoding="utf-8", errors="replace") as f:
                # Читаем последние 200 KB чтоб быстрее.
                f.seek(0, 2)
                size = f.tell()
                f.seek(max(0, size - 200_000))
                tail = f.read().splitlines()
            errors = [ln[-300:] for ln in tail if "ERROR" in ln or "CRITICAL" in ln][-10:]
            diag["recent_errors"] = errors
    except Exception:
        pass
    return jsonify(diag)


_MODULE_SHORT = {
    "telepush.spam":        "spam",
    "telepush.dm":          "dm",
    "telepush.join":        "join",
    "telepush.parser":      "parser",
    "telepush.monitor":     "monitor",
    "telepush.warmup":      "warmup",
    "telepush.profile":     "warmup",   # сюда же кладём профильные действия
    "telepush.autoreply":   "autoreply",
    "telepush.reauth":      "system",
    "telepush.updater":     "system",
    "telepush.auto_resume": "system",
    "telepush.web":         "system",
    "telepush":             "system",
}


def _module_short(record_name: str) -> str:
    """telepush.spam → spam. Незнакомые имена → system."""
    return _MODULE_SHORT.get(record_name, "system")


@app.route("/api/logs/recent")
@login_required
def api_logs_recent():
    """Последние логи для консоли в UI. Каждая запись содержит module-тег
    (spam/dm/join/parser/monitor/warmup/autoreply/system) для фильтрации."""
    import collections
    _log_buffer = getattr(app, '_log_buffer', None)
    if _log_buffer is None:
        # 500 строк = ~10-15 минут активной работы. Хранится только в памяти.
        app._log_buffer = collections.deque(maxlen=500)
        app._log_last_id = 0
        import logging
        _seq = [0]  # стабильный счётчик id (deque сдвигается, len меняется)

        class _UILogHandler(logging.Handler):
            def emit(self, record):
                _seq[0] += 1
                app._log_buffer.append({
                    "id": _seq[0],
                    "level": record.levelname,
                    "module": _module_short(record.name),
                    "message": self.format(record),
                })
        h = _UILogHandler(level=logging.INFO)
        h.setFormatter(logging.Formatter("%(message)s"))
        logging.getLogger("telepush").addHandler(h)
        _log_buffer = app._log_buffer

    since = int(request.args.get("since", 0))
    logs = [l for l in _log_buffer if l["id"] > since]
    return jsonify({"logs": logs[-100:]})


@app.route("/api/logs/pack", methods=["POST"])
@login_required
def api_logs_pack():
    """Упаковывает логи в ЗАШИФРОВАННЫЙ архив в папке Экспорт/Логи/ и открывает его в Проводнике.

    Формат: .tplog — бинарный контейнер, внутри AES-256-GCM зашифрованный zip.
    Файл нельзя просто открыть (архиватор его не распознаёт) и нельзя прочитать
    содержимое если у вас нет ключа. Клиенты могут безопасно присылать такой файл
    в поддержку — посторонние увидят только мусор, а администратор TelePush
    расшифрует утилитой. Ключ известен только нам."""
    from config import LOG_DIR
    import zipfile
    import io as _io
    try:
        out_dir = EXPORT_DIR / "Логи"
        out_dir.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
        enc_path = out_dir / f"telepush-logs-{stamp}.tplog"

        # 1. Собираем zip в памяти
        buf = _io.BytesIO()
        added = 0
        skipped = 0
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
            if LOG_DIR.exists():
                for f in LOG_DIR.rglob("*"):
                    if not f.is_file():
                        continue
                    try:
                        zf.write(str(f), arcname=f"logs/{f.relative_to(LOG_DIR)}")
                        added += 1
                    except Exception:
                        skipped += 1
            try:
                meta = (
                    f"TelePush version: {TELEPUSH_VERSION}\n"
                    f"Packed at: {stamp}\n"
                    f"LOG_DIR: {LOG_DIR}\n"
                    f"DATA_DIR: {DATA_DIR}\n"
                )
                zf.writestr("info.txt", meta)
            except Exception:
                pass
        plain = buf.getvalue()

        # 2. Шифруем zip через AES-256-GCM.
        # Ключ известен только нам — посторонний человек не прочитает лог.
        import os as _os
        from cryptography.hazmat.primitives.ciphers.aead import AESGCM
        # Фиксированный мастер-ключ (32 байта = 256 бит). Не храни его в читабельном виде.
        _MASTER_KEY = bytes.fromhex(
            "54656c65507573684c6f674d617374657232303236" + "0" * 22  # "TelePushLogMaster2026" padded
        )[:32]
        nonce = _os.urandom(12)  # 96-битный nonce
        aes = AESGCM(_MASTER_KEY)
        ciphertext = aes.encrypt(nonce, plain, associated_data=b"telepush-logs-v1")

        # 3. Пишем файл-контейнер: magic + version + nonce + ciphertext
        # Magic «TPLG» — чтобы утилита декрипта могла проверить что это наш формат.
        with open(str(enc_path), "wb") as fh:
            fh.write(b"TPLG")      # magic
            fh.write(b"\x01")        # version
            fh.write(nonce)          # 12 bytes
            fh.write(ciphertext)     # остальное

        # 4. Открываем папку с файлом в проводнике
        try:
            if sys.platform == "win32":
                import subprocess as _sp
                _sp.Popen(["explorer", "/select,", str(enc_path)])
            else:
                import subprocess as _sp
                _sp.Popen(["xdg-open", str(out_dir)])
        except Exception:
            pass

        size_kb = enc_path.stat().st_size // 1024 if enc_path.exists() else 0
        return jsonify({
            "ok": True,
            "file": enc_path.name,
            "path": str(enc_path),
            "folder": str(out_dir),
            "added": added,
            "skipped": skipped,
            "size_kb": size_kb,
            "encrypted": True,
        })
    except Exception as e:
        logger.error("[logs/pack] %s", e, exc_info=True)
        return jsonify({"ok": False, "error": f"{type(e).__name__}: {e}"}), 500


@app.route("/api/update/install", methods=["POST"])
@login_required
def api_update_install():
    """Синхронно скачивает + распаковывает + копирует файлы.
    Если падает — возвращает конкретную причину клиенту.
    Только если всё ок — уходит в фоновый os._exit через 1 сек (чтобы Flask успел отдать 200)."""
    from utils.updater import install_update
    try:
        result = install_update()
    except Exception as e:
        import traceback
        logger.error("[update] неожиданный exception: %s\n%s", e, traceback.format_exc())
        return jsonify({"ok": False, "error_code": "unexpected",
                        "message": f"Неожиданная ошибка: {type(e).__name__}: {str(e)[:150]}"}), 200

    if isinstance(result, dict) and not result.get("ok"):
        return jsonify({"ok": False,
                        "error_code": result.get("error_code", "unknown"),
                        "message": result.get("message", "Неизвестная ошибка обновления"),
                        "manual_url": f"{TELEPUSH_SERVER_URL}/api/download?v=latest"}), 200

    # install_update при успехе делает os._exit(0) — сюда не попадём.
    # Но на всякий случай вернём ok.
    return jsonify({"ok": True, "msg": "Обновление установлено, перезапуск..."})


# ── API: Tickets ─────────────────────────────────────────────────────────────

@app.route("/api/ticket", methods=["POST"])
@login_required
def api_ticket():
    # Поддержка FormData (с файлом) и JSON
    if request.content_type and 'multipart' in request.content_type:
        module = request.form.get("module", "")
        subject = request.form.get("subject", "").strip()
        body = request.form.get("body", "").strip()
        screenshot = request.files.get("screenshot")
    else:
        data = request.json or {}
        module = data.get("module", "")
        subject = data.get("subject", "").strip()
        body = data.get("body", "").strip()
        screenshot = None

    if not subject:
        return jsonify({"error": "Введите тему"}), 400

    # Сохраняем скриншот если есть (лимит 5МБ — защита от DoS через огромный upload)
    screenshot_b64 = None
    if screenshot:
        import base64
        MAX_SCREENSHOT = 5 * 1024 * 1024
        raw = screenshot.read(MAX_SCREENSHOT + 1)
        if len(raw) > MAX_SCREENSHOT:
            return jsonify({"error": "Скриншот слишком большой (макс 5 МБ)"}), 400
        screenshot_b64 = base64.b64encode(raw).decode()

    payload = {
        "module": module,
        "subject": subject,
        "body": body,
        "hwid": str(session.get("uid", "")),
        "version": TELEPUSH_VERSION,
        "key": session.get("license_key", ""),
    }
    if screenshot_b64:
        payload["screenshot_b64"] = screenshot_b64

    # Синхронная отправка: клиент должен видеть реальный результат, а не fire-and-forget
    try:
        import requests as _req
        try:
            resp = _req.post(f"{TELEPUSH_SERVER_URL}/api/ticket", json=payload, timeout=30)
        except _req.exceptions.SSLError as ssl_err:
            # Fallback: сертификат/CA проблема на клиенте — повторяем без verify, но предупреждаем
            logger.warning("Ticket SSL verify failed (%s), retrying without verification", ssl_err)
            resp = _req.post(f"{TELEPUSH_SERVER_URL}/api/ticket", json=payload, timeout=30, verify=False)
        if resp.status_code != 200:
            logger.warning("Ticket send: server returned %d", resp.status_code)
            return jsonify({"ok": False, "error": f"Сервер вернул код {resp.status_code}"}), 502
    except Exception as e:
        logger.warning("Ticket send failed: %s", e)
        return jsonify({"ok": False, "error": f"Не удалось отправить: {e}"}), 502
    return jsonify({"ok": True})


# ── API: Spam (рассылка по чатам) ────────────────────────────────────────────

@app.route("/api/spam/template", methods=["POST"])
@login_required
def api_spam_template():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    tpl = data.get("template", "") or ""
    if len(tpl) > 50000:
        return jsonify({"error": "Текст слишком длинный (макс 50000 символов)"}), 400
    ctx.spam["template"] = tpl or None
    return jsonify({"ok": True})


@app.route("/api/spam/targets/collect", methods=["POST"])
@login_required
def api_spam_collect():
    ctx = _get_ctx()
    if not ctx or not ctx.active:
        return jsonify({"error": "нет активного аккаунта"}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 400

    from core.spam import collect_writable_groups
    async def _collect():
        ids, titles = await collect_writable_groups(acc)
        ctx.spam["targets"] = ids
        ctx.spam["titles"] = titles
        ctx.spam["writable_count"] = len(ids)
        return len(ids)

    result = _run_async(_collect(), timeout=60)
    if result is None:
        return jsonify({"error": "таймаут"}), 503
    return jsonify({"ok": True, "count": result})


@app.route("/api/spam/targets/manual", methods=["POST"])
@login_required
def api_spam_manual():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    text = data.get("text", "")
    lines = [l.strip() for l in text.splitlines() if l.strip()]
    existing = ctx.spam.get("targets") or []
    existing_set = set(existing)
    for line in lines:
        try:
            cid = int(line)
            if cid not in existing_set:
                existing.append(cid)
                existing_set.add(cid)
        except ValueError:
            pass
    ctx.spam["targets"] = existing
    return jsonify({"ok": True, "count": len(existing)})


@app.route("/api/spam/targets/clear", methods=["POST"])
@login_required
def api_spam_clear():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    ctx.spam["targets"] = []
    ctx.spam["titles"] = {}
    return jsonify({"ok": True})


# ── Anti-detect helpers (общие для всех start-endpoints) ────────────────────

def _build_ad_dict(ctx, request_json: dict) -> dict:
    """Собирает финальный ad_config из payload + global settings."""
    ad = dict((request_json or {}).get("ad_config") or {})
    g = (ctx.user_settings.get("global_ad") if ctx else None) or {}
    if not ad.get("pre_spam_check") and g.get("pre_spam_check"):
        ad["pre_spam_check"] = True
    return ad


async def _apply_ad_pre_check(acc_or_list, ad: dict, module: str, log_fn=None) -> tuple:
    """Если в ad стоит pre_spam_check — пробегает аккаунт(ы) через @SpamBot.
    Возвращает (ok_list, blocked_list). Если pre_spam_check выключен — возвращает
    ([acc] или acc_or_list, []).
    """
    if not ad.get("pre_spam_check"):
        accs = acc_or_list if isinstance(acc_or_list, list) else [acc_or_list]
        return accs, []
    from utils.anti_detect import pre_spam_check_batch
    accs = acc_or_list if isinstance(acc_or_list, list) else [acc_or_list]
    return await pre_spam_check_batch(accs, log_module=module)


async def _start_online_loop(client, ad: dict, module: str):
    """Если в ad стоит online — стартует фоновый keep-online task. Возвращает
    (stop_event, task). Иначе (None, None)."""
    if not ad.get("online"):
        return None, None
    from utils.anti_detect import keep_online_loop
    stop = asyncio.Event()
    task = asyncio.create_task(keep_online_loop(client, stop, module=module))
    return stop, task


async def _stop_online_loop(stop_event, task):
    if not stop_event:
        return
    try:
        stop_event.set()
        if task:
            await asyncio.wait_for(task, timeout=2)
    except Exception:
        if task:
            task.cancel()


@app.route("/api/global_ad/save", methods=["POST"])
@login_required
def api_global_ad_save():
    """Сохранение глобальной защиты аккаунтов (галка в Настройках)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    g = ctx.user_settings.setdefault("global_ad", {})
    if "pre_spam_check" in data:
        g["pre_spam_check"] = bool(data["pre_spam_check"])
    try:
        _save_user_settings(ctx.user_settings)
    except Exception as e:
        logger.warning("[global_ad] save failed: %s", e)
    return jsonify({"ok": True, "global_ad": g})


@app.route("/api/global_ad/get")
@login_required
def api_global_ad_get():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"global_ad": {}})
    return jsonify({"global_ad": ctx.user_settings.get("global_ad") or {}})


# ── Расписание / рабочие часы ───────────────────────────────────────────────

def _is_within_working_hours(ctx) -> tuple:
    """Возвращает (allowed: bool, reason: str). Если расписание выключено — (True, '')."""
    sch = ctx.user_settings.get("schedule") or {}
    if not sch.get("enabled"):
        return True, ""
    try:
        from datetime import datetime
        h = datetime.now().hour
        f = int(sch.get("from", 9))
        t = int(sch.get("to", 23))
        # Поддержка перехода через полночь (например 22-6)
        if f <= t:
            ok = f <= h < t
        else:
            ok = h >= f or h < t
        if ok:
            return True, ""
        return False, f"Сейчас не рабочее время. Окно: {f:02d}:00-{t:02d}:00"
    except Exception:
        return True, ""


@app.route("/api/schedule/save", methods=["POST"])
@login_required
def api_schedule_save():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    s = ctx.user_settings.setdefault("schedule", {})
    if "enabled" in data:
        s["enabled"] = bool(data["enabled"])
    if "from" in data:
        s["from"] = max(0, min(23, int(data["from"])))
    if "to" in data:
        s["to"] = max(1, min(24, int(data["to"])))
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass
    return jsonify({"ok": True, "schedule": s})


@app.route("/api/schedule/get")
@login_required
def api_schedule_get():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"schedule": {}})
    return jsonify({"schedule": ctx.user_settings.get("schedule") or {}})


# ── TG-бот уведомления ──────────────────────────────────────────────────────

def _notify_telegram(ctx, msg: str, level: str = "INFO") -> bool:
    """Отправляет уведомление через TG-бота клиента (если настроен)."""
    bot = (ctx.user_settings.get("tg_bot") or {}) if ctx else {}
    token = bot.get("token", "").strip()
    chat_id = bot.get("chat_id", "").strip()
    if not token or not chat_id:
        return False
    try:
        import urllib.request as _u
        import urllib.parse as _p
        prefix = {"INFO": "ℹ️", "WARN": "⚠️", "ERROR": "🚨"}.get(level, "")
        full = f"{prefix} {msg}".strip()
        url = f"https://api.telegram.org/bot{token}/sendMessage"
        data = _p.urlencode({"chat_id": chat_id, "text": full, "parse_mode": "HTML"}).encode()
        req = _u.Request(url, data=data, method="POST")
        with _u.urlopen(req, timeout=5) as r:
            return r.status == 200
    except Exception as e:
        logger.debug("[tg-bot] notify failed: %s", e)
        return False


@app.route("/api/tg_bot/save", methods=["POST"])
@login_required
def api_tg_bot_save():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    b = ctx.user_settings.setdefault("tg_bot", {})
    for k in ("token", "chat_id"):
        if k in data:
            b[k] = str(data[k]).strip()
    for k in ("notify_spamblock", "notify_task_done", "notify_error"):
        if k in data:
            b[k] = bool(data[k])
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass
    return jsonify({"ok": True})


@app.route("/api/tg_bot/get")
@login_required
def api_tg_bot_get():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"tg_bot": {}})
    b = dict(ctx.user_settings.get("tg_bot") or {})
    # Прячем полный токен
    if b.get("token"):
        b["token_mask"] = b["token"][:8] + "..." + b["token"][-4:]
    return jsonify({"tg_bot": b})


@app.route("/api/ad_stats")
@login_required
def api_ad_stats():
    """Live-счётчики anti-detect (с момента старта программы)."""
    from utils.anti_detect import AD_COUNTERS
    return jsonify({"counters": dict(AD_COUNTERS)})


@app.route("/api/tg_bot/test", methods=["POST"])
@login_required
def api_tg_bot_test():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    ok = _notify_telegram(ctx, "Тестовое сообщение от TelePush — настройки бота работают.", "INFO")
    return jsonify({"ok": bool(ok), "error": "" if ok else "Не удалось — проверь токен и chat_id"})


# ── Массовое заполнение профилей из материалов ────────────────────────────

def _safe_under_client_root(folder_str: str) -> Optional["Path"]:
    """Резолвит путь и проверяет что он внутри CLIENT_ROOT. Иначе None.

    Без проверки os.startfile(any_path) открывает что угодно — атакер через
    web/CSRF может запустить C:\\Windows\\System32\\cmd.exe или сетевой share.
    Все file-related endpoints обязаны прогонять path через эту функцию.
    """
    from pathlib import Path
    from config import _CLIENT_ROOT
    try:
        p = Path(folder_str).resolve(strict=False)
        root = Path(_CLIENT_ROOT).resolve(strict=False)
        # is_relative_to: Python 3.9+
        try:
            if p.is_relative_to(root):
                return p
        except AttributeError:
            try:
                p.relative_to(root)
                return p
            except ValueError:
                return None
        return None
    except Exception:
        return None


@app.route("/api/profile/bulk_scan", methods=["POST"])
@login_required
def api_profile_bulk_scan():
    """Сканирует папку с материалами, возвращает что нашлось."""
    from pathlib import Path
    from core.profile_bulk import scan_materials
    data = request.json or {}
    folder = (data.get("path") or "").strip()
    if not folder:
        return jsonify({"error": "Укажите путь к папке"}), 400
    p = _safe_under_client_root(folder)
    if not p:
        return jsonify({"error": "Путь должен быть внутри папки TelePush"}), 400
    if not p.exists():
        return jsonify({"error": f"Папка не найдена: {folder}"}), 404
    return jsonify({"ok": True, "result": scan_materials(p)})


@app.route("/api/profile/bulk_open", methods=["POST"])
@login_required
def api_profile_bulk_open():
    """Открывает папку с материалами в Проводнике (Windows). Только пути
    внутри корневой папки TelePush — иначе path-traversal позволяет открыть
    любой файл на ПК через CSRF/XSS."""
    import subprocess, os, platform
    data = request.json or {}
    folder = (data.get("path") or "").strip()
    if not folder:
        return jsonify({"error": "Укажите путь"}), 400
    p = _safe_under_client_root(folder)
    if not p:
        return jsonify({"error": "Можно открывать только папки внутри TelePush"}), 400
    if not p.exists() or not p.is_dir():
        return jsonify({"error": "Папка не найдена"}), 404
    try:
        if platform.system() == "Windows":
            os.startfile(str(p))
        else:
            subprocess.Popen(["xdg-open", str(p)])
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/profile/bulk_start", methods=["POST"])
@login_required
def api_profile_bulk_start():
    """Запускает массовое заполнение профилей из материалов."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    from pathlib import Path
    from core.profile_bulk import scan_materials, load_pool, pick_materials, consume_avatar, bulk_fill_account
    from core.client_builder import ensure_connected, release_after_task

    data = request.json or {}
    folder = (data.get("path") or "").strip()
    phones = data.get("phones") or []
    options = {
        "do_name": bool(data.get("do_name", True)),
        "do_bio": bool(data.get("do_bio", True)),
        "do_avatar": bool(data.get("do_avatar", True)),
        "do_stories": bool(data.get("do_stories", False)),
        "avatar_once": bool(data.get("avatar_once", True)),
        "delete_old_avatars": bool(data.get("delete_old", False)),
        "move_processed": bool(data.get("move_processed", False)),
        "target_folder": str(data.get("target_folder", "")).strip(),
    }

    if not folder:
        return jsonify({"error": "Укажите папку с материалами"}), 400
    if not phones:
        return jsonify({"error": "Выберите аккаунты"}), 400

    root = Path(folder)
    if not root.exists():
        return jsonify({"error": f"Папка не найдена: {folder}"}), 404

    # Загружаем пулы
    female = load_pool(root / "Женские") if (root / "Женские").exists() else None
    male = load_pool(root / "Мужские") if (root / "Мужские").exists() else None
    general = load_pool(root)
    used_female_dir = root / "Женские" / "Аватарки" / "used"
    used_male_dir = root / "Мужские" / "Аватарки" / "used"
    used_general_dir = root / "Аватарки" / "used"

    # Стейт в ctx.temp
    bulk_state = ctx.temp.setdefault("bulk_fill", {})
    bulk_state["status"] = "running"
    bulk_state["total"] = len(phones)
    bulk_state["done"] = 0
    bulk_state["ok"] = 0
    bulk_state["fail"] = 0
    bulk_state["logs"] = []

    def _log(msg: str):
        from datetime import datetime as _dt
        line = f"[{_dt.now().strftime('%H:%M:%S')}] {msg}"
        bulk_state["logs"].append(line)
        if len(bulk_state["logs"]) > 300:
            bulk_state["logs"] = bulk_state["logs"][-200:]
        logger.info("[bulk_fill] %s", msg)

    async def _runner():
        for phone in phones:
            if bulk_state.get("status") == "stopped":
                break
            acc = ctx.accounts.get(phone)
            if not acc:
                bulk_state["fail"] += 1
                bulk_state["done"] += 1
                _log(f"✗ {phone}: акк не найден")
                continue
            if not await ensure_connected(acc):
                bulk_state["fail"] += 1
                bulk_state["done"] += 1
                _log(f"✗ {phone}: не подключился")
                continue

            # Выбираем пол по эвристике (первая буква имени аккаунта если есть, иначе случайно)
            gender = random.choice(["female", "male"]) if (female or male) else "general"
            if gender == "female":
                materials = pick_materials(female, fallback=general)
                used_dir = used_female_dir
                pool_for_consume = female or general
            elif gender == "male":
                materials = pick_materials(male, fallback=general)
                used_dir = used_male_dir
                pool_for_consume = male or general
            else:
                materials = pick_materials(general)
                used_dir = used_general_dir
                pool_for_consume = general

            avatar = materials.get("avatar")
            try:
                result = await bulk_fill_account(acc.client, materials, options)
                if result["ok"]:
                    bulk_state["ok"] += 1
                    _log(f"✓ {phone}: {', '.join(result['actions']) or 'ничего'}")
                    # Использовали аватарку — переносим в used/
                    if options["avatar_once"] and avatar:
                        try: consume_avatar(pool_for_consume, avatar, used_dir)
                        except Exception: pass
                    # Перемещаем акк в указанную папку — строго внутри ACCOUNTS_DIR
                    if options["move_processed"] and options["target_folder"]:
                        try:
                            from config import ACCOUNTS_DIR
                            from core.checker import _safe_under
                            target = _safe_under(options["target_folder"], ACCOUNTS_DIR)
                            if not target:
                                _log(f"  ⚠ {phone}: невалидный путь '{options['target_folder']}' — должен быть внутри Аккаунты/")
                            else:
                                target.mkdir(parents=True, exist_ok=True)
                                if acc.session_path and acc.session_path.exists():
                                    import shutil as _sh
                                    _sh.move(str(acc.session_path), str(target / acc.session_path.name))
                                    if acc.json_path and acc.json_path.exists():
                                        _sh.move(str(acc.json_path), str(target / acc.json_path.name))
                                    _log(f"  ↪ {phone} перемещён в {target.name}")
                        except Exception as me:
                            _log(f"  ⚠ {phone}: перемещение не удалось ({me})")
                else:
                    bulk_state["fail"] += 1
                    _log(f"✗ {phone}: {result['error']}")
            except Exception as e:
                bulk_state["fail"] += 1
                _log(f"⚠ {phone}: {str(e)[:60]}")
            finally:
                bulk_state["done"] += 1
                try: await release_after_task(acc)
                except Exception: pass
                # Пауза между акками
                await asyncio.sleep(random.uniform(2.0, 5.0))

        bulk_state["status"] = "finished" if bulk_state.get("status") != "stopped" else "stopped"
        _log(f"Заполнение завершено. ✓ {bulk_state['ok']} / ✗ {bulk_state['fail']}")

    try:
        st = _get_state()
        loop = getattr(st, "event_loop", None) if st else None
        if not loop or loop.is_closed():
            return jsonify({"error": "Event loop недоступен"}), 500
        asyncio.run_coroutine_threadsafe(_runner(), loop)
    except Exception as e:
        return jsonify({"error": f"Не удалось запустить: {e}"}), 500

    return jsonify({"ok": True, "total": len(phones)})


@app.route("/api/profile/bulk_status")
@login_required
def api_profile_bulk_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"running": False})
    bs = ctx.temp.get("bulk_fill") or {}
    if not bs:
        return jsonify({"running": False})
    return jsonify({
        "running": bs.get("status") == "running",
        "status": bs.get("status", ""),
        "total": bs.get("total", 0),
        "done": bs.get("done", 0),
        "ok": bs.get("ok", 0),
        "fail": bs.get("fail", 0),
        "logs": (bs.get("logs") or [])[-80:],
    })


@app.route("/api/profile/bulk_stop", methods=["POST"])
@login_required
def api_profile_bulk_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": True})
    bs = ctx.temp.get("bulk_fill")
    if bs:
        bs["status"] = "stopped"
    return jsonify({"ok": True})


# ── Чекер аккаунтов ────────────────────────────────────────────────────────

@app.route("/api/checker/config", methods=["GET", "POST"])
@login_required
def api_checker_config():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    s = ctx.user_settings.setdefault("checker", {})
    if request.method == "GET":
        # Маскируем ключи в ответе
        out = dict(s)
        if out.get("rucaptcha_key"):
            out["rucaptcha_key_mask"] = out["rucaptcha_key"][:4] + "..." + out["rucaptcha_key"][-4:]
        if out.get("deepseek_key"):
            out["deepseek_key_mask"] = out["deepseek_key"][:4] + "..." + out["deepseek_key"][-4:]
        return jsonify({"checker": out})
    data = request.json or {}
    for key in ("rucaptcha_key", "deepseek_key", "freeze_prompt",
                "folder_alive", "folder_temp_spam", "folder_eternal_spam", "folder_frozen"):
        if key in data:
            s[key] = str(data[key]).strip()
    if "spamblock_texts" in data:
        v = data["spamblock_texts"]
        if isinstance(v, str):
            v = [x.strip() for x in v.splitlines() if x.strip()]
        s["spamblock_texts"] = list(v)[:50]
    for key in ("check_spamblock", "submit_spamblock_appeal", "submit_freeze_appeal",
                "use_hacked_reason", "use_mistake_reason", "sort_by_folders",
                "rename_files", "delete_spambot_dialog"):
        if key in data:
            s[key] = bool(data[key])
    _save_user_settings(ctx.user_settings)
    return jsonify({"ok": True})


@app.route("/api/checker/start", methods=["POST"])
@login_required
def api_checker_start():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    if ctx.checker_job and ctx.checker_job.status == "running":
        return jsonify({"error": "Чекер уже запущен"}), 409
    data = request.json or {}
    phones = data.get("phones") or []

    from models import CheckerJob
    settings = dict(ctx.user_settings.get("checker") or {})
    job = CheckerJob(config=settings, target_phones=phones, status="running",
                     started_at=time.time())
    ctx.checker_job = job

    def _log(msg: str):
        from datetime import datetime as _dt
        line = f"[{_dt.now().strftime('%H:%M:%S')}] {msg}"
        job.log_lines.append(line)
        if len(job.log_lines) > 500:
            del job.log_lines[:200]
        logger.info("[checker] %s", msg)

    async def _runner():
        try:
            from core.checker import run_checker
            await run_checker(ctx, job, _log)
        except Exception as e:
            logger.error("[checker] runner failed: %s", e, exc_info=True)
            job.status = "finished"
            _log(f"Ошибка: {e}")

    try:
        st = _get_state()
        loop = getattr(st, "event_loop", None) if st else None
        if not loop or loop.is_closed():
            return jsonify({"error": "Event loop недоступен"}), 500
        asyncio.run_coroutine_threadsafe(_runner(), loop)
    except Exception as e:
        return jsonify({"error": f"Не удалось запустить: {e}"}), 500
    return jsonify({"ok": True, "total": len(phones)})


@app.route("/api/checker/status")
@login_required
def api_checker_status():
    ctx = _get_ctx()
    if not ctx or not ctx.checker_job:
        return jsonify({"running": False})
    j = ctx.checker_job
    return jsonify({
        "running": j.status == "running",
        "status": j.status,
        "total": j.total,
        "done": j.done,
        "alive": j.alive_count,
        "temp_spam": j.temp_spam_count,
        "eternal_spam": j.eternal_spam_count,
        "frozen": j.frozen_count,
        "error": j.error_count,
        "logs": j.log_lines[-80:],
    })


@app.route("/api/checker/stop", methods=["POST"])
@login_required
def api_checker_stop():
    ctx = _get_ctx()
    if ctx and ctx.checker_job:
        ctx.checker_job.status = "stopped"
    return jsonify({"ok": True})


# ── Накрутка (boost) ───────────────────────────────────────────────────────
_BOOST_TYPES = {"subscribers", "reactions", "polls", "buttons", "bots", "views"}


@app.route("/api/boost/start", methods=["POST"])
@login_required
def api_boost_start():
    """Запускает массовое действие пачкой аккаунтов.

    body: {
        type: "subscribers"|"reactions"|"polls"|"buttons"|"bots"|"views",
        target: "t.me/...|@username",
        count: int,
        delay_min: float, delay_max: float, threads: int,
        folder: str (optional),
        emojis: [str] (для reactions),
        poll_option: int|null (для polls),
        button_text: str (для buttons),
        allow_restart: bool, allow_revote: bool,
    }
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}

    btype = (data.get("type") or "").strip()
    if btype not in _BOOST_TYPES:
        return jsonify({"error": f"Неизвестный тип: {btype}"}), 400

    target = (data.get("target") or "").strip()
    if not target:
        return jsonify({"error": "Укажите цель (ссылку/юзернейм)"}), 400

    # Если уже идёт — отказ
    if ctx.boost_job and ctx.boost_job.status == "running":
        return jsonify({"error": "Накрутка уже запущена. Остановите текущую перед новым запуском."}), 409

    from core.boost import BoostConfig, run_boost
    from models import BoostJob

    cfg = BoostConfig(
        boost_type=btype,
        target=target,
        count=int(data.get("count", 50)),
        delay_min=float(data.get("delay_min", 5)),
        delay_max=float(data.get("delay_max", 15)),
        threads=max(1, min(20, int(data.get("threads", 1)))),
        folder=str(data.get("folder", "")).strip(),
        emojis=list(data.get("emojis", []) or []),
        poll_option=data.get("poll_option") if isinstance(data.get("poll_option"), int) else None,
        button_text=str(data.get("button_text", "")).strip(),
        allow_restart=bool(data.get("allow_restart", False)),
        allow_revote=bool(data.get("allow_revote", False)),
    )
    job = BoostJob(config=cfg, status="running", started_at=time.time())
    ctx.boost_job = job

    def _log(msg: str):
        from datetime import datetime as _dt
        line = f"[{_dt.now().strftime('%H:%M:%S')}] {msg}"
        job.log_lines.append(line)
        if len(job.log_lines) > 500:
            del job.log_lines[:200]
        logger.info("[boost] %s", msg)

    async def _runner():
        try:
            await run_boost(ctx, job, _log)
        except Exception as e:
            logger.error("[boost] runner failed: %s", e, exc_info=True)
            job.status = "finished"
            _log(f"Ошибка: {e}")

    try:
        st = _get_state()
        loop = getattr(st, "event_loop", None) if st else None
        if not loop or loop.is_closed():
            return jsonify({"error": "Event loop недоступен"}), 500
        asyncio.run_coroutine_threadsafe(_runner(), loop)
    except Exception as e:
        return jsonify({"error": f"Не удалось запустить: {e}"}), 500

    return jsonify({"ok": True, "type": btype, "total": 0})


@app.route("/api/boost/status")
@login_required
def api_boost_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"running": False})
    job = ctx.boost_job
    if not job:
        return jsonify({"running": False})
    return jsonify({
        "running": job.status == "running",
        "status": job.status,
        "type": job.config.boost_type if job.config else "",
        "target": job.config.target if job.config else "",
        "total": job.total,
        "done": job.done,
        "ok": job.ok,
        "fail": job.fail,
        "logs": job.log_lines[-80:],
    })


@app.route("/api/boost/stop", methods=["POST"])
@login_required
def api_boost_stop():
    ctx = _get_ctx()
    if not ctx or not ctx.boost_job:
        return jsonify({"ok": True})
    ctx.boost_job.status = "stopped"
    return jsonify({"ok": True})


# ── Тема оформления ────────────────────────────────────────────────────────
_VALID_THEMES = {"calm", "amoled", "light"}


@app.route("/api/theme/get")
@login_required
def api_theme_get():
    ctx = _get_ctx()
    theme = "calm"
    if ctx:
        t = (ctx.user_settings.get("theme") or "calm").strip().lower()
        if t in _VALID_THEMES:
            theme = t
    return jsonify({"theme": theme})


@app.route("/api/theme/save", methods=["POST"])
@login_required
def api_theme_save():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    theme = (data.get("theme") or "").strip().lower()
    if theme not in _VALID_THEMES:
        return jsonify({"error": "invalid theme"}), 400
    ctx.user_settings["theme"] = theme
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass
    return jsonify({"ok": True, "theme": theme})


# ── Имитация активности ─────────────────────────────────────────────────────

@app.route("/api/imitation/start", methods=["POST"])
@login_required
def api_imitation_start():
    """Запуск фоновой имитации человеческой активности на выбранных акках."""
    ctx = _get_ctx()
    uid = session.get("uid") or 0
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phones = data.get("phones") or []
    if not phones:
        return jsonify({"error": "Выберите аккаунты"}), 400

    # Если уже работает — стопаем перед стартом
    existing = getattr(ctx, "imitation_job", None)
    if existing and getattr(existing, "status", "") == "running":
        existing.stop_flag = True

    from core.imitation import ImitationJob, run_imitation_job
    job = ImitationJob(
        owner_id=uid,
        phones=list(phones),
        interval_min=int(data.get("interval_min", 15)),
        interval_max=int(data.get("interval_max", 45)),
        actions_per_round=int(data.get("actions_per_round", 3)),
        do_read=bool(data.get("do_read", True)),
        do_view_profile=bool(data.get("do_view_profile", True)),
        do_view_photo=bool(data.get("do_view_photo", False)),
    )
    ctx.imitation_job = job

    async def _run():
        try:
            await run_imitation_job(job, ctx)
        except Exception as e:
            logger.error("[imit] crashed: %s", e, exc_info=True)
            job.status = "stopped"

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)

    return jsonify({"ok": True, "phones": len(phones)})


@app.route("/api/imitation/status")
@login_required
def api_imitation_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"status": "idle"})
    job = getattr(ctx, "imitation_job", None)
    if not job:
        return jsonify({"status": "idle"})
    return jsonify({
        "status": job.status,
        "phones": len(job.phones),
        "counts": dict(job.counts),
        "started_at": job.started_at,
        "log": job.log_lines[-50:],
    })


@app.route("/api/imitation/stop", methods=["POST"])
@login_required
def api_imitation_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})
    job = getattr(ctx, "imitation_job", None)
    if job:
        job.stop_flag = True
        job.status = "stopped"
    return jsonify({"ok": True})


@app.route("/api/spam/start", methods=["POST"])
@login_required
def api_spam_start():
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx or not ctx.active:
        return jsonify({"error": "нет активного аккаунта"}), 400
    # Проверка рабочих часов (если настроено)
    sch_ok, sch_msg = _is_within_working_hours(ctx)
    if not sch_ok:
        return jsonify({"error": sch_msg}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 400
    busy = _get_account_busy(ctx, ctx.active)
    if busy:
        return jsonify({"error": f"Аккаунт занят: {busy}. Выберите другой аккаунт"}), 400
    tpl = ctx.spam.get("template")
    if not tpl:
        return jsonify({"error": "нет шаблона"}), 400
    targets = ctx.spam.get("targets") or []
    if not targets:
        return jsonify({"error": "нет целей"}), 400

    # Anti-detect конфиг (флаги «Защита аккаунта» из UI). Глобальный pre-spam-check
    # из user_settings → накладывается если в модуле не задан явно.
    data = request.json or {}
    ad_dict = data.get("ad_config") or {}
    if not ad_dict.get("pre_spam_check") and (ctx.user_settings.get("global_ad") or {}).get("pre_spam_check"):
        ad_dict["pre_spam_check"] = True

    # Новые опции 1.5.49: формат текста, пересылка, цикличность
    parse_mode = data.get("parse_mode") or None  # None / 'md' / 'html'
    forward_from_chat = (data.get("forward_from_chat") or "").strip() or None
    forward_msg_id = data.get("forward_msg_id") or None
    loop_hours = data.get("loop_hours") or None  # целое — повторять каждые N часов

    from core.spam import run_spam_job
    from models import SpamJob

    try:
        _spam_cap = max(0, min(2000, int(data.get("max_per_acc", 0) or 0)))
    except Exception:
        _spam_cap = 0
    job = SpamJob(
        owner_id=uid, acc=acc, text=tpl,
        targets=list(targets), titles=dict(ctx.spam.get("titles") or {}),
        total=len(targets),
        max_per_acc=_spam_cap,
    )
    job.status = "running"
    job.ad_config = ad_dict  # сохраняем для run_spam_job
    job.parse_mode = parse_mode
    job.forward_from_chat = forward_from_chat
    job.forward_msg_id = int(forward_msg_id) if forward_msg_id else None
    job.loop_hours = int(loop_hours) if loop_hours else None
    ctx.spam_job = job

    async def _run():
        from core.client_builder import ensure_connected, release_after_task
        from utils.anti_detect import keep_online_loop, pre_spam_check_batch, AntiDetectConfig
        _started = time.time()
        if not await ensure_connected(acc):
            job.status = "stopped"
            _set_job_auth_error(job, acc, "spam")
            return

        ad = AntiDetectConfig.from_dict(getattr(job, 'ad_config', None))
        spam_log = logging.getLogger("telepush.spam")

        # Pre-start spam-check
        if ad.pre_spam_check:
            spam_log.info("[защита] Проверяю аккаунт на спам-блок перед стартом...")
            ok_list, blocked = await pre_spam_check_batch([acc], log_module="spam")
            if not ok_list:
                reason = blocked[0][1] if blocked else "?"
                spam_log.warning("[защита] Аккаунт в спам-блоке (%s) — рассылка отменена", reason)
                job.status = "stopped"
                return

        # Online keep-loop в фоне
        online_stop = asyncio.Event()
        online_task = None
        if ad.online:
            spam_log.info("[защита] Держу аккаунт в онлайне на время рассылки")
            online_task = asyncio.create_task(keep_online_loop(acc.client, online_stop, module="spam"))

        # Цикл (если задано job.loop_hours): после завершения ждём N часов
        # и запускаем заново. Цикл идёт пока не job.stop_flag или until app stops.
        round_n = 1
        try:
            while True:
                if round_n > 1:
                    spam_log.info("[цикл] круг #%d: запускаем рассылку заново", round_n)
                await run_spam_job(job)
                lh = getattr(job, 'loop_hours', None)
                if not lh or getattr(job, 'stop_flag', False):
                    break
                # Сброс состояния для нового круга
                job.status = "running"
                job.processed = 0
                try:
                    job.counts = {k: 0 for k in (job.counts or {}).keys()}
                except Exception:
                    pass
                job.results = {}
                round_n += 1
                spam_log.info("[цикл] круг завершён, ждём %d ч до следующего круга", lh)
                await asyncio.sleep(lh * 3600)
                if getattr(job, 'stop_flag', False):
                    break
        finally:
            if online_task:
                online_stop.set()
                try:
                    await asyncio.wait_for(online_task, timeout=2)
                except Exception:
                    online_task.cancel()
            try:
                from core.history import log_run
                log_run(uid, "spam", acc.phone, _started, job.status,
                        dict(job.counts) if getattr(job, "counts", None) else {},
                        getattr(job, "total", 0))
            except Exception:
                pass
            try:
                await release_after_task(acc)
            except Exception:
                pass

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    logger.debug("[audit] uid=%s SPAM_START phone=%s targets=%d text_len=%d", uid, acc.phone, len(targets), len(tpl))
    return jsonify({"ok": True, "total": len(targets)})


# ── API: DM Spam ─────────────────────────────────────────────────────────────

@app.route("/api/dm/template", methods=["POST"])
@login_required
def api_dm_template():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    tpl = data.get("template", "")
    ctx.user_spam["template"] = tpl or None
    warnings = []
    if tpl:
        from core.spam import check_dm_triggers
        warnings = check_dm_triggers(tpl)
    return jsonify({"ok": True, "trigger_warnings": warnings})


@app.route("/api/dm/targets", methods=["POST"])
@login_required
def api_dm_targets():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    text = data.get("text", "")
    from core.spam import parse_dm_targets
    new_targets, new_titles, failed = parse_dm_targets(text)
    existing = ctx.user_spam.get("targets") or []
    existing_set = set(t.lower() if isinstance(t, str) else t for t in existing)
    for t in new_targets:
        key = t.lower() if isinstance(t, str) else t
        if key not in existing_set:
            existing.append(t)
            existing_set.add(key)
    existing_titles = ctx.user_spam.get("titles") or {}
    existing_titles.update(new_titles)
    ctx.user_spam["targets"] = existing
    ctx.user_spam["titles"] = existing_titles
    return jsonify({"ok": True, "count": len(existing), "failed": failed})


@app.route("/api/dm/targets/clear", methods=["POST"])
@login_required
def api_dm_clear():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    ctx.user_spam["targets"] = []
    ctx.user_spam["titles"] = {}
    return jsonify({"ok": True})


@app.route("/api/dm/start", methods=["POST"])
@login_required
def api_dm_start():
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx or not ctx.active:
        return jsonify({"error": "нет активного аккаунта"}), 400
    sch_ok, sch_msg = _is_within_working_hours(ctx)
    if not sch_ok:
        return jsonify({"error": sch_msg}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 400
    busy = _get_account_busy(ctx, ctx.active)
    if busy:
        return jsonify({"error": f"Аккаунт занят: {busy}. Выберите другой аккаунт"}), 400
    tpl = ctx.user_spam.get("template")
    if not tpl:
        return jsonify({"error": "нет шаблона"}), 400
    targets = ctx.user_spam.get("targets") or []
    if not targets:
        return jsonify({"error": "нет юзеров"}), 400

    from core.spam import run_spam_job
    from models import SpamJob

    data = request.json or {}
    try:
        _cap = max(0, min(2000, int(data.get("max_per_acc", 0) or 0)))
    except Exception:
        _cap = 0
    job = SpamJob(
        owner_id=uid, acc=acc, text=tpl,
        targets=list(targets), titles=dict(ctx.user_spam.get("titles") or {}),
        total=len(targets), dm_mode=True,
        max_per_acc=_cap,
    )
    job.status = "running"
    job.ad_config = _build_ad_dict(ctx, data)
    job.parse_mode = data.get("parse_mode") or None
    job.loop_hours = int(data.get("loop_hours")) if data.get("loop_hours") else None
    ctx.user_spam_job = job

    async def _run():
        from core.client_builder import ensure_connected, release_after_task
        _started = time.time()
        if not await ensure_connected(acc):
            job.status = "stopped"
            _set_job_auth_error(job, acc, "dm")
            return

        ad = job.ad_config or {}
        dm_log = logging.getLogger("telepush.dm")
        if ad.get("pre_spam_check"):
            dm_log.info("[защита] Проверяю аккаунт на спам-блок перед стартом DM...")
            ok_list, blocked = await _apply_ad_pre_check(acc, ad, "dm")
            if not ok_list:
                reason = blocked[0][1] if blocked else "?"
                dm_log.warning("[защита] Аккаунт в спам-блоке (%s) — DM рассылка отменена", reason)
                job.status = "stopped"
                return
        online_stop, online_task = await _start_online_loop(acc.client, ad, "dm")
        if online_stop:
            dm_log.info("[защита] Держу аккаунт в онлайне на время DM рассылки")

        # Цикл (loop_hours) — повторяем после завершения
        round_n = 1
        try:
            while True:
                if round_n > 1:
                    dm_log.info("[цикл] DM круг #%d", round_n)
                await run_spam_job(job)
                lh = getattr(job, 'loop_hours', None)
                if not lh or getattr(job, 'stop_flag', False):
                    break
                job.status = "running"
                job.processed = 0
                try:
                    job.counts = {k: 0 for k in (job.counts or {}).keys()}
                except Exception:
                    pass
                job.results = {}
                round_n += 1
                dm_log.info("[цикл] DM ждём %d ч до следующего круга", lh)
                await asyncio.sleep(lh * 3600)
                if getattr(job, 'stop_flag', False):
                    break
        finally:
            await _stop_online_loop(online_stop, online_task)
            try:
                from core.history import log_run
                log_run(uid, "dm", acc.phone, _started, job.status,
                        dict(job.counts) if getattr(job, "counts", None) else {},
                        getattr(job, "total", 0))
            except Exception:
                pass
            try:
                await release_after_task(acc)
            except Exception:
                pass

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    logger.debug("[audit] uid=%s DM_START phone=%s targets=%d text_len=%d", uid, acc.phone, len(targets), len(tpl))
    return jsonify({"ok": True, "total": len(targets)})


# ── API: Join (подписка) ─────────────────────────────────────────────────────

@app.route("/api/join/load", methods=["POST"])
@login_required
def api_join_load():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    text = data.get("text", "")
    lines = [l.strip() for l in text.splitlines() if l.strip() and not l.strip().startswith(("#", "//"))]
    ctx.temp["links"] = lines
    return jsonify({"ok": True, "count": len(lines)})


@app.route("/api/join/start", methods=["POST"])
@login_required
def api_join_start():
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx or not ctx.active:
        return jsonify({"error": "нет активного аккаунта"}), 400
    sch_ok, sch_msg = _is_within_working_hours(ctx)
    if not sch_ok:
        return jsonify({"error": sch_msg}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 400
    busy = _get_account_busy(ctx, ctx.active)
    if busy:
        return jsonify({"error": f"Аккаунт занят: {busy}. Выберите другой аккаунт"}), 400
    links = ctx.temp.get("links") or []
    if not links:
        return jsonify({"error": "нет ссылок"}), 400

    from core.join import run_join_job, deduplicate_links
    from models import JoinJob

    deduped, removed = deduplicate_links(links)
    try:
        _join_cap = max(0, min(2000, int((request.json or {}).get("max_per_acc", 0) or 0)))
    except Exception:
        _join_cap = 0
    job = JoinJob(
        owner_id=uid, acc=acc, queue=deduped,
        total=len(deduped), dup_removed=removed,
        max_per_acc=_join_cap,
    )
    job.status = "running"
    job.ad_config = _build_ad_dict(ctx, request.json)
    ctx.job = job

    async def _run():
        from core.client_builder import ensure_connected, release_after_task
        _started = time.time()
        if not await ensure_connected(acc):
            job.status = "stopped"
            _set_job_auth_error(job, acc, "join")
            return

        ad = job.ad_config or {}
        join_log = logging.getLogger("telepush.join")
        if ad.get("pre_spam_check"):
            join_log.info("[защита] Проверяю аккаунт на спам-блок перед стартом подписки...")
            ok_list, blocked = await _apply_ad_pre_check(acc, ad, "join")
            if not ok_list:
                reason = blocked[0][1] if blocked else "?"
                join_log.warning("[защита] Аккаунт в спам-блоке (%s) — подписка отменена", reason)
                job.status = "stopped"
                return
        online_stop, online_task = await _start_online_loop(acc.client, ad, "join")
        if online_stop:
            join_log.info("[защита] Держу аккаунт в онлайне на время подписки")

        try:
            await run_join_job(job)
        except Exception as e:
            logger.error("[join] run_join_job crashed: %s", e, exc_info=True)
            job.status = "stopped"
            job.current_step = f"Ошибка: {e}"
        finally:
            await _stop_online_loop(online_stop, online_task)
            try:
                from core.history import log_run
                log_run(uid, "join", acc.phone, _started, job.status,
                        dict(job.counts) if getattr(job, "counts", None) else {},
                        getattr(job, "total", 0))
            except Exception:
                pass
            try:
                await release_after_task(acc)
            except Exception:
                pass

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    logger.debug("[audit] uid=%s JOIN_START phone=%s links=%d removed=%d", uid, acc.phone, len(deduped), removed)
    return jsonify({"ok": True, "total": len(deduped), "removed": removed})


def _write_export_folder(label: str, ok_lines, fail_lines, by_reason: dict, reason_files: dict, open_folder: bool = True):
    """Общий хелпер: пишет папку Экспорт/<label>/<stamp>/ и (если open_folder=True)
    открывает в Проводнике. При автоматическом экспорте (watcher) папку НЕ открываем,
    чтобы не доставать юзера каждые 5 минут."""
    from config import DATA_DIR
    base = EXPORT_DIR  # рядом с exe или в Documents (если Temp-launch)
    try:
        base.mkdir(parents=True, exist_ok=True)
    except Exception:
        base = DATA_DIR / "exports"
        base.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    folder = base / label / stamp
    folder.mkdir(parents=True, exist_ok=True)

    (folder / "\u2705_\u0423\u0441\u043f\u0435\u0445.txt").write_text(
        "\n".join(ok_lines) + "\n" if ok_lines else "", encoding="utf-8")
    (folder / "\u274c_\u041d\u0435_\u0432\u044b\u0448\u043b\u043e.txt").write_text(
        "\n".join(fail_lines) + "\n" if fail_lines else "", encoding="utf-8")

    summary = {"✅ Успех": len(ok_lines), "❌ Не вышло": len(fail_lines)}
    for key, fname in reason_files.items():
        lst = by_reason.get(key) or []
        if not lst:
            continue
        (folder / fname).write_text("\n".join(lst) + "\n", encoding="utf-8")
        summary[fname.replace(".txt", "").replace("_", " ")] = len(lst)

    if open_folder:
        try:
            if sys.platform == "win32":
                os.startfile(str(folder))
            else:
                import subprocess
                subprocess.Popen(["xdg-open", str(folder)])
        except Exception as e:
            logger.debug("[export] open folder: %s", e)
    return folder, summary


def _collect_spam_results(ctx, want_dm: bool):
    """Собирает job.results из активных/завершённых Spam/DM задач."""
    results = {}
    titles = {}
    if want_dm:
        j = getattr(ctx, "user_spam_job", None)
    else:
        j = getattr(ctx, "spam_job", None)
    if j and getattr(j, "results", None):
        for k, v in j.results.items():
            results[str(k)] = v
            if getattr(j, "titles", None):
                t = j.titles.get(k)
                if t:
                    titles[str(k)] = t
    for mj in (ctx.temp.get("multi_jobs") or []):
        mj_type = getattr(mj, "type", None)
        if want_dm and mj_type != "dm":
            continue
        if not want_dm and mj_type != "spam":
            continue
        if getattr(mj, "results", None):
            for k, v in mj.results.items():
                results[str(k)] = v
                if getattr(mj, "titles", None):
                    t = mj.titles.get(k)
                    if t:
                        titles[str(k)] = t
    return results, titles


@app.route("/api/history")
@login_required
def api_history():
    """Последние 50 запусков задач клиента."""
    ctx = _get_ctx()
    uid = session.get("uid") or 0
    if not ctx:
        return jsonify({"runs": []})
    try:
        from core.history import list_runs
        runs = list_runs(uid, limit=50)
    except Exception as e:
        logger.warning("[history] list: %s", e)
        runs = []
    return jsonify({"runs": runs})


@app.route("/api/warmup/report")
@login_required
def api_warmup_report():
    """Отчёт по прогреву: кто живой, кто в FloodWait, кто frozen, кто dead."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"accounts": []})
    rows = []
    now = time.time()
    for phone, acc in list(ctx.accounts.items()):
        if getattr(acc, "folder_name", "") not in ("Прогрев", "Warmup"):
            continue
        fw_left = 0
        try:
            fw_until = getattr(acc, "floodwait_until", 0) or 0
            if fw_until > now:
                fw_left = int(fw_until - now)
        except Exception:
            pass
        rows.append({
            "phone": phone,
            "username": getattr(acc, "username", "") or "",
            "dead": bool(getattr(acc, "dead", False)),
            "frozen": bool(getattr(acc, "frozen", False)),
            "disabled": bool(getattr(acc, "disabled", False)),
            "disabled_reason": getattr(acc, "disabled_reason", "") or "",
            "idle": bool(getattr(acc, "idle", False)),
            "floodwait_left": fw_left,
            "spam_blocked": bool(getattr(acc, "spam_blocked", False)),
            "spam_block_note": (getattr(acc, "spam_block_note", "") or "")[:120],
            "warmup": bool(getattr(acc, "warmup", False)),
            "last_used_at": getattr(acc, "last_used_at", 0) or 0,
        })
    job = getattr(ctx, "warmup_job", None)
    summary = {"total": len(rows),
               "dead": sum(1 for r in rows if r["dead"]),
               "frozen": sum(1 for r in rows if r["frozen"]),
               "floodwait": sum(1 for r in rows if r["floodwait_left"] > 0),
               "spam_block": sum(1 for r in rows if r["spam_blocked"])}
    if job:
        summary["day"] = getattr(job.schedule, "current_day", 0)
        summary["total_days"] = getattr(job.schedule, "total_days", 0)
        summary["total_messages"] = getattr(job, "total_messages_sent", 0)
        summary["status"] = getattr(job, "status", "idle")
    return jsonify({"accounts": rows, "summary": summary})


@app.route("/api/retry_failed", methods=["POST"])
@login_required
def api_retry_failed():
    """Возвращает список цели с последней задачи, где НЕ получилось —
    клиент тут же закинет их в input задачи и нажмёт Старт."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 404
    data = request.json or {}
    kind = (data.get("kind") or "").strip()
    if kind == "join":
        j = getattr(ctx, "job", None)
        results = {}
        if j and getattr(j, "results", None):
            results.update({k: v for k, v in j.results.items()})
        for mj in (ctx.temp.get("multi_jobs") or []):
            if getattr(mj, "type", None) == "join" and getattr(mj, "results", None):
                results.update({k: v for k, v in mj.results.items()})
        failed = [k for k, v in results.items()
                  if not (str(v).startswith("joined") or str(v).startswith("already")
                          or str(v).startswith("request_sent"))]
        return jsonify({"ok": True, "items": failed, "count": len(failed)})
    if kind in ("spam", "dm"):
        want_dm = (kind == "dm")
        results, titles = _collect_spam_results(ctx, want_dm=want_dm)
        failed = [k for k, v in results.items() if not str(v).startswith("sent")]
        return jsonify({"ok": True, "items": failed, "count": len(failed)})
    return jsonify({"ok": False, "error": "kind должен быть join/spam/dm"}), 400


def _do_spam_export(ctx, open_folder: bool = True):
    """Общее ядро экспорта результатов рассылки по чатам. Возвращает dict или None."""
    results, titles = _collect_spam_results(ctx, want_dm=False)
    if not results:
        return None

    def _show(cid):
        t = titles.get(cid)
        return f"{cid}  {t}" if t else cid

    ok_lines, fail_lines = [], []
    by_reason = {"spamfilter": [], "flood": [], "slowmode": [], "no_rights": [],
                 "privacy": [], "not_found": [], "failed": []}
    for cid, status in results.items():
        s = str(status or "")
        low = s.lower()
        disp = _show(cid)
        if s.startswith("sent"):
            ok_lines.append(disp)
        else:
            fail_lines.append(disp)
            if "spamfilter" in low or "peer_flood" in low:
                by_reason["spamfilter"].append(disp)
            elif "flood" in low:
                by_reason["flood"].append(disp)
            elif "slowmode" in low:
                by_reason["slowmode"].append(disp)
            elif "no_rights" in low:
                by_reason["no_rights"].append(disp)
            else:
                by_reason["failed"].append(disp)

    reason_files = {
        "spamfilter": "\u0421\u043f\u0430\u043c_\u0431\u043b\u043e\u043a.txt",
        "flood": "\u0424\u043b\u0443\u0434.txt",
        "slowmode": "\u0421\u043b\u043e\u0443_\u043c\u043e\u0434.txt",
        "no_rights": "\u041d\u0435\u0442_\u043f\u0440\u0430\u0432.txt",
        "failed": "\u041f\u0440\u043e\u0447\u0435\u0435.txt",
    }
    folder, summary = _write_export_folder(
        "\u0420\u0430\u0441\u0441\u044b\u043b\u043a\u0430",  # Рассылка
        ok_lines, fail_lines, by_reason, reason_files,
        open_folder=open_folder,
    )
    return {"folder": str(folder), "summary": summary,
            "ok_count": len(ok_lines), "fail_count": len(fail_lines)}


@app.route("/api/spam/export_results", methods=["GET", "POST"])
@login_required
def api_spam_export_results():
    """Экспорт результатов рассылки по чатам: ✅ куда ушло / ❌ где не вышло."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 404
    res = _do_spam_export(ctx, open_folder=True)
    if not res:
        return jsonify({"ok": False, "error": "Нет данных — запустите хотя бы одну рассылку"}), 404
    return jsonify({"ok": True, **res})


def _do_dm_export(ctx, open_folder: bool = True):
    """Ядро экспорта результатов DM-рассылки."""
    results, titles = _collect_spam_results(ctx, want_dm=True)
    if not results:
        return None

    ok_lines, fail_lines = [], []
    by_reason = {"privacy": [], "spamfilter": [], "flood": [], "not_found": [],
                 "deactivated": [], "failed": []}
    for uname, status in results.items():
        s = str(status or "")
        low = s.lower()
        if s.startswith("sent"):
            ok_lines.append(uname)
        else:
            fail_lines.append(uname)
            if "privacy" in low:
                by_reason["privacy"].append(uname)
            elif "spamfilter" in low or "peer_flood" in low:
                by_reason["spamfilter"].append(uname)
            elif "flood" in low:
                by_reason["flood"].append(uname)
            elif "not_found" in low or "username_not" in low or "invalid" in low:
                by_reason["not_found"].append(uname)
            elif "deactivated" in low or "account_dead" in low:
                by_reason["deactivated"].append(uname)
            else:
                by_reason["failed"].append(uname)

    reason_files = {
        "privacy": "\u041f\u0440\u0438\u0432\u0430\u0442\u043d\u043e\u0441\u0442\u044c_\u043f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u044f.txt",
        "spamfilter": "\u0421\u043f\u0430\u043c_\u0431\u043b\u043e\u043a.txt",
        "flood": "\u0424\u043b\u0443\u0434.txt",
        "not_found": "\u042e\u0437\u0435\u0440_\u043d\u0435_\u043d\u0430\u0439\u0434\u0435\u043d.txt",
        "deactivated": "\u0414\u0435\u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u043d.txt",
        "failed": "\u041f\u0440\u043e\u0447\u0435\u0435.txt",
    }
    folder, summary = _write_export_folder(
        "DM", ok_lines, fail_lines, by_reason, reason_files, open_folder=open_folder,
    )
    return {"folder": str(folder), "summary": summary,
            "ok_count": len(ok_lines), "fail_count": len(fail_lines)}


@app.route("/api/dm/export_results", methods=["GET", "POST"])
@login_required
def api_dm_export_results():
    """Экспорт результатов DM: ✅ дошло / ❌ не дошло (приватность/спам/юзер не найден)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 404
    res = _do_dm_export(ctx, open_folder=True)
    if not res:
        return jsonify({"ok": False, "error": "Нет данных — запустите хотя бы одну DM-рассылку"}), 404
    return jsonify({"ok": True, **res})


def _do_join_export(ctx, open_folder: bool = True):
    """Ядро экспорта подписки (join). Собирает ctx.job + все multi_jobs."""
    results = {}
    job = getattr(ctx, "job", None)
    if job and getattr(job, "results", None):
        for k, v in job.results.items():
            results[k] = v
    for mj in (ctx.temp.get("multi_jobs") or []):
        if getattr(mj, "type", None) != "join":
            continue
        if getattr(mj, "results", None):
            for k, v in mj.results.items():
                results[k] = v
    if not results:
        return None

    ok_lines, fail_lines = [], []
    by_reason = {
        "spam": [], "flood": [], "expired": [], "invalid": [],
        "not_a_chat": [], "private": [], "other_fail": [],
    }
    for link, status in results.items():
        s = str(status or "")
        low = s.lower()
        if s.startswith("joined") or s.startswith("already") or s.startswith("request_sent"):
            ok_lines.append(link)
        else:
            fail_lines.append(link)
            if "spam" in low:
                by_reason["spam"].append(link)
            elif "flood" in low:
                by_reason["flood"].append(link)
            elif "expired" in low:
                by_reason["expired"].append(link)
            elif "invalid" in low or "not_found" in low or "username_not" in low:
                by_reason["invalid"].append(link)
            elif "not_a_chat" in low:
                by_reason["not_a_chat"].append(link)
            elif "private" in low or "forbidden" in low:
                by_reason["private"].append(link)
            else:
                by_reason["other_fail"].append(link)

    reason_files = {
        "spam": "\u0421\u043f\u0430\u043c_\u0431\u043b\u043e\u043a.txt",
        "flood": "\u0424\u043b\u0443\u0434.txt",
        "expired": "\u041f\u0440\u043e\u0441\u0440\u043e\u0447\u0435\u043d\u043e.txt",
        "invalid": "\u041d\u0435_\u043d\u0430\u0439\u0434\u0435\u043d\u043e.txt",
        "not_a_chat": "\u041d\u0435_\u0447\u0430\u0442_\u0431\u043e\u0442\u044b.txt",
        "private": "\u041f\u0440\u0438\u0432\u0430\u0442\u043d\u044b\u0435.txt",
        "other_fail": "\u041f\u0440\u043e\u0447\u0435\u0435.txt",
    }
    folder, summary = _write_export_folder(
        "\u041f\u043e\u0434\u043f\u0438\u0441\u043a\u0430",  # Подписка
        ok_lines, fail_lines, by_reason, reason_files, open_folder=open_folder,
    )
    # Переименовываем success-файл из «✅_Успех.txt» (дефолт _write_export_folder) в «✅_Подписался.txt»
    try:
        _src = folder / "\u2705_\u0423\u0441\u043f\u0435\u0445.txt"
        if _src.exists():
            _dst = folder / "\u2705_\u041f\u043e\u0434\u043f\u0438\u0441\u0430\u043b\u0441\u044f.txt"
            _src.rename(_dst)
            summary.pop("✅ Успех", None)
            summary["✅ Подписался"] = len(ok_lines)
    except Exception:
        pass

    return {"folder": str(folder), "summary": summary,
            "ok_count": len(ok_lines), "fail_count": len(fail_lines)}


@app.route("/api/join/export_results", methods=["GET", "POST"])
@login_required
def api_join_export_results():
    """Выгружает результаты последней подписки (по каждой ссылке — что вышло)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 404
    res = _do_join_export(ctx, open_folder=True)
    if not res:
        return jsonify({"ok": False, "error": "Нет данных — нужна хотя бы одна задача подписки"}), 404
    return jsonify({"ok": True, **res})


@app.route("/api/multi/stop", methods=["POST"])
@login_required
def api_multi_stop():
    """Останавливает все активные мульти-задачи (join/spam/dm)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 400
    data = request.json or {}
    phone_filter = (data.get("phone") or "").strip()  # если указан — стопим только этот акк
    job_type_filter = (data.get("type") or "").strip()  # если указан — только этот тип

    multi_jobs = ctx.temp.get("multi_jobs") or []
    stopped = 0
    for mj in multi_jobs:
        if mj.status not in ("running", "paused"):
            continue
        if phone_filter and mj.acc.phone != phone_filter:
            continue
        if job_type_filter and getattr(mj, "type", "spam") != job_type_filter:
            continue
        mj.stop_flag = True
        mj.pause_event.set()
        mj.status = "stopped"
        stopped += 1
    logger.info("[multi] Stop: %d jobs stopped (phone=%s, type=%s)", stopped, phone_filter or "all", job_type_filter or "all")
    return jsonify({"ok": True, "stopped": stopped})


@app.route("/api/multi/join", methods=["POST"])
@login_required
def api_multi_join():
    """Мульти-подписка: запуск join по нескольким аккаунтам одновременно."""
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx:
        return jsonify({"error": "no context"}), 500
    _ad = _build_ad_dict(ctx, request.json)
    if any(_ad.values()):
        _flags = ', '.join(k for k,v in _ad.items() if v)
        logging.getLogger("telepush.system").info("[защита-мульти] включено: %s", _flags)

    data = request.json or {}
    phones = data.get("phones") or []
    if not phones:
        return jsonify({"error": "Выберите аккаунты"}), 400

    links = ctx.temp.get("links") or []
    if not links:
        return jsonify({"error": "Загрузите ссылки"}), 400

    # Защита от двойного запуска
    existing = ctx.temp.get("multi_jobs") or []
    active = [j for j in existing if getattr(j, "status", None) == "running"]
    if active:
        return jsonify({"error": "Мульти-подписки уже запущены!"}), 400

    from core.join import run_join_job, deduplicate_links
    from models import JoinJob

    deduped, removed = deduplicate_links(links)
    if not deduped:
        return jsonify({"error": "Нет валидных ссылок"}), 400

    # connect_delay = единая настройка задержки между подключениями акков
    # (Настройки → Подключение аккаунтов). Применяется во всех массовых
    # операциях: мульти-подписка/рассылка/DM, проверка на спам, прогрев.
    stagger = float(ctx.user_settings.get("connect_delay", 3) or 3)

    async def _start_multi():
        from core.client_builder import ensure_connected
        import asyncio as _aio
        multi_jobs = []
        # Сначала подключаем все аккаунты, считаем реально доступных
        _valid_accs = []
        for phone in phones:
            acc = ctx.accounts.get(phone)
            if not acc or acc.dead or getattr(acc, "frozen", False):
                continue
            if not await ensure_connected(acc):
                continue
            _valid_accs.append((phone, acc))

        if not _valid_accs:
            return

        # Anti-detect: pre-spam-check на всём пуле (один проход)
        if _ad.get("pre_spam_check"):
            mlog = logging.getLogger("telepush.system")
            mlog.info("[защита-мульти-подписка] Проверяю %d акков на спам-блок...", len(_valid_accs))
            _accs_only = [a for _, a in _valid_accs]
            ok_list, blocked = await _apply_ad_pre_check(_accs_only, _ad, "system")
            if blocked:
                blocked_phones = {b[0].phone for b in blocked}
                _valid_accs = [(p, a) for (p, a) in _valid_accs if p not in blocked_phones]
                mlog.warning("[защита-мульти-подписка] %d акков в спам-блоке исключены", len(blocked))
            if not _valid_accs:
                mlog.warning("[защита-мульти-подписка] Все акки в спам-блоке — отмена")
                return

        started = 0
        _total_valid = len(_valid_accs)

        # Online keep-loops для всех валидных акков
        online_tasks = []
        online_stop = None
        if _ad.get("online"):
            from utils.anti_detect import keep_online_loop
            online_stop = asyncio.Event()
            for _, _a in _valid_accs:
                if getattr(_a, 'client', None):
                    online_tasks.append(asyncio.create_task(
                        keep_online_loop(_a.client, online_stop, module="system")))

        for phone, acc in _valid_accs:
            # Распределяем ссылки между реально подключёнными
            _chunk_size = max(1, len(deduped) // _total_valid)
            _start_idx = started * _chunk_size
            _end_idx = _start_idx + _chunk_size if started < _total_valid - 1 else len(deduped)
            _my_links = deduped[_start_idx:_end_idx]
            if not _my_links:
                continue
            try:
                _mj_cap = max(0, min(2000, int(data.get("max_per_acc", 0) or 0)))
            except Exception:
                _mj_cap = 0
            job = JoinJob(
                owner_id=uid, acc=acc,
                queue=list(_my_links), total=len(_my_links), dup_removed=removed if started == 0 else 0,
                max_per_acc=_mj_cap,
            )
            job.type = "join"
            job.status = "running"
            job.pause_event.set()
            job.job_id = f"multi_join_{phone}"

            if started == 0:
                ctx.job = job
            multi_jobs.append(job)

            async def _run(j=job):
                from core.client_builder import release_after_task
                try:
                    await run_join_job(j)
                except Exception as e:
                    logger.error("[multi-join] %s crashed: %s", j.acc.phone, e, exc_info=True)
                    j.status = "stopped"
                    j.current_step = f"Ошибка: {e}"
                finally:
                    try:
                        await release_after_task(j.acc)
                    except Exception:
                        pass

            _aio.ensure_future(_run())
            started += 1
            await _aio.sleep(stagger)

        ctx.temp["multi_jobs"] = multi_jobs

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if not loop or loop.is_closed():
        return jsonify({"error": "Event loop not ready"}), 500

    asyncio.run_coroutine_threadsafe(_start_multi(), loop)
    return jsonify({"ok": True, "started": len(phones), "total": len(deduped), "removed": removed})


@app.route("/api/multi/spam", methods=["POST"])
@login_required
def api_multi_spam():
    """Мульти-рассылка по чатам: распределение между аккаунтами."""
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx:
        return jsonify({"error": "no context"}), 500
    _ad = _build_ad_dict(ctx, request.json)
    if any(_ad.values()):
        _flags = ', '.join(k for k,v in _ad.items() if v)
        logging.getLogger("telepush.system").info("[защита-мульти] включено: %s", _flags)

    data = request.json or {}
    phones = data.get("phones") or []
    text = (data.get("text") or "").strip()
    if not phones:
        return jsonify({"error": "Выберите аккаунты"}), 400
    if not text:
        return jsonify({"error": "Введите текст рассылки"}), 400

    from core.spam import run_spam_job, collect_writable_group_ids
    from models import SpamJob

    # connect_delay = единая настройка задержки между подключениями акков
    # (Настройки → Подключение аккаунтов). Применяется во всех массовых
    # операциях: мульти-подписка/рассылка/DM, проверка на спам, прогрев.
    stagger = float(ctx.user_settings.get("connect_delay", 3) or 3)

    async def _start_multi():
        from core.client_builder import ensure_connected
        import asyncio as _aio
        multi_jobs = []
        started = 0
        for phone in phones:
            acc = ctx.accounts.get(phone)
            if not acc or acc.dead or getattr(acc, "frozen", False):
                continue
            busy = _get_account_busy(ctx, phone)
            if busy:
                continue
            if not await ensure_connected(acc):
                continue

            # Собираем чаты для этого аккаунта
            try:
                targets = await collect_writable_group_ids(acc.client)
            except Exception:
                targets = []
            if not targets:
                continue

            titles = {str(t): "" for t in targets}
            try:
                _ms_cap = max(0, min(2000, int(data.get("max_per_acc", 0) or 0)))
            except Exception:
                _ms_cap = 0
            job = SpamJob(
                owner_id=uid, acc=acc, text=text,
                targets=list(targets), titles=titles,
                total=len(targets),
                max_per_acc=_ms_cap,
            )
            job.type = "spam"
            job.status = "running"
            job.pause_event.set()
            job.job_id = f"multi_spam_{phone}"
            job.ad_config = _ad
            multi_jobs.append(job)

            async def _run(j=job, _online_flag=_ad.get("online")):
                from core.client_builder import release_after_task
                from utils.anti_detect import keep_online_loop
                online_stop = None
                online_task = None
                if _online_flag and j.acc.client:
                    online_stop = asyncio.Event()
                    online_task = asyncio.create_task(
                        keep_online_loop(j.acc.client, online_stop, module="system"))
                try:
                    await run_spam_job(j)
                except Exception as e:
                    logger.error("[multi-spam] %s: %s", j.acc.phone, e, exc_info=True)
                    j.status = "stopped"
                finally:
                    if online_stop:
                        online_stop.set()
                        if online_task:
                            try:
                                await asyncio.wait_for(online_task, timeout=2)
                            except Exception:
                                online_task.cancel()
                    try:
                        await release_after_task(j.acc)
                    except Exception:
                        pass

            _aio.ensure_future(_run())
            started += 1
            await _aio.sleep(stagger)

        # Сохраняем в temp
        existing = ctx.temp.get("multi_jobs") or []
        existing.extend(multi_jobs)
        ctx.temp["multi_jobs"] = existing

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if not loop or loop.is_closed():
        return jsonify({"error": "Event loop not ready"}), 500

    asyncio.run_coroutine_threadsafe(_start_multi(), loop)
    return jsonify({"ok": True, "started": len(phones)})


@app.route("/api/multi/dm", methods=["POST"])
@login_required
def api_multi_dm():
    """Мульти-DM: распределение юзеров между аккаунтами."""
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx:
        return jsonify({"error": "no context"}), 500
    _ad = _build_ad_dict(ctx, request.json)
    if any(_ad.values()):
        _flags = ', '.join(k for k,v in _ad.items() if v)
        logging.getLogger("telepush.system").info("[защита-мульти] включено: %s", _flags)

    data = request.json or {}
    phones = data.get("phones") or []
    text = (data.get("text") or "").strip()
    raw_targets = (data.get("targets") or "").strip()
    if not phones:
        return jsonify({"error": "Выберите аккаунты"}), 400
    if not text:
        return jsonify({"error": "Введите текст"}), 400
    if not raw_targets:
        return jsonify({"error": "Введите юзернеймы"}), 400

    from core.spam import run_spam_job, parse_dm_targets
    from models import SpamJob

    # parse_dm_targets возвращает (targets, titles, failed_count) — 3 значения
    all_targets, _titles, _failed = parse_dm_targets(raw_targets)
    if not all_targets:
        return jsonify({"error": "Не найдено юзернеймов"}), 400

    # Распределяем юзеров между аккаунтами поровну
    valid_phones = [p for p in phones if p in ctx.accounts and not ctx.accounts[p].dead
                    and not getattr(ctx.accounts[p], "frozen", False)
                    and not _get_account_busy(ctx, p)]
    if not valid_phones:
        return jsonify({"error": "Нет доступных аккаунтов"}), 400

    chunk_size = max(1, len(all_targets) // len(valid_phones))
    chunks = []
    for i in range(len(valid_phones)):
        start = i * chunk_size
        end = start + chunk_size if i < len(valid_phones) - 1 else len(all_targets)
        chunks.append(all_targets[start:end])

    # connect_delay = единая настройка задержки между подключениями акков
    # (Настройки → Подключение аккаунтов). Применяется во всех массовых
    # операциях: мульти-подписка/рассылка/DM, проверка на спам, прогрев.
    stagger = float(ctx.user_settings.get("connect_delay", 3) or 3)

    async def _start_multi():
        from core.client_builder import ensure_connected
        import asyncio as _aio
        multi_jobs = []
        started = 0
        for idx, phone in enumerate(valid_phones):
            acc = ctx.accounts.get(phone)
            if not acc or idx >= len(chunks) or not chunks[idx]:
                continue
            if not await ensure_connected(acc):
                continue

            my_targets = chunks[idx]
            # Per-acc лимит из data.max_per_acc — каждый акк остановится после
            # N успешных сообщений независимо от того сколько ему дали целей.
            try:
                _cap = max(0, min(2000, int(data.get("max_per_acc", 0) or 0)))
            except Exception:
                _cap = 0
            job = SpamJob(
                owner_id=uid, acc=acc, text=text,
                targets=my_targets, titles={},
                total=len(my_targets), dm_mode=True,
                max_per_acc=_cap,
            )
            job.type = "dm"
            job.status = "running"
            job.pause_event.set()
            job.job_id = f"multi_dm_{phone}"
            job.ad_config = _ad
            multi_jobs.append(job)

            async def _run(j=job, _online_flag=_ad.get("online")):
                from core.client_builder import release_after_task
                from utils.anti_detect import keep_online_loop
                online_stop = None
                online_task = None
                if _online_flag and j.acc.client:
                    online_stop = asyncio.Event()
                    online_task = asyncio.create_task(
                        keep_online_loop(j.acc.client, online_stop, module="system"))
                try:
                    await run_spam_job(j)
                except Exception as e:
                    logger.error("[multi-dm] %s: %s", j.acc.phone, e, exc_info=True)
                    j.status = "stopped"
                finally:
                    if online_stop:
                        online_stop.set()
                        if online_task:
                            try:
                                await asyncio.wait_for(online_task, timeout=2)
                            except Exception:
                                online_task.cancel()
                    try:
                        await release_after_task(j.acc)
                    except Exception:
                        pass

            _aio.ensure_future(_run())
            started += 1
            await _aio.sleep(stagger)

        existing = ctx.temp.get("multi_jobs") or []
        existing.extend(multi_jobs)
        ctx.temp["multi_jobs"] = existing

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if not loop or loop.is_closed():
        return jsonify({"error": "Event loop not ready"}), 500

    asyncio.run_coroutine_threadsafe(_start_multi(), loop)
    return jsonify({"ok": True, "started": len(valid_phones), "total": len(all_targets),
                     "per_account": chunk_size})


# ── API: Accounts ────────────────────────────────────────────────────────────

# Гео-флаги по префиксу номера. Module-level чтобы переиспользовать в нескольких
# endpoints (api_accounts, api_warmup_status и др.)
_GEO_FLAGS = {
    "998": "uz", "996": "kg", "995": "ge", "994": "az", "993": "tm",
    "992": "tj", "380": "ua", "375": "by", "373": "md", "372": "ee",
    "371": "lv", "370": "lt", "90": "tr", "86": "cn", "82": "kr",
    "81": "jp", "66": "th", "65": "sg", "62": "id", "60": "my",
    "55": "br", "54": "ar", "52": "mx", "51": "pe", "49": "de",
    "48": "pl", "47": "no", "46": "se", "45": "dk", "44": "gb",
    "43": "at", "42": "cz", "41": "ch", "40": "ro", "39": "it",
    "36": "hu", "34": "es", "33": "fr", "32": "be", "31": "nl",
    "30": "gr", "27": "za", "20": "eg", "91": "in", "92": "pk",
    "93": "af", "84": "vn", "1": "us", "7": "ru", "971": "ae",
    "972": "il", "974": "qa", "966": "sa", "961": "lb", "420": "cz",
}
_GEO_PREFIX_KEYS = sorted(_GEO_FLAGS.keys(), key=len, reverse=True)


def _detect_geo(phone_num):
    p = str(phone_num).lstrip("+")
    for prefix in _GEO_PREFIX_KEYS:
        if p.startswith(prefix):
            cc = _GEO_FLAGS[prefix]
            return (cc.upper(), cc)
    return ("??", "xx")


@app.route("/api/accounts")
@login_required
def api_accounts():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"accounts": []})

    accs = []
    for phone, acc in list(ctx.accounts.items()):
        geo_code, geo_flag = _detect_geo(phone)
        first_name = getattr(acc, "first_name", "") or ""
        username = getattr(acc, "username", "") or ""
        busy_module = _get_account_busy(ctx, phone)
        # Proxy info
        proxy_str = ""
        proxy_obj = getattr(acc, "proxy", None)
        if proxy_obj:
            proxy_str = getattr(proxy_obj, "addr", "") or str(proxy_obj)
            # Mask credentials in proxy string
            if "@" in proxy_str:
                proxy_str = proxy_str.split("@")[-1]
        accs.append({
            "phone": phone,
            "available": acc.is_available,
            "dead": getattr(acc, "dead", False),
            "dead_reason": getattr(acc, "dead_reason", "") or "",
            "verified": getattr(acc, "verified", False),
            "frozen": getattr(acc, "frozen", False),
            "spam_blocked": getattr(acc, "spam_blocked", False),
            "joins": acc.stats.joins_ok,
            "spam_sent": acc.stats.spam_sent,
            "geo": geo_code,
            "flag": geo_flag,
            "first_name": first_name,
            "username": username,
            "last_name": getattr(acc, "last_name", "") or "",
            "is_premium": getattr(acc, "is_premium", False),
            "status_text": acc.status_text(),
            "idle": getattr(acc, "idle", False),
            "folder": getattr(acc, "folder_name", ""),
            "monitor": getattr(acc, "monitor", False),
            "warmup": getattr(acc, "warmup", False),
            "spamblock": getattr(acc, "spamblock_json", None) or None,
            "session_created": getattr(acc, "session_created", ""),
            "register_time": getattr(acc, "register_time", 0),
            "floodwait": acc.floodwait_remaining_str if acc.is_floodwaiting else "",
            "busy": busy_module or "",
            "proxy": proxy_str,
        })
    # Диагностика для UI: путь к папке Аккаунты + скан .session на диске
    # если список пуст. Помогает клиенту понять «почему 0 аккаунтов после рестарта».
    resp = {"accounts": accs, "active": ctx.active}
    if not accs:
        try:
            resp["accounts_dir"] = str(ACCOUNTS_DIR)
            disk_count = 0
            bad_names = []
            if ACCOUNTS_DIR.exists():
                for _d in ACCOUNTS_DIR.iterdir():
                    if not _d.is_dir():
                        continue
                    for _f in _d.iterdir():
                        if _f.suffix == '.session':
                            disk_count += 1
                            if not _f.stem.isdigit():
                                bad_names.append(_f.name)
            resp["disk_sessions_count"] = disk_count
            if bad_names:
                resp["bad_session_names"] = bad_names[:5]
        except Exception as e:
            logger.debug("[api_accounts] диагностика не сработала: %s", e)
    return jsonify(resp)


@app.route("/api/accounts/select", methods=["POST"])
@login_required
def api_accounts_select():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phone = data.get("phone", "")
    if phone in ctx.accounts:
        ctx.active = phone
        return jsonify({"ok": True})
    return jsonify({"error": "аккаунт не найден"}), 404


@app.route("/api/accounts/check_spam/<phone>", methods=["POST"])
@login_required
def api_check_spam(phone):
    """Проверить аккаунт на спамблок через @SpamBot.
    Сначала глубокая проверка живости (frozen / dead / session-revoked) —
    раньше эта кнопка только дёргала @SpamBot и пропускала замороженные аккаунты
    как «чистые»."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 404

    from core.client_builder import ensure_connected, release_after_task
    from core.spam import probe_spamblock, classify_spambot_note_scope, deep_health_check_account

    async def _check():
        if not await ensure_connected(acc):
            if acc.dead:
                return {"ok": True, "phone": phone, "status": "dead",
                        "note": getattr(acc, "dead_reason", "") or "dead"}
            if getattr(acc, "frozen", False):
                return {"ok": True, "phone": phone, "status": "frozen",
                        "note": getattr(acc, "frozen_reason", "") or "frozen"}
            return {"error": "не удалось подключить"}
        try:
            # Глубокая проверка перед @SpamBot — выявит frozen/dead до
            # реального запроса в SpamBot (который замороженным просто не отвечает).
            health = await deep_health_check_account(acc)
            if health["status"] != "alive":
                return {"ok": True, "phone": phone, "status": health["status"],
                        "note": health.get("reason", "")}
            blocked, note = await probe_spamblock(acc, force=True)
            scope = classify_spambot_note_scope(note)
            return {
                "ok": True,
                "phone": phone,
                "blocked": blocked,
                "scope": scope,
                "note": note[:500] if note else "",
                "status": "spam" if blocked else "clean",
            }
        finally:
            try: await release_after_task(acc)
            except Exception: pass

    # Глубокая проверка — 6 шагов по ~10с каждый, плюс @SpamBot ~10с.
    # Старый таймаут 20с не успевал даже до шага 4; стало 90с с запасом.
    result = _run_async(_check(), timeout=90)
    if not result:
        return jsonify({"error": "таймаут проверки"}), 500
    if "error" in result:
        return jsonify(result), 500
    return jsonify(result)


@app.route("/api/accounts/check_spam_all", methods=["POST"])
@login_required
def api_check_spam_all():
    """Массовая проверка аккаунтов на спамблок.
    Без body — все. С body {"phones": [...]} — только указанные (для
    «Проверить выделенные» из UI).
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    from core.client_builder import ensure_connected, release_after_task
    from core.spam import probe_spamblock, deep_health_check_account

    data = request.json if request.is_json else {}
    phones_in = (data or {}).get("phones") or []
    if isinstance(phones_in, list) and phones_in:
        wanted = {str(p).strip() for p in phones_in if str(p).strip()}
        phones = [p for p in ctx.accounts.keys() if p in wanted]
    else:
        phones = list(ctx.accounts.keys())
    if not phones:
        return jsonify({"error": "нет аккаунтов"}), 400

    # Run in background
    check_state = ctx.temp.setdefault("spam_check", {})
    check_state["status"] = "running"
    check_state["total"] = len(phones)
    check_state["checked"] = 0
    check_state["results"] = {}
    check_state["cancel"] = False

    # Параллельная проверка (до 5 одновременно) — на 100 акках было 200+ сек,
    # стало ~40 сек. Telegram лимиты не трогаем, это пассивная проверка.
    SEM_LIMIT = 5
    sem = asyncio.Semaphore(SEM_LIMIT)

    async def _check_one(phone):
        async with sem:
            if check_state.get("cancel"):
                check_state["results"][phone] = "cancelled"
                check_state["checked"] += 1
                return
            acc = ctx.accounts.get(phone)
            _was_connected = False
            if not acc:
                check_state["results"][phone] = "skip"
            elif acc.dead:
                # Уже помечен мёртвым — не дёргаем повторно, отдаём текущую причину
                _r = getattr(acc, "dead_reason", "") or "dead"
                check_state["results"][phone] = f"dead:{_r}"
            elif getattr(acc, "frozen", False):
                check_state["results"][phone] = "frozen"
            else:
                try:
                    if not await ensure_connected(acc):
                        # ensure_connected мог уже выставить acc.dead — берём из него
                        if acc.dead:
                            _r = getattr(acc, "dead_reason", "") or "dead"
                            check_state["results"][phone] = f"dead:{_r}"
                        elif getattr(acc, "frozen", False):
                            check_state["results"][phone] = "frozen"
                        else:
                            check_state["results"][phone] = "offline"
                    else:
                        _was_connected = True
                        # Глубокая проверка живости — 6 шагов от мягкого read-метода
                        # к жёсткому write-action (account.UpdateStatus). Раньше
                        # эти 6 шагов были inline-кодом тут — вынесли в helper
                        # core.spam.deep_health_check_account чтобы кнопка
                        # «Проверить спам» (single) использовала ту же логику.
                        health = await deep_health_check_account(acc)
                        if health["status"] == "dead":
                            check_state["results"][phone] = f"dead:{health.get('reason','')}"
                            check_state["checked"] += 1
                            return
                        if health["status"] == "frozen":
                            check_state["results"][phone] = "frozen"
                            check_state["checked"] += 1
                            return
                        # Шаг 2 — спамблок (только если живой и не замороженный)
                        blocked, _note = await probe_spamblock(acc, force=True)
                        check_state["results"][phone] = "spam" if blocked else "clean"
                except Exception as e:
                    check_state["results"][phone] = f"error:{e}"
                    logger.debug("[spam_check] %s: %s", phone, e)
                finally:
                    if _was_connected:
                        try:
                            await release_after_task(acc)  # grace=0 теперь по умолчанию
                        except Exception:
                            pass
            check_state["checked"] += 1
            # Задержка между подключениями аккаунтов — общая настройка
            # user_settings.connect_delay (Настройки → Подключение аккаунтов).
            # Дефолт 3 сек синхронизирован с UI — чтобы клиент увидев «3» в
            # поле видел реальную задержку, а не скрытые 0.5.
            _delay = max(0.0, float(ctx.user_settings.get("connect_delay", 3) or 3))
            if _delay > 0:
                await asyncio.sleep(_delay)

    async def _check_all():
        await asyncio.gather(*[_check_one(p) for p in phones], return_exceptions=True)
        # Перенос .session/.json в правильные папки по новому статусу.
        # После этого UI таблица сама фильтрует мёртвых из «В работе» →
        # они показываются только во вкладке «Мёртвые» (folder_name).
        try:
            from core.accounts_folder import sync_accounts_folder
            sync_accounts_folder(ctx.accounts)
        except Exception as _se:
            logger.debug("[spam_check] sync folders error: %s", _se)
        check_state["status"] = "stopped" if check_state.get("cancel") else "done"

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_check_all(), loop)

    return jsonify({"ok": True, "total": len(phones)})


@app.route("/api/accounts/check_spam_stop", methods=["POST"])
@login_required
def api_check_spam_stop():
    """Останавливает массовую проверку спамблока."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 400
    check = ctx.temp.get("spam_check", {})
    if check.get("status") != "running":
        return jsonify({"ok": False, "error": "Проверка не запущена"}), 400
    check["cancel"] = True
    logger.info("[spam_check] Stop requested")
    return jsonify({"ok": True})


@app.route("/api/accounts/check_spam_status")
@login_required
def api_check_spam_status():
    """Статус массовой проверки."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"status": "idle"})
    check = ctx.temp.get("spam_check", {})
    return jsonify({
        "status": check.get("status", "idle"),
        "total": check.get("total", 0),
        "checked": check.get("checked", 0),
        "results": check.get("results", {}),
    })


@app.route("/api/accounts/avatar/<phone>")
@login_required
def api_account_avatar(phone):
    """Аватарка аккаунта из Telegram (с кэшем)."""
    ctx = _get_ctx()
    if not ctx:
        return "", 404
    acc = ctx.accounts.get(phone)
    if not acc:
        return "", 404

    # Из кэша
    cached = getattr(acc, "avatar_cached", None)
    if cached:
        return Response(cached, mimetype="image/jpeg",
                        headers={"Cache-Control": "public, max-age=3600"})

    # Загрузить из TG если подключён
    if not acc.client or getattr(acc, "idle", False):
        return "", 404

    async def _get():
        try:
            photo = await acc.client.download_profile_photo("me", bytes, download_big=False)
            if photo:
                acc.avatar_cached = photo
                return photo
        except Exception as e:
            logger.debug("Avatar error %s: %s", phone, e)
        return None

    result = _run_async(_get(), timeout=10)
    if result:
        return Response(result, mimetype="image/jpeg",
                        headers={"Cache-Control": "public, max-age=3600"})
    return "", 404


@app.route("/api/accounts/reset_device", methods=["POST"])
@login_required
def api_reset_device():
    """Забыть кэшированное устройство для номера. После этого при следующем
    добавлении этого номера сгенерируется новый фингерпринт (модель ПК +
    свежая версия Telegram Desktop)."""
    data = request.json or {}
    phone = (data.get("phone") or "").strip()
    if not phone:
        return jsonify({"error": "phone обязателен"}), 400
    try:
        from core.client_builder import forget_device
        removed = forget_device(phone)
        return jsonify({"ok": True, "removed": bool(removed)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/accounts/send_code", methods=["POST"])
@login_required
def api_send_code():
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phone = data.get("phone", "").strip()
    if not phone:
        return jsonify({"error": "введите номер"}), 400

    # Закрываем предыдущий полумёртвый клиент (если первая попытка сорвалась
    # по таймауту — он висит в памяти и мешает новому подключению)
    prev = ctx.temp.pop("auth_client", None)
    if prev is not None:
        async def _drop_prev(c=prev):
            try:
                from core.client_builder import safe_disconnect
                await safe_disconnect(c)
            except Exception:
                pass
        try:
            _run_async(_drop_prev(), timeout=5)
        except Exception:
            pass

    async def _send():
        last_exc = None
        for attempt in range(1, 3):
            try:
                client = await _build_client_for_phone(uid, phone)
                result = await client.send_code_request(phone)
                ctx.temp["auth_client"] = client
                ctx.temp["auth_phone"] = phone
                ctx.temp["auth_hash"] = result.phone_code_hash
                return {"ok": True}
            except RuntimeError as e:
                # Наш явный отказ из-за отсутствия живого прокси
                return {"error": str(e)}
            except (TimeoutError, asyncio.TimeoutError, ConnectionError, OSError) as e:
                last_exc = e
                logger.info("send_code attempt %d failed: %s — retry", attempt, type(e).__name__)
                try:
                    if 'client' in locals():
                        from core.client_builder import safe_disconnect as _sd
                        await _sd(client)
                except Exception:
                    pass
                await asyncio.sleep(1)
                continue
        if last_exc:
            raise last_exc
        return {"ok": False}

    # 60с: холодный старт после обновления/рестарта требует времени на DC-миграцию
    result = _run_async(_send(), timeout=60)
    if result is None:
        return jsonify({"error": "Telegram не отвечает. Проверь интернет/прокси и попробуй ещё раз."}), 503
    if isinstance(result, dict) and result.get("error"):
        return jsonify({"error": result["error"]}), 400
    return jsonify({"ok": True})


@app.route("/api/accounts/verify_code", methods=["POST"])
@login_required
def api_verify_code():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    code = data.get("code", "").strip()
    if not code or len(code) > 10:
        return jsonify({"error": "введите код (макс 10 символов)"}), 400

    client = ctx.temp.get("auth_client")
    phone = ctx.temp.get("auth_phone")
    phone_hash = ctx.temp.get("auth_hash")
    if not client or not phone or not phone_hash:
        return jsonify({"error": "сначала отправьте код"}), 400
    logger.debug("[audit] uid=%s VERIFY_CODE phone=...%s", session.get("uid"), phone[-4:] if phone else "?")

    async def _verify():
        try:
            await client.sign_in(phone, code, phone_code_hash=phone_hash)
            # Явный flush сессии на диск — Telethon комитит auth_key автоматически,
            # но OS-буфер может не успеть синкнуться при быстром закрытии программы.
            # Клиент жаловался: добавил аккаунт → закрыл → запустил → аккаунт пропал.
            try:
                _sess = getattr(client, "session", None)
                if _sess and hasattr(_sess, "save"):
                    _sess.save()
                _conn = getattr(_sess, "_conn", None)
                if _conn:
                    try: _conn.commit()
                    except Exception: pass
                # Диагностика: показываем что реально записалось на диск
                try:
                    _sf = getattr(_sess, "filename", "?")
                    from pathlib import Path as _P
                    _p = _P(str(_sf))
                    _sz = _p.stat().st_size if _p.exists() else -1
                    logger.info("✅ Аккаунт добавлен. Сессия: %s (%d байт)", _sf, _sz)
                except Exception:
                    pass
            except Exception as _se:
                logger.debug("[verify] session save hint failed: %s", _se)
            me = await client.get_me()
            from models import Account
            from core.client_builder import load_device, load_proxy
            from config import USERS_DIR, OFFICIAL_TDESKTOP_API_ID, OFFICIAL_TDESKTOP_APP_HASH
            USERS_DIR.mkdir(parents=True, exist_ok=True)
            # Нормализуем номер к digit-only (без +), чтобы ключ в ctx.accounts
            # совпадал с тем что при autoload при следующем запуске. autoload
            # использует stem .session-файла (цифры), и без этого после ре-старта
            # ctx.accounts["+998xxx"] не находился по ключу "998xxx".
            phone_key = str(phone).lstrip("+").strip()

            # Закрываем клиент сразу после добавления — SQLite закроется корректно,
            # журнал удалится, auth_key гарантированно на диске. При следующем
            # использовании ensure_connected поднимет клиент заново из .session.
            # Это нужно потому что клиенты жаловались на пропадание аккаунта
            # при быстром закрытии программы после добавления.
            try:
                _sess_file = getattr(getattr(client, "session", None), "filename", None)
                from core.client_builder import safe_disconnect as _sd
                await _sd(client)
                # После disconnect SQLite conn закрыт, данные точно на диске
                from pathlib import Path as _P
                if _sess_file:
                    _p = _P(str(_sess_file))
                    if _p.exists():
                        logger.info("✅ Сессия сохранена: %s (%d байт)", _p.name, _p.stat().st_size)
                    else:
                        logger.error("❌ Сессия НЕ сохранилась на диск: %s", _sess_file)
            except Exception as _de:
                logger.warning("[verify] disconnect warning: %s", _de)

            acc = Account(phone=phone_key, client=None)
            acc.session_path = USERS_DIR / phone_key
            acc.dev = load_device(phone_key)
            acc.proxy = load_proxy()
            acc.first_name = getattr(me, 'first_name', '') or ''
            acc.username = getattr(me, 'username', '') or ''
            acc.folder_name = USERS_DIR.name
            acc.stats.joins_ok = 0
            acc.stats.spam_sent = 0
            acc.idle = True
            import time as _time
            acc.idle_since = _time.time()
            ctx.accounts[phone_key] = acc

            # Создаём JSON с метаданными — чтобы после перезапуска использовалось
            # то же самое устройство (иначе Telegram может отозвать сессию)
            import json as _json, datetime as _dt
            meta = {
                "app_id": OFFICIAL_TDESKTOP_API_ID,
                "app_hash": OFFICIAL_TDESKTOP_APP_HASH,
                "sdk": acc.dev.system_version,
                "device": acc.dev.device_model,
                "app_version": acc.dev.app_version,
                "lang_pack": acc.dev.lang_code,
                "system_lang_pack": acc.dev.system_lang_code,
                "twoFA": None,
                "id": getattr(me, 'id', 0),
                "phone": phone_key,
                "username": getattr(me, 'username', '') or '',
                "first_name": getattr(me, 'first_name', '') or '',
                "last_name": getattr(me, 'last_name', '') or '',
                "is_premium": bool(getattr(me, 'premium', False)),
                "has_profile_pic": bool(getattr(me, 'photo', None)),
                "spamblock": "free",
                "session_file": phone_key,
                "session_created_date": _dt.datetime.now(_dt.timezone.utc).isoformat(),
                "last_connect_date": _dt.datetime.now(_dt.timezone.utc).isoformat(),
                "register_time": int(_dt.datetime.now().timestamp()),
                "status": "Free",
            }
            try:
                (USERS_DIR / f"{phone_key}.json").write_text(
                    _json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
                )
            except Exception as _je:
                logger.warning("Failed to write account JSON: %s", _je)

            if not ctx.active:
                ctx.active = phone_key
            ctx.temp.pop("auth_client", None)
            ctx.temp.pop("auth_phone", None)
            ctx.temp.pop("auth_hash", None)
            return {"ok": True, "phone": phone_key}
        except Exception as e:
            err_str = str(e).lower()
            if "2fa" in err_str or "password" in err_str or "two-step" in err_str or "SessionPasswordNeeded" in str(type(e).__name__):
                return {"needs_2fa": True}
            return {"error": str(e)}

    result = _run_async(_verify(), timeout=20)
    if result is None:
        return jsonify({"error": "таймаут"}), 503
    return jsonify(result)


@app.route("/api/accounts/verify_2fa", methods=["POST"])
@login_required
def api_verify_2fa():
    """Ввод 2FA пароля."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    password = data.get("password", "").strip()
    if not password:
        return jsonify({"error": "Введите пароль"}), 400

    client = ctx.temp.get("auth_client")
    phone = ctx.temp.get("auth_phone")
    if not client or not phone:
        return jsonify({"error": "Сессия авторизации истекла, начните заново"}), 400

    async def _verify_2fa():
        try:
            await client.sign_in(password=password)
            me = await client.get_me()
            from models import Account
            from core.client_builder import load_device, load_proxy
            from config import USERS_DIR, OFFICIAL_TDESKTOP_API_ID, OFFICIAL_TDESKTOP_APP_HASH
            USERS_DIR.mkdir(parents=True, exist_ok=True)
            phone_key = str(phone).lstrip("+").strip()

            # Disconnect для гарантированного flush сессии на диск
            try:
                _sess_file = getattr(getattr(client, "session", None), "filename", None)
                from core.client_builder import safe_disconnect as _sd
                await _sd(client)
                from pathlib import Path as _P
                if _sess_file:
                    _p = _P(str(_sess_file))
                    if _p.exists():
                        logger.info("✅ Сессия (2FA) сохранена: %s (%d байт)", _p.name, _p.stat().st_size)
            except Exception as _de:
                logger.warning("[verify_2fa] disconnect warning: %s", _de)

            acc = Account(phone=phone_key, client=None)
            acc.session_path = USERS_DIR / phone_key
            acc.dev = load_device(phone_key)
            acc.proxy = load_proxy()
            acc.first_name = getattr(me, 'first_name', '') or ''
            acc.folder_name = USERS_DIR.name
            acc.username = getattr(me, 'username', '') or ''
            acc.idle = True
            import time as _time
            acc.idle_since = _time.time()
            ctx.accounts[phone_key] = acc

            # Создаём JSON с метаданными
            import json as _json, datetime as _dt
            meta = {
                "app_id": OFFICIAL_TDESKTOP_API_ID,
                "app_hash": OFFICIAL_TDESKTOP_APP_HASH,
                "sdk": acc.dev.system_version,
                "device": acc.dev.device_model,
                "app_version": acc.dev.app_version,
                "lang_pack": acc.dev.lang_code,
                "system_lang_pack": acc.dev.system_lang_code,
                "twoFA": password,  # сохраняем для автологина
                "id": getattr(me, 'id', 0),
                "phone": phone_key,
                "username": getattr(me, 'username', '') or '',
                "first_name": getattr(me, 'first_name', '') or '',
                "last_name": getattr(me, 'last_name', '') or '',
                "is_premium": bool(getattr(me, 'premium', False)),
                "has_profile_pic": bool(getattr(me, 'photo', None)),
                "spamblock": "free",
                "session_file": phone_key,
                "session_created_date": _dt.datetime.now(_dt.timezone.utc).isoformat(),
                "last_connect_date": _dt.datetime.now(_dt.timezone.utc).isoformat(),
                "register_time": int(_dt.datetime.now().timestamp()),
                "status": "Free",
            }
            try:
                (USERS_DIR / f"{phone_key}.json").write_text(
                    _json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
                )
            except Exception as _je:
                logger.warning("Failed to write account JSON: %s", _je)

            if not ctx.active:
                ctx.active = phone_key
            ctx.temp.pop("auth_client", None)
            ctx.temp.pop("auth_phone", None)
            ctx.temp.pop("auth_hash", None)
            return {"ok": True, "phone": phone_key}
        except Exception as e:
            err_str = str(e).lower()
            if "invalid" in err_str or "wrong" in err_str:
                return {"error": "Неверный пароль 2FA"}
            return {"error": str(e)}

    result = _run_async(_verify_2fa(), timeout=20)
    if result is None:
        return jsonify({"error": "таймаут"}), 503
    return jsonify(result)


@app.route("/api/accounts/upload_session", methods=["POST"])
@login_required
def api_upload_session():
    """Upload .session + .json файлов (пара обязательна)."""
    ctx = _get_ctx()
    uid = session.get("uid")
    if not ctx:
        return jsonify({"error": "no context"}), 500

    from config import STAGING_DIR, USERS_DIR
    import shutil

    files = request.files.getlist("file")
    if not files:
        f = request.files.get("file")
        if f:
            files = [f]

    if not files:
        return jsonify({"error": "нет файлов"}), 400

    # Сортируем файлы по типу
    session_files = {}  # name_without_ext -> path
    json_files = {}     # name_without_ext -> path

    for f in files:
        # Берём только базовое имя файла — защищает от ../../ и любых разделителей
        fname = Path(f.filename or "").name
        if not fname or fname in (".", ".."):
            continue
        # Разрешаем только [A-Za-z0-9._-], остальное — заменяем
        import re as _re
        fname = _re.sub(r"[^A-Za-z0-9._-]", "_", fname)[:128]
        name_base = fname.rsplit(".", 1)[0] if "." in fname else fname
        name_base = name_base[:64]

        if fname.endswith(".session"):
            tmp_path = STAGING_DIR / (secrets.token_hex(4) + "_" + fname)
            f.save(str(tmp_path))
            session_files[name_base] = tmp_path
        elif fname.endswith(".json"):
            tmp_path = STAGING_DIR / (secrets.token_hex(4) + "_" + fname)
            f.save(str(tmp_path))
            json_files[name_base] = tmp_path

    # Проверяем что есть пара
    if not session_files:
        return jsonify({"error": "Нужен .session файл"}), 400
    if not json_files:
        return jsonify({"error": "Нужен .json файл к сессии! Загрузите .session и .json вместе"}), 400

    # Матчим пары строго по имени — fallback "first available" мог привязать
    # чужой .json к .session и испортить метаданные аккаунта
    paired = []
    unmatched_sessions = []
    for name, sess_path in session_files.items():
        json_path = json_files.get(name)
        if json_path:
            paired.append((name, sess_path, json_path))
        else:
            unmatched_sessions.append(sess_path)
    # Чистим неспаренные .session
    for sp in unmatched_sessions:
        try:
            sp.unlink(missing_ok=True)
        except Exception:
            pass

    if not paired:
        return jsonify({"error": "Не удалось сопоставить .session и .json файлы. Назовите их одинаково (например: 998901234567.session + 998901234567.json)"}), 400

    # Берём первую пару (для единичной загрузки) или обрабатываем все
    name, sess_path, json_path = paired[0]

    # Извлекаем номер из json
    phone = None
    try:
        import json as _json
        jdata = _json.loads(json_path.read_text(encoding="utf-8"))
        phone = str(jdata.get("phone", "") or jdata.get("phone_number", "") or name)
        phone = phone.lstrip("+").strip()
    except Exception:
        phone = name

    if not phone or len(phone) < 5:
        return jsonify({"error": "Не удалось определить номер телефона из json"}), 400

    # Копируем прямо в В работе/ (без подпапки)
    USERS_DIR.mkdir(parents=True, exist_ok=True)
    dest_session = USERS_DIR / f"{phone}.session"
    dest_json = USERS_DIR / f"{phone}.json"

    try:
        shutil.copy2(str(sess_path), str(dest_session))
        shutil.copy2(str(json_path), str(dest_json))
    except Exception as e:
        logger.error("Upload copy error uid=%s: %s", uid, e)
        return jsonify({"error": "Ошибка копирования файлов"}), 500

    # Чистим staging
    for _, sp, jp in paired:
        try:
            sp.unlink(missing_ok=True)
        except Exception:
            pass
        try:
            jp.unlink(missing_ok=True)
        except Exception:
            pass

    # Подключаем аккаунт через Telethon
    async def _load():
        try:
            client = await _build_client_for_phone(uid, phone)
            me = await client.get_me()
            if not me:
                return {"error": "Сессия невалидна — аккаунт не авторизован"}
            actual_phone = getattr(me, "phone", phone) or phone
            from models import Account
            from core.client_builder import load_device, load_proxy
            acc = Account(phone=actual_phone, client=client)
            acc.session_path = USERS_DIR / actual_phone
            acc.dev = load_device(actual_phone)
            acc.proxy = load_proxy()
            acc.first_name = getattr(me, 'first_name', '') or ''
            acc.username = getattr(me, 'username', '') or ''
            acc.folder_name = USERS_DIR.name
            ctx.accounts[actual_phone] = acc
            if not ctx.active:
                ctx.active = actual_phone
            return {"ok": True, "phone": actual_phone}
        except Exception as e:
            return {"error": str(e)}

    result = _run_async(_load(), timeout=30)
    if result is None:
        return jsonify({"error": "таймаут"}), 503
    return jsonify(result)




# ── API: Folders ────────────────────────────────────────────────────────────

@app.route('/api/folders')
@login_required
def api_folders():
    """Lists all folders in ACCOUNTS_DIR."""
    from config import ACCOUNTS_DIR
    folders = []
    try:
        if ACCOUNTS_DIR.exists():
            for d in sorted(ACCOUNTS_DIR.iterdir()):
                if d.is_dir():
                    # Count session files
                    count = len(list(d.glob("*.session")))
                    folders.append({"name": d.name, "count": count})
    except Exception as e:
        logger.error("Folders list error: %s", e)
    return jsonify({"folders": folders})


@app.route('/api/folders/create', methods=['POST'])
@login_required
def api_folders_create():
    """Create a new folder in ACCOUNTS_DIR."""
    from config import ACCOUNTS_DIR
    data = request.json or {}
    name = (data.get("name") or "").strip()
    if not name or len(name) > 50:
        return jsonify({"error": "empty name"}), 400
    if "/" in name or "\\" in name or ".." in name:
        return jsonify({"error": "недопустимое имя папки"}), 400
    try:
        (ACCOUNTS_DIR / name).mkdir(parents=True, exist_ok=True)
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ── API: Scan Folder ────────────────────────────────────────────────────────

@app.route('/api/accounts/open_folder', methods=['POST'])
@login_required
def api_open_folder():
    """Открывает папку аккаунтов в проводнике.
    Принимает optional body: {folder: "имя_подпапки"} — открыть конкретную папку."""
    from config import USERS_DIR, ACCOUNTS_DIR
    import subprocess
    data = request.json if request.is_json else {}
    folder_name = (data.get("folder") or "").strip() if isinstance(data, dict) else ""
    try:
        if folder_name:
            target = ACCOUNTS_DIR / folder_name
        else:
            target = USERS_DIR
        target.mkdir(parents=True, exist_ok=True)
        subprocess.Popen(['explorer', str(target)])
        return jsonify({"ok": True, "path": str(target)})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route('/api/accounts/folders')
@login_required
def api_accounts_folders():
    """Список доступных папок для переноса аккаунтов."""
    from config import ACCOUNTS_DIR
    folders = []
    try:
        for d in sorted(ACCOUNTS_DIR.iterdir()):
            if d.is_dir():
                folders.append(d.name)
    except Exception:
        pass
    return jsonify({"folders": folders})


@app.route('/api/accounts/move_to_folder', methods=['POST'])
@login_required
def api_accounts_move_to_folder():
    """Перемещает .session и .json (+ WAL/SHM/journal) в указанную папку.
    Версия rev3: ВСЕГДА сканит все подпапки Аккаунты/ по имени файла, не
    полагаясь на acc.session_path (мог устареть после sync_accounts_folder).
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    from config import ACCOUNTS_DIR
    import shutil
    data = request.json or {}
    phones = data.get("phones") or []
    if isinstance(phones, str):
        phones = [phones]
    target_folder = (data.get("folder") or "").strip()
    if not phones or not target_folder:
        return jsonify({"error": "Укажите телефоны и папку"}), 400
    target_dir = ACCOUNTS_DIR / target_folder
    if not target_dir.exists():
        try:
            target_dir.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            return jsonify({"error": f"Не удалось создать папку: {e}"}), 500

    def _find_files_for_phone(phone):
        """Возвращает список путей (session/json/wal/shm/journal) для phone
        во всех подпапках Аккаунты/. Включает корневую ACCOUNTS_DIR."""
        found = []
        # Корневая папка Аккаунты/
        candidates_dirs = [ACCOUNTS_DIR]
        try:
            for sub in ACCOUNTS_DIR.iterdir():
                if sub.is_dir():
                    candidates_dirs.append(sub)
        except Exception as e:
            logger.warning("[move] не смог обойти ACCOUNTS_DIR: %s", e)
        for d in candidates_dirs:
            for fname in (
                f"{phone}.session", f"{phone}.json",
                f"{phone}.session-wal", f"{phone}.session-shm",
                f"{phone}.session-journal",
            ):
                p = d / fname
                if p.exists() and p.is_file():
                    found.append(p)
        return found

    # Перед переносом ОТКЛЮЧАЕМ Telethon-клиентов у переносимых акков —
    # иначе Windows блокирует файл .session и shutil.move кидает [WinError 32].
    # После переноса при следующей задаче клиент снова подключится автоматически.
    def _disconnect_acc_sync(_acc):
        try:
            cli = getattr(_acc, "client", None)
            if not cli: return
            from core.client_builder import safe_disconnect
            st = _get_state()
            loop = getattr(st, "event_loop", None) if st else None
            if loop and not loop.is_closed():
                fut = asyncio.run_coroutine_threadsafe(safe_disconnect(cli), loop)
                try: fut.result(timeout=8)
                except Exception: pass
            _acc.client = None
        except Exception as _de:
            logger.debug("[move] disconnect %s: %s", getattr(_acc, "phone", "?"), _de)

    # Также стопаем мониторинг если он работает на акке который мы переносим —
    # иначе он удерживает session-файл через свой handler.
    pool = list(getattr(ctx, "monitor_jobs", None) or [])
    legacy_mj = getattr(ctx, "monitor_job", None)
    if legacy_mj and legacy_mj not in pool:
        pool.append(legacy_mj)
    monitor_was_running = False
    for mj in pool:
        try:
            mj_phone = getattr(getattr(mj, "acc", None), "phone", "")
            if mj_phone in phones and getattr(mj, "status", "") == "running":
                mj.stop_flag = True
                mj.status = "stopped"
                monitor_was_running = True
                # Снимаем handler
                _h = getattr(mj, "_handler", None)
                _cli = getattr(getattr(mj, "acc", None), "client", None)
                if _h is not None and _cli is not None:
                    try: _cli.remove_event_handler(_h)
                    except Exception: pass
                logger.info("[move] остановил monitor_job на %s перед переносом файлов", mj_phone)
        except Exception:
            pass

    # Disconnect всех акков из списка переноса
    for ph in phones:
        a = ctx.accounts.get(ph)
        if a:
            _disconnect_acc_sync(a)
    # Дать ОС секунду на освобождение файлов
    import time as _t
    _t.sleep(1.0)

    moved = 0
    errors = []
    for phone in phones:
        acc = ctx.accounts.get(phone)
        if not acc:
            errors.append(f"{phone}: не найден в ctx")
            # Всё равно попробуем переместить файлы, если они есть на диске
        try:
            files = _find_files_for_phone(phone)
            logger.info("[move] %s: найдено %d файл(ов) для переноса в '%s'",
                        phone, len(files), target_folder)
            if not files:
                errors.append(f"{phone}: файлы не найдены в Аккаунты/ (искал session/json/wal/shm/journal во всех подпапках)")
                continue
            file_moved = False
            for src in files:
                dest = target_dir / src.name
                try:
                    if src.resolve() == dest.resolve():
                        # Уже на месте — пропускаем
                        continue
                    if dest.exists():
                        # Чтобы shutil.move не упал на Windows когда dest существует
                        try: dest.unlink()
                        except Exception: pass
                    # Пробуем move с ретраями (Windows иногда держит файл ещё
                    # секунду после disconnect — sqlite + WAL могут долетать)
                    last_err = None
                    for attempt in range(4):
                        try:
                            shutil.move(str(src), str(dest))
                            last_err = None
                            break
                        except Exception as _me:
                            last_err = _me
                            time.sleep(0.5 * (attempt + 1))
                    if last_err is not None:
                        errors.append(f"{phone}: {src.name}: {last_err}")
                        logger.warning("[move] %s: %s -> %s упал после 4 попыток: %s", phone, src, dest, last_err)
                        continue
                    file_moved = True
                    logger.debug("[move] %s -> %s", src, dest)
                    # Обновляем пути в Account
                    if acc:
                        if src.suffix == ".session":
                            acc.session_path = dest
                        elif src.suffix == ".json":
                            acc.json_path = dest
                except Exception as _me:
                    errors.append(f"{phone}: shutil.move {src.name}: {_me}")
                    logger.warning("[move] %s: %s -> %s упал: %s", phone, src, dest, _me)
            if not file_moved:
                # Все файлы уже на месте — это не ошибка, пометим как success
                file_moved = True
            if acc is not None:
                acc.folder_name = target_folder
                # Sync flags under new folder
                acc.warmup = target_folder in ("\u041f\u0440\u043e\u0433\u0440\u0435\u0432", "Warmup")
                acc.monitor = target_folder in ("\u041c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433", "Monitoring")
                if target_folder in ("\u041c\u0451\u0440\u0442\u0432\u044b\u0435", "Dead"):
                    acc.dead = True
                    if not getattr(acc, "dead_reason", ""):
                        acc.dead_reason = "manual_move"
                elif target_folder in ("\u0417\u0430\u043c\u043e\u0440\u043e\u0437\u043a\u0430", "Frozen"):
                    acc.frozen = True
                    if not getattr(acc, "frozen_reason", ""):
                        acc.frozen_reason = "manual_move"
                elif target_folder in ("\u0412 \u0441\u043f\u0430\u043c", "\u0412\u0435\u0447\u043d\u044b\u0439 \u0441\u043f\u0430\u043c", "Spam", "Permanent_spam"):
                    acc.spam_blocked = True
                    if hasattr(acc, "spam_permanent"):
                        acc.spam_permanent = target_folder in ("\u0412\u0435\u0447\u043d\u044b\u0439 \u0441\u043f\u0430\u043c", "Permanent_spam")
                else:
                    acc.dead = False
                    acc.dead_reason = ""
                    acc.frozen = False
                    acc.frozen_reason = ""
                    acc.spam_blocked = False
                    if hasattr(acc, "spam_permanent"):
                        acc.spam_permanent = False
                # Сохраняем статус в персистентный JSON — чтобы выживал
                # рестарты независимо от того в какую папку файл переехал.
                try:
                    from core.account_state import save_account_state
                    save_account_state(
                        phone,
                        dead=getattr(acc, "dead", False),
                        dead_reason=getattr(acc, "dead_reason", "") or "",
                        frozen=getattr(acc, "frozen", False),
                        frozen_reason=getattr(acc, "frozen_reason", "") or "",
                        spam_blocked=getattr(acc, "spam_blocked", False),
                        spam_permanent=getattr(acc, "spam_permanent", False),
                    )
                except Exception:
                    pass
            moved += 1
            try:
                import logging as _l
                _l.getLogger("telepush").info("[move] OK %s -> %s", phone, target_folder)
            except Exception:
                pass
        except Exception as e:
            errors.append(f"{phone}: {type(e).__name__}: {e}")
            try:
                import logging as _l
                _l.getLogger("telepush").warning("[move] FAIL %s -> %s: %s", phone, target_folder, e)
            except Exception:
                pass

    # Если перенесли в Мониторинг и у юзера есть конфиг мониторинга — сразу
    # пере-стартуем джоб на новом акке: стопаем старый (если есть), поднимаем
    # на свежем. Так клиент не ждёт 2 минуты пока watchdog подхватит.
    if moved > 0 and target_folder in ("Мониторинг", "Monitoring"):
        try:
            _restart_monitor_for_ctx(ctx, "moved-to-monitoring")
        except Exception as _re:
            logger.debug("[move] auto-restart monitor failed: %s", _re)

    return jsonify({"ok": True, "moved": moved, "errors": errors})


def _restart_monitor_for_ctx(ctx, reason: str):
    """Останавливает текущий monitor_job (если есть) и поднимает заново на акке
    с флагом monitor=True (или fallback на phone из настроек). Используется
    когда клиент переносит акк в папку Мониторинг — чтобы переключение было
    мгновенным, а не через watchdog."""
    _mon = (ctx.user_settings.get("monitor") or {})
    if not _mon.get("chats") or not _mon.get("keywords"):
        # Конфиг ещё не задан — нечего стартовать
        return
    # Останавливаем старый джоб
    old = getattr(ctx, "monitor_job", None)
    if old:
        try: old.stop_flag = True
        except Exception: pass
        try: old.status = "stopped"
        except Exception: pass
        try:
            _h = getattr(old, "_handler", None)
            _cli = getattr(getattr(old, "acc", None), "client", None)
            if _h is not None and _cli is not None:
                _cli.remove_event_handler(_h)
        except Exception:
            pass
    ctx.monitor_job = None

    # Включаем autostart чтобы watchdog тоже подхватил, и сохраняем
    _mon["autostart"] = True
    ctx.user_settings["monitor"] = _mon
    try: _save_user_settings(ctx.user_settings)
    except Exception: pass

    # Запускаем новый — повторяем логику из api_monitor_start
    import re as _re_mod
    _acc = None
    for _ph, _a in ctx.accounts.items():
        if getattr(_a, "monitor", False) and not _a.dead and not getattr(_a, "frozen", False):
            _acc = _a
            break
    if _acc is None:
        _phone = (_mon.get("phone") or "").strip()
        if _phone:
            _candidate = ctx.accounts.get(_phone)
            if _candidate and not _candidate.dead and not getattr(_candidate, "frozen", False):
                _acc = _candidate
    if _acc is None:
        logger.info("[monitor] %s: нет живого акка для рестарта", reason)
        return

    _chats = [x.strip() for x in _re_mod.split(r"[\n\r]+", _mon.get("chats") or "") if x.strip()]
    _kws = [x.strip() for x in _re_mod.split(r"[\n\r,;]+", _mon.get("keywords") or "") if x.strip()]
    _iws = [x.strip() for x in _re_mod.split(r"[\n\r,;]+", _mon.get("ignore_words") or "") if x.strip()]
    if not _chats or not _kws:
        return

    _bot_token = ""
    try:
        _enc = _mon.get("bot_token_enc") or ""
        if _enc:
            from utils.secrets_storage import decrypt_secret
            _bot_token = decrypt_secret(_enc)
    except Exception:
        pass

    from core.monitor import MonitorJob, run_monitor_job
    from core.client_builder import ensure_connected
    job = MonitorJob(
        owner_id=session.get("uid") or 0,
        acc=_acc,
        chat_inputs=_chats,
        keywords=_kws,
        ignore_words=_iws,
        target=_mon.get("target") or "me",
        auto_dm_text=_mon.get("auto_dm_text") or "",
        bot_token=_bot_token,
        bot_chat_id=_mon.get("bot_chat_id") or "",
    )
    ctx.monitor_job = job

    async def _run():
        if not await ensure_connected(_acc):
            job.status = "stopped"
            return
        try:
            await run_monitor_job(job)
        except Exception as e:
            logger.error("[monitor] %s crashed: %s", reason, e)
            job.status = "stopped"

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
        logger.info("[monitor] 🔁 рестарт на акке %s (%s)", _acc.phone, reason)


@app.route('/api/accounts/delete', methods=['POST'])
@login_required
def api_accounts_delete():
    """Удалить акк: рвёт коннект, физически стирает .session/.json (+ WAL/SHM)
    в любом подкаталоге Аккаунты/, убирает из ctx.accounts."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phones = data.get("phones") or ([data.get("phone")] if data.get("phone") else [])
    phones = [p for p in phones if p]
    if not phones:
        return jsonify({"error": "Укажите телефон(ы)"}), 400

    deleted = 0
    errors = []
    for phone in phones:
        acc = ctx.accounts.get(phone)
        # Дисконнект — ОБЯЗАТЕЛЬНО ждать завершения, иначе SQLite session-файл
        # остаётся залоченным и unlink ниже падает с PermissionError тихо.
        # Раньше fire-and-forget, аккаунт удалялся из ctx, но файл оставался,
        # после «Обновить» scan_folder его находил и возвращал в список.
        if acc and getattr(acc, "client", None):
            try:
                from core.client_builder import safe_disconnect
                st = _get_state()
                loop = getattr(st, "event_loop", None) if st else None
                if loop and not loop.is_closed():
                    fut = asyncio.run_coroutine_threadsafe(safe_disconnect(acc.client), loop)
                    try:
                        fut.result(timeout=8)  # ждём реального завершения disconnect
                    except Exception as _wait_e:
                        logger.warning("[delete] disconnect wait %s: %s", phone, _wait_e)
                acc.client = None
            except Exception as _de:
                logger.debug("[delete] disconnect %s: %s", phone, _de)
        # Дополнительный sleep — Windows иногда держит handle ещё ~0.5с
        # после disconnect (антивирус, индексер).
        time.sleep(0.5)
        # Удаляем все session/json РЕКУРСИВНО по ВСЕМ подпапкам Аккаунты/.
        # С 3 retry'ями + exp backoff, потому что Windows иногда держит handle
        # дольше после disconnect.
        # КЛЮЧЕВОЙ момент: ищем не только `{phone}.session`, но и любой файл,
        # цифры в имени которого совпадают с phone. Иначе пропускаются дубликаты
        # с пробелами/плюсами в названии (например `994 51 797 8579.session`),
        # которые при следующем autoload авто-переименовываются и возвращают
        # «удалённый» аккаунт обратно в список.
        files_removed = 0
        files_failed = []
        try:
            import re as _re
            search_roots = []
            if ACCOUNTS_DIR.exists():
                search_roots.append(ACCOUNTS_DIR)
            if USERS_DIR.exists():
                search_roots.append(USERS_DIR)
            phone_digits = _re.sub(r"\D", "", phone or "")
            files_to_delete = []
            for root in search_roots:
                # Сканируем весь подграф один раз и матчим по digits в stem'е.
                try:
                    for f in root.rglob("*"):
                        if not f.is_file():
                            continue
                        suff = f.suffix.lower()
                        # Принимаем только наши расширения, чтобы не задеть случайные .txt/.zip
                        if suff not in (".session", ".json"):
                            # ещё session-* sidecars
                            name_lower = f.name.lower()
                            if not (".session-" in name_lower):
                                continue
                        # stem без всех расширений: для 994.session-journal stem='994.session'
                        base = f.name
                        # Убираем все известные хвосты
                        for tail in (".session-journal", ".session-wal", ".session-shm",
                                     ".session", ".json"):
                            if base.lower().endswith(tail):
                                base = base[: -len(tail)]
                                break
                        base_digits = _re.sub(r"\D", "", base)
                        if base_digits and base_digits == phone_digits:
                            files_to_delete.append(f)
                except Exception:
                    pass

            for f in files_to_delete:
                # 3 попытки с задержкой 0/0.5/1.5с — против Windows file-locks.
                ok = False
                last_err = None
                for attempt in range(3):
                    if attempt > 0:
                        time.sleep(0.5 * attempt)
                    try:
                        f.unlink()
                        ok = True
                        break
                    except Exception as _fe:
                        last_err = _fe
                if ok:
                    if f.suffix.lower() in (".session", ".json"):
                        files_removed += 1
                    logger.debug("[delete] %s", f)
                else:
                    files_failed.append(f"{f.name}: {last_err}")
                    logger.warning("[delete] не смог удалить %s после 3 попыток: %s", f, last_err)
        except Exception as _ee:
            errors.append(f"{phone}: {_ee}")
        if files_failed:
            errors.append(f"{phone}: {len(files_failed)} файлов не удалось удалить (возможно занят антивирусом или Telegram-клиентом)")
        # In-memory blacklist: даже если файл физически не удалился (Windows lock),
        # до рестарта программы scan_folder и autoload не должны его возвращать.
        # Иначе клиент в логе видит «Удалён», обновляет — и аккаунт снова в списке.
        try:
            bl = ctx.temp.setdefault("deleted_blacklist", set())
            bl.add(phone)
        except Exception:
            pass
        # Чистим persisted state — иначе при следующем запуске даже если
        # файл вернулся, статус из JSON всплывёт.
        try:
            from core.account_state import clear_account_state
            clear_account_state(phone)
        except Exception:
            pass
        # Убираем из ctx
        if phone in ctx.accounts:
            del ctx.accounts[phone]
            if ctx.active == phone:
                ctx.active = next(iter(ctx.accounts), None)
        # Забываем сохранённое устройство (при повторном добавлении сгенерируется новое)
        try:
            from core.client_builder import forget_device
            forget_device(phone)
        except Exception:
            pass
        if files_removed > 0 or phone not in ctx.accounts:
            deleted += 1
            logger.info("[delete] удалён акк %s (файлов: %d)", phone, files_removed)

    return jsonify({"ok": True, "deleted": deleted, "errors": errors})


@app.route('/api/accounts/sync', methods=['POST'])
@login_required
def api_accounts_sync():
    """Синхронизация: удаляет из списка аккаунты чьи файлы удалены с диска."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    from config import USERS_DIR, ACCOUNTS_DIR
    removed = 0
    for phone in list(ctx.accounts.keys()):
        acc = ctx.accounts[phone]
        sp = getattr(acc, "session_path", None)

        # Проверяем файл сессии по нескольким путям
        found = False
        if sp and sp.exists():
            found = True
        else:
            # Ищем в известных папках
            for search_dir in [USERS_DIR, ACCOUNTS_DIR]:
                if not search_dir.exists():
                    continue
                # В корне папки
                if (search_dir / f"{phone}.session").exists():
                    found = True
                    break
                # В подпапках
                for sub in search_dir.iterdir():
                    if sub.is_dir() and (sub / f"{phone}.session").exists():
                        found = True
                        break
                if found:
                    break

        if not found:
            # Файл сессии не найден нигде — убираем
            if getattr(acc, "client", None):
                try:
                    import asyncio
                    from core.client_builder import safe_disconnect
                    loop = getattr(_get_state(), "event_loop", None)
                    if loop and not loop.is_closed():
                        asyncio.run_coroutine_threadsafe(safe_disconnect(acc.client), loop)
                except Exception:
                    pass
            del ctx.accounts[phone]
            removed += 1
            logger.debug("[sync] Removed %s — session file not found", phone)

            # Забываем устройство — при повторном добавлении номера сгенерируется
            # новое (актуальная версия Telegram Desktop, новая модель ПК). Иначе
            # клиент жалуется: удалил аккаунт, добавил заново — Telegram видит
            # то же самое устройство что и было.
            try:
                from core.client_builder import forget_device
                forget_device(phone)
            except Exception:
                pass

            # Чистим WAL/SHM если остались
            if sp:
                for suffix in ("-wal", "-shm", "-journal"):
                    try:
                        p = Path(str(sp) + suffix)
                        if p.exists():
                            p.unlink()
                    except Exception:
                        pass

    if ctx.active and ctx.active not in ctx.accounts:
        ctx.active = next(iter(ctx.accounts), None)
    return jsonify({"ok": True, "removed": removed})


@app.route('/api/accounts/scan_folder', methods=['POST'])
@login_required
def api_scan_folder():
    """Сканирует data/sessions/ на новые .session+.json пары и подключает."""
    ctx = _get_ctx()
    uid = session.get('uid')
    if not ctx:
        return jsonify({'error': 'no context'}), 500

    from config import USERS_DIR, ACCOUNTS_DIR

    uid = session.get("uid")
    found = 0
    added = 0
    errors = []
    existing_phones = set(ctx.accounts.keys())

    # Ищем .session файлы ТОЛЬКО в Аккаунты/. Раньше скан также смотрел в
    # USERS_DIR/data/ и в корень клиента + копировал найденные туда же.
    # Это создавало «зомби-копии»: клиент удалял файл из Аккаунты/, нажимал
    # «Обновить», скан находил копию в data/ → акк возвращался. Теперь
    # источник истины — только Аккаунты/, копии в USERS_DIR не создаются.
    session_files = []
    seen = set()
    if ACCOUNTS_DIR.exists():
        try:
            for f in ACCOUNTS_DIR.rglob("*.session"):
                if f.is_file() and f.stem.isdigit() and f.stem not in seen:
                    seen.add(f.stem)
                    session_files.append(f)
        except Exception:
            pass

    # Чистим зомби-копии в USERS_DIR — те что уже не имеют пары в Аккаунты/.
    # Без этого старые установки (до этого фикса) тащили мусор.
    try:
        if USERS_DIR.exists():
            digits_in_accounts = seen
            for f in USERS_DIR.iterdir():
                if not f.is_file():
                    continue
                if f.stem.isdigit() and f.suffix in ('.session', '.json', '.session-journal'):
                    if f.stem not in digits_in_accounts:
                        try:
                            f.unlink()
                        except Exception:
                            pass
    except Exception:
        pass

    # Phones что были удалены в этом запуске — не возвращаем при сканировании
    # (Windows может держать .session-файл залоченным после disconnect, и до
    # рестарта мы не сможем его реально стереть; но повторно подхватывать
    # удалённый акк точно не должны).
    deleted_blacklist = ctx.temp.get("deleted_blacklist", set()) if hasattr(ctx, "temp") else set()
    for sf in session_files:
        phone = sf.stem
        if phone in existing_phones:
            continue
        if phone in deleted_blacklist:
            logger.info("[scan_folder] %s в blacklist (был удалён в этой сессии) — пропускаю", phone)
            continue
        found += 1
        jf = sf.with_suffix('.json')
        if not jf.exists():
            errors.append(f'{phone}: нет .json файла')
            continue
        # Файлы остаются прямо в Аккаунты/ — никаких копий в data/.
        # autoload_all_users заберёт их из той папки где они лежат.
        added += 1

    if added > 0:
        async def _reload():
            try:
                from core.scheduler import autoload_all_users
                await autoload_all_users()
            except Exception as e:
                logger.error("[scan_folder] reload error: %s", e)

        _run_async(_reload(), timeout=30)

    return jsonify({
        'ok': True,
        'found': found,
        'added': added,
        'errors': errors[:10],
        'msg': f'Найдено {found} новых, подключено {added}' + (f', ошибок {len(errors)}' if errors else ''),
    })


# ── API: Telegram Preview (read-only через существующий клиент) ───────────────

@app.route("/api/tg/dialogs")
@login_required
def api_tg_dialogs():
    """Список диалогов аккаунта — чаты, группы, ЛС."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"dialogs": []})
    data = request.json if request.is_json else {}
    phone = (data or {}).get("phone") or ctx.active
    if not phone:
        return jsonify({"dialogs": []})
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"dialogs": [], "stats": {}})

    stats = {
        "joins": acc.stats.joins_ok,
        "spam": acc.stats.spam_sent,
        "dm": getattr(acc.stats, 'dm_sent', 0),
    }

    # Если аккаунт занят задачей — не трогаем, может сломать сессию
    busy = _get_account_busy(ctx, phone)
    if busy:
        return jsonify({"dialogs": [], "stats": stats,
                         "busy": True, "msg": f"Аккаунт занят: {busy}. Просмотр недоступен во время работы."})

    # Авто-сон убран в 1.4.55. Если клиент не подключён — коннектим сразу,
    # без отдельной кнопки «Обновить». Пусто возвращаем только при реальной
    # ошибке подключения.
    if not acc.client or getattr(acc, 'idle', False):
        async def _connect():
            from core.client_builder import ensure_connected
            return await ensure_connected(acc)
        connected = _run_async(_connect(), timeout=15)
        if not connected:
            return jsonify({"dialogs": [], "stats": stats,
                             "error": "Не удалось подключить аккаунт"})

    async def _get():
        dialogs = []
        try:
            async for d in acc.client.iter_dialogs(limit=50):
                ent = d.entity
                dtype = "user"
                if hasattr(ent, "megagroup") and ent.megagroup:
                    dtype = "group"
                elif hasattr(ent, "broadcast") and ent.broadcast:
                    dtype = "channel"
                elif hasattr(ent, "title"):
                    dtype = "group"

                last_msg = ""
                if d.message and d.message.text:
                    last_msg = d.message.text[:100]

                dialogs.append({
                    "id": d.id,
                    "name": d.name or str(d.id),
                    "type": dtype,
                    "unread": d.unread_count,
                    "last_message": last_msg,
                    "date": d.date.isoformat() if d.date else "",
                })
        except Exception as e:
            logger.warning("[tg-preview] dialogs error: %s", e)
        return dialogs

    result = _run_async(_get(), timeout=15)
    return jsonify({"dialogs": result or [], "stats": stats})


@app.route("/api/tg/messages")
@login_required
def api_tg_messages():
    """Последние сообщения из конкретного чата с медиа-инфо."""
    ctx = _get_ctx()
    if not ctx or not ctx.active:
        return jsonify({"messages": []})
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"messages": []})

    chat_id = request.args.get("chat_id")
    if not chat_id:
        return jsonify({"messages": []})

    async def _get():
        msgs = []
        try:
            cid = int(chat_id)
            async for m in acc.client.iter_messages(cid, limit=30):
                sender_name = ""
                if m.sender:
                    sender_name = getattr(m.sender, "first_name", "") or getattr(m.sender, "title", "") or str(m.sender_id)

                # Detect media type
                media_type = None
                media_name = ""
                if m.photo:
                    media_type = "photo"
                elif m.video:
                    media_type = "video"
                    media_name = getattr(m.video, "file_name", "") or ""
                elif m.sticker:
                    media_type = "sticker"
                elif m.gif:
                    media_type = "gif"
                elif m.voice:
                    media_type = "voice"
                elif m.video_note:
                    media_type = "video_note"
                elif m.document:
                    media_type = "document"
                    media_name = getattr(m.document, "file_name", "") or "file"
                elif m.audio:
                    media_type = "audio"
                    media_name = getattr(m.audio, "file_name", "") or ""

                # Format time
                time_str = ""
                if m.date:
                    time_str = m.date.strftime("%H:%M")

                # Reply info
                reply_text = ""
                reply_sender = ""
                if m.reply_to and hasattr(m, 'reply_to_msg_id') and m.reply_to_msg_id:
                    try:
                        rm = await acc.client.get_messages(cid, ids=m.reply_to_msg_id)
                        if rm:
                            reply_text = (rm.text or rm.message or "")[:100]
                            if rm.sender:
                                reply_sender = getattr(rm.sender, "first_name", "") or str(rm.sender_id)
                    except Exception:
                        pass

                msgs.append({
                    "id": m.id,
                    "text": (m.text or m.message or "")[:500],
                    "sender": sender_name,
                    "sender_id": m.sender_id,
                    "date": m.date.isoformat() if m.date else "",
                    "time": time_str,
                    "out": m.out,
                    "edited": bool(m.edit_date),
                    "media": media_type,
                    "media_name": media_name,
                    "chat_id": cid,
                    "reply_text": reply_text,
                    "reply_sender": reply_sender,
                })
        except Exception as e:
            logger.warning("[tg-preview] messages error: %s", e)
        return msgs

    result = _run_async(_get(), timeout=15)
    return jsonify({"messages": result or []})


@app.route("/api/tg/media")
@login_required
def api_tg_media():
    """Download media from a message — returns image/video bytes."""
    ctx = _get_ctx()
    if not ctx or not ctx.active:
        return "no context", 404
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return "no client", 404

    chat_id = request.args.get("chat_id")
    msg_id = request.args.get("msg_id")
    thumb = request.args.get("thumb")  # "1" for thumbnail only
    if not chat_id or not msg_id:
        return "missing params", 400

    async def _download():
        try:
            cid = int(chat_id)
            mid = int(msg_id)
            msgs = await acc.client.get_messages(cid, ids=mid)
            if not msgs or not msgs.media:
                return None, None
            m = msgs

            # For thumbnails or photos — download small version
            if thumb == "1" and m.photo:
                data = await acc.client.download_media(m, bytes, thumb=-1)
                return data, "image/jpeg"

            # Full download (limit 5MB to avoid hanging)
            if m.photo:
                data = await acc.client.download_media(m, bytes)
                return data, "image/jpeg"
            elif m.sticker:
                data = await acc.client.download_media(m, bytes)
                mime = "image/webp"
                if hasattr(m.sticker, "mime_type"):
                    mime = m.sticker.mime_type or mime
                return data, mime
            elif m.video or m.gif or m.video_note:
                # Only thumbnail for video (full video too heavy)
                if m.video and hasattr(m.video, "thumbs") and m.video.thumbs:
                    data = await acc.client.download_media(m, bytes, thumb=-1)
                    return data, "image/jpeg"
                return None, None
            elif m.document:
                size = getattr(m.document, "size", 0) or 0
                if size > 5 * 1024 * 1024:  # 5MB limit
                    return None, None
                data = await acc.client.download_media(m, bytes)
                mime = getattr(m.document, "mime_type", "application/octet-stream")
                return data, mime
            return None, None
        except Exception as e:
            # Перевод на русский + тип ошибки. Раньше писало «download error:» с пустым
            # хвостом если str(e) был пустой, что выглядело как баг.
            _t = type(e).__name__
            _m = str(e).strip() or _t
            logger.warning("⚠ Не удалось загрузить медиа из чата (%s): %s", _t, _m)
            return None, None

    data, mime = _run_async(_download(), timeout=20)
    if not data:
        return "no media", 404

    from flask import Response
    return Response(data, mimetype=mime, headers={
        "Cache-Control": "public, max-age=3600",
    })


@app.route("/api/tg/incoming")
@login_required
def api_tg_incoming():
    """Входящие ответы на DM рассылку — кто ответил."""
    ctx = _get_ctx()
    if not ctx or not ctx.active:
        return jsonify({"replies": []})
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"replies": []})

    async def _get():
        replies = []
        try:
            async for d in acc.client.iter_dialogs(limit=100):
                if not hasattr(d.entity, "first_name"):
                    continue  # только юзеры
                if d.unread_count > 0 and d.message and not d.message.out:
                    replies.append({
                        "user": d.name,
                        "user_id": d.id,
                        "text": (d.message.text or "")[:200],
                        "unread": d.unread_count,
                        "date": d.date.isoformat() if d.date else "",
                    })
        except Exception as e:
            logger.warning("[tg-preview] incoming error: %s", e)
        return replies

    result = _run_async(_get(), timeout=15)
    return jsonify({"replies": result or []})


# ── API: Telegram Send ──────────────────────────────────────────────────────

@app.route("/api/tg/send", methods=["POST"])
@login_required
def api_tg_send():
    """Отправка сообщения в чат через аккаунт."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    chat_id = data.get("chat_id")
    text = (data.get("text") or "").strip()
    reply_to = data.get("reply_to")
    if not chat_id or not text:
        return jsonify({"error": "Пустое сообщение"}), 400
    if len(text) > 4096:
        return jsonify({"error": "Сообщение слишком длинное"}), 400

    phone = data.get("phone") or ctx.active
    acc = ctx.accounts.get(phone) if phone else None
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    # Не отправлять если аккаунт занят задачей
    busy = _get_account_busy(ctx, phone)
    if busy:
        return jsonify({"error": f"Аккаунт занят: {busy}"}), 400

    async def _send():
        try:
            kwargs = {"parse_mode": "html"}
            if reply_to:
                kwargs["reply_to"] = int(reply_to)
            msg = await acc.client.send_message(int(chat_id), text, **kwargs)
            return {"ok": True, "msg_id": msg.id}
        except Exception as e:
            return {"error": str(e)}

    result = _run_async(_send(), timeout=15)
    if result is None:
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/tg/edit", methods=["POST"])
@login_required
def api_tg_edit():
    """Редактирование своего сообщения."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    chat_id = data.get("chat_id")
    msg_id = data.get("msg_id")
    text = (data.get("text") or "").strip()
    if not chat_id or not msg_id or not text:
        return jsonify({"error": "Пустые данные"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    async def _edit():
        try:
            await acc.client.edit_message(int(chat_id), int(msg_id), text, parse_mode="html")
            return {"ok": True}
        except Exception as e:
            return {"error": str(e)}

    result = _run_async(_edit(), timeout=10)
    if result is None:
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/tg/delete", methods=["POST"])
@login_required
def api_tg_delete():
    """Удаление сообщения. Принимает msg_id (одиночка) или msg_ids (список).
    revoke=True — удаляет у всех (для своих исходящих)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    chat_id = data.get("chat_id")
    msg_ids = data.get("msg_ids") or []
    if not msg_ids and data.get("msg_id"):
        msg_ids = [data.get("msg_id")]
    revoke = data.get("revoke", True)
    if not chat_id or not msg_ids:
        return jsonify({"error": "Пустые данные"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    async def _delete():
        try:
            await acc.client.delete_messages(int(chat_id), [int(m) for m in msg_ids], revoke=bool(revoke))
            return {"ok": True}
        except Exception as e:
            return {"error": str(e)}

    result = _run_async(_delete(), timeout=10)
    if result is None:
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/tg/react", methods=["POST"])
@login_required
def api_tg_react():
    """Поставить/снять реакцию на сообщение. Поддерживает emoji-реакции
    и кастом-эмодзи. emoji='' — снимает реакцию (отправляет пустой список)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    chat_id = data.get("chat_id")
    msg_id = data.get("msg_id")
    emoji = (data.get("emoji") or "").strip()
    if not chat_id or not msg_id:
        return jsonify({"error": "Нужен chat_id и msg_id"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    async def _react():
        try:
            from telethon.tl.functions.messages import SendReactionRequest
            from telethon.tl.types import ReactionEmoji
            reaction_list = [ReactionEmoji(emoticon=emoji)] if emoji else []
            entity = await acc.client.get_input_entity(int(chat_id))
            await acc.client(SendReactionRequest(
                peer=entity,
                msg_id=int(msg_id),
                reaction=reaction_list,
            ))
            return {"ok": True, "emoji": emoji}
        except Exception as e:
            return {"error": str(e)[:200]}

    result = _run_async(_react(), timeout=10)
    if result is None:
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


# ── API: Invite (массовый инвайт юзеров в чат/канал) ──────────────────────────

@app.route("/api/invite/start", methods=["POST"])
@login_required
def api_invite_start():
    """Запуск инвайт-задачи. body:
       { chat: "@target_chat", targets: "...юзернеймы или id...",
         phones: [...] (опц), pause_min: 30, pause_max: 70 }"""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    existing = ctx.temp.get("invite_job")
    if existing and getattr(existing, "status", "") == "running":
        return jsonify({"error": "Инвайт уже запущен. Сначала Стоп."}), 400

    data = request.json or {}
    chat = (data.get("chat") or "").strip()
    raw_targets = (data.get("targets") or "").strip()
    if not chat:
        return jsonify({"error": "Укажи целевой чат (@username или id)"}), 400
    if not raw_targets:
        return jsonify({"error": "Укажи список юзеров для инвайта"}), 400

    import re as _re
    targets = [t.strip() for t in _re.split(r"[\s,;\n\r]+", raw_targets) if t.strip()]
    if not targets:
        return jsonify({"error": "Список целей пуст"}), 400

    # Дедупликация
    seen = set(); targets_uniq = []
    for t in targets:
        k = t.lower().lstrip("@")
        if k in seen: continue
        seen.add(k); targets_uniq.append(t)

    # Пул акков: либо пришли в data.phones, либо берём всех живых
    phones_in = data.get("phones") or []
    accs = []
    if phones_in:
        for p in phones_in:
            a = ctx.accounts.get(str(p).strip())
            if a and not a.dead and not getattr(a, "frozen", False):
                accs.append(a)
    if not accs:
        for ph, a in ctx.accounts.items():
            if not a.dead and not getattr(a, "frozen", False):
                accs.append(a)
    if not accs:
        return jsonify({"error": "Нет живых акков для инвайта"}), 400

    def _int(key, default, lo, hi):
        try: v = int(data.get(key, default))
        except Exception: v = default
        return max(lo, min(hi, v))
    pause_min = _int("pause_min", 30, 5, 600)
    pause_max = max(pause_min + 1, _int("pause_max", 70, 6, 900))
    max_per_acc = _int("max_per_acc", 20, 1, 200)
    hourly_limit = _int("hourly_limit", 40, 1, 200)
    flood_cooldown_min = _int("flood_cooldown_min", 60, 10, 1440)
    stop_on_privacy = _int("stop_on_privacy_streak", 0, 0, 100)
    spamblock_skip = _int("spamblock_skip_limit", 1, 0, 100)
    chargeback_lim = _int("chargeback_limit", 999, 1, 100000)
    big_fw_lim = _int("big_floodwait_limit", 9999, 1, 100000)
    delay_join_min = _int("delay_after_join_min", 7, 0, 600)
    delay_join_max = max(delay_join_min + 1, _int("delay_after_join_max", 15, 1, 900))
    delay_promote_min = _int("delay_after_promote_min", 5, 0, 600)
    delay_promote_max = max(delay_promote_min + 1, _int("delay_after_promote_max", 12, 1, 900))
    cascade_seed = _int("cascade_seed_count", 3, 1, 20)

    # Режим работы и права
    mode = (data.get("mode") or "direct").strip()
    if mode not in ("direct", "via_admin"):
        mode = "direct"
    main_admin_phone = (data.get("main_admin_phone") or "").strip()
    main_admin = ctx.accounts.get(main_admin_phone) if main_admin_phone else None
    if mode == "via_admin" and (not main_admin or main_admin.dead or getattr(main_admin, "frozen", False)):
        return jsonify({"error": "Режим «Через админку» требует живого главного админа"}), 400

    rights_in = data.get("admin_rights") or {}
    admin_rights = {
        "change_info": bool(rights_in.get("change_info", False)),
        "delete_messages": bool(rights_in.get("delete_messages", False)),
        "ban_users": bool(rights_in.get("ban_users", False)),
        "manage_call": bool(rights_in.get("manage_call", False)),
        "pin_messages": bool(rights_in.get("pin_messages", False)),
        "anonymous": bool(rights_in.get("anonymous", True)),
    }

    # Сохраняем все тайминги/опции в user_settings.invite
    inv_cfg = ctx.user_settings.get("invite") or {}
    inv_cfg.update({
        "pause_min": pause_min, "pause_max": pause_max,
        "max_per_acc": max_per_acc, "hourly_limit": hourly_limit,
        "flood_cooldown_min": flood_cooldown_min,
        "stop_on_privacy_streak": stop_on_privacy,
        "spamblock_skip_limit": spamblock_skip,
        "chargeback_limit": chargeback_lim,
        "big_floodwait_limit": big_fw_lim,
        "delay_after_join_min": delay_join_min,
        "delay_after_join_max": delay_join_max,
        "delay_after_promote_min": delay_promote_min,
        "delay_after_promote_max": delay_promote_max,
        "cascade_seed_count": cascade_seed,
        "skip_chat_invalid": bool(data.get("skip_chat_invalid", True)),
        "leave_after_invite_worker": bool(data.get("leave_after_invite_worker", True)),
        "leave_after_invite_admin": bool(data.get("leave_after_invite_admin", False)),
        "archive_chat": bool(data.get("archive_chat", False)),
        "set_spamblock_in_userdb": bool(data.get("set_spamblock_in_userdb", True)),
        "mode": mode,
        "main_admin_phone": main_admin_phone,
        "admin_rights": admin_rights,
        "folder_spamblock": data.get("folder_spamblock") or "Аккаунты/После инвайта/Спамблок",
        "folder_fresh_session": data.get("folder_fresh_session") or "Аккаунты/После инвайта/Слишком свежая сессия для изменения прав",
        "folder_search_limit": data.get("folder_search_limit") or "Аккаунты/После инвайта/Лимит на поиск по номерам",
        "folder_floodwait": data.get("folder_floodwait") or "Аккаунты/После инвайта/Большой FloodWait",
        "folder_frozen": data.get("folder_frozen") or "Аккаунты/После инвайта/Заморозка",
        "folder_dead": data.get("folder_dead") or "Аккаунты/После инвайта/Мёртвые",
    })
    ctx.user_settings["invite"] = inv_cfg
    try: _save_user_settings(ctx.user_settings)
    except Exception: pass

    from core.invite import InviteJob, run_invite_job
    job = InviteJob(
        owner_id=session.get("uid") or 0,
        accs=accs,
        chat=chat,
        targets=targets_uniq,
        pause_between_min=pause_min,
        pause_between_max=pause_max,
        max_per_acc=max_per_acc,
        hourly_limit=hourly_limit,
        flood_cooldown_min=flood_cooldown_min,
        stop_on_privacy_streak=stop_on_privacy,
        mode=mode,
        main_admin=main_admin,
        admin_rights=admin_rights,
        spamblock_skip_limit=spamblock_skip,
        chargeback_limit=chargeback_lim,
        big_floodwait_limit=big_fw_lim,
        delay_after_join_min=delay_join_min,
        delay_after_join_max=delay_join_max,
        delay_after_promote_min=delay_promote_min,
        delay_after_promote_max=delay_promote_max,
        cascade_seed_count=cascade_seed,
        skip_chat_invalid=bool(data.get("skip_chat_invalid", True)),
        leave_after_invite_worker=bool(data.get("leave_after_invite_worker", True)),
        leave_after_invite_admin=bool(data.get("leave_after_invite_admin", False)),
        archive_chat=bool(data.get("archive_chat", False)),
        set_spamblock_in_userdb=bool(data.get("set_spamblock_in_userdb", True)),
        folder_spamblock=inv_cfg["folder_spamblock"],
        folder_fresh_session=inv_cfg["folder_fresh_session"],
        folder_search_limit=inv_cfg["folder_search_limit"],
        folder_floodwait=inv_cfg["folder_floodwait"],
        folder_frozen=inv_cfg["folder_frozen"],
        folder_dead=inv_cfg["folder_dead"],
    )
    ctx.temp["invite_job"] = job
    job.ad_config = _build_ad_dict(ctx, request.json)

    async def _run():
        ad = job.ad_config or {}
        inv_log = logging.getLogger("telepush.invite")

        # Pre-spam-check на всём пуле инвайтеров
        if ad.get("pre_spam_check") and accs:
            inv_log.info("[защита] Проверяю %d аккаунтов на спам-блок перед инвайтом...", len(accs))
            ok_list, blocked = await _apply_ad_pre_check(list(accs), ad, "invite")
            if not ok_list:
                inv_log.warning("[защита] Все %d аккаунтов в спам-блоке — инвайт отменён", len(blocked))
                job.status = "stopped"
                return
            if blocked:
                inv_log.warning("[защита] %d акков в спам-блоке исключены, продолжаем с %d живыми",
                                len(blocked), len(ok_list))
                # Заменим job.accs на отфильтрованный
                try:
                    job.accs = ok_list
                except Exception:
                    pass

        # Online keep-loop для каждого работающего акка
        online_tasks = []
        if ad.get("online"):
            inv_log.info("[защита] Держу %d аккаунтов в онлайне", len(getattr(job, 'accs', accs) or []))
            from utils.anti_detect import keep_online_loop
            online_stop = asyncio.Event()
            for a in (getattr(job, 'accs', accs) or []):
                if getattr(a, 'client', None):
                    online_tasks.append(asyncio.create_task(
                        keep_online_loop(a.client, online_stop, module="invite")))
        else:
            online_stop = None

        try:
            await run_invite_job(job)
        except Exception as e:
            logger.error("[invite] crashed: %s", e, exc_info=True)
            job.status = "stopped"
        finally:
            if online_stop:
                online_stop.set()
                for t in online_tasks:
                    try:
                        await asyncio.wait_for(t, timeout=2)
                    except Exception:
                        t.cancel()
        # Сохраняем отчёт в user_settings.invite_reports
        try:
            from datetime import datetime as _dt
            reports = ctx.user_settings.get("invite_reports") or []
            reports.append({
                "chat": job.chat,
                "started_at": _dt.fromtimestamp(job.started_at).strftime("%Y-%m-%d %H:%M") if job.started_at else "",
                "finished_at": _dt.fromtimestamp(job.finished_at).strftime("%Y-%m-%d %H:%M") if job.finished_at else "",
                "mode": job.mode,
                "total": len(job.targets or []),
                "processed": job.processed,
                "invited": job.counts.get("invited", 0),
                "privacy": job.counts.get("privacy", 0),
                "peer_flood": job.counts.get("peer_flood", 0),
                "failed": job.counts.get("failed", 0),
            })
            # Храним последние 50 отчётов
            ctx.user_settings["invite_reports"] = reports[-50:]
            _save_user_settings(ctx.user_settings)
            # И обновляем базу юзеров: помечаем invited / privacy / spamblock
            try:
                db = _invite_userdb_load()
                ids_map = db.setdefault("ids", {})
                for tgt, status in (job.results or {}).items():
                    key = str(tgt).strip().lstrip("@")
                    storage_key = key if key.lstrip("-").isdigit() else "@" + key.lower()
                    info = ids_map.setdefault(storage_key, {"username": "" if storage_key.lstrip("-").isdigit() else key, "tags": []})
                    tags = set(info.get("tags") or [])
                    if status == "invited":
                        tags.add("invited")
                    elif status == "privacy":
                        tags.add("privacy")
                    elif status == "peer_flood":
                        tags.add("spamblock")
                    info["tags"] = sorted(tags)
                _invite_userdb_save(db)
            except Exception as _ue:
                logger.debug("[invite] userdb update: %s", _ue)
        except Exception as e:
            logger.debug("[invite] report save error: %s", e)

    if any((job.ad_config or {}).values()):
        _flags = ', '.join(k for k,v in (job.ad_config or {}).items() if v)
        logging.getLogger("telepush.invite").info("[защита] включено: %s", _flags)

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)

    return jsonify({
        "ok": True,
        "targets": len(targets_uniq),
        "duplicates_removed": len(targets) - len(targets_uniq),
        "accs": len(accs),
    })


@app.route("/api/invite/status")
@login_required
def api_invite_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"status": "idle"})
    job = ctx.temp.get("invite_job")
    if not job:
        return jsonify({"status": "idle"})
    elapsed = int(time.time() - job.started_at) if job.started_at else 0
    return jsonify({
        "status": getattr(job, "status", "idle"),
        "processed": getattr(job, "processed", 0),
        "total": len(getattr(job, "targets", []) or []),
        "counts": dict(getattr(job, "counts", {}) or {}),
        "log": list(getattr(job, "log_lines", []) or [])[-30:],
        "elapsed": elapsed,
        "burned": list(getattr(job, "burned_phones", set()) or []),
        "chat": getattr(job, "chat", ""),
    })


@app.route("/api/invite/stop", methods=["POST"])
@login_required
def api_invite_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = ctx.temp.get("invite_job")
    if not job:
        return jsonify({"ok": True, "msg": "Инвайт не запущен"})
    job.stop_flag = True
    job.status = "stopped"
    return jsonify({"ok": True, "processed": job.processed})


@app.route("/api/invite/settings", methods=["GET", "POST"])
@login_required
def api_invite_settings():
    """GET — текущие сохранённые тайминги. POST — сохраняет в user_settings.invite."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    if request.method == "GET":
        cfg = ctx.user_settings.get("invite") or {}
        return jsonify({
            "ok": True,
            "pause_min": cfg.get("pause_min", 30),
            "pause_max": cfg.get("pause_max", 70),
            "max_per_acc": cfg.get("max_per_acc", 20),
            "hourly_limit": cfg.get("hourly_limit", 40),
            "flood_cooldown_min": cfg.get("flood_cooldown_min", 60),
            "stop_on_privacy_streak": cfg.get("stop_on_privacy_streak", 0),
            "spamblock_skip_limit": cfg.get("spamblock_skip_limit", 1),
            "chargeback_limit": cfg.get("chargeback_limit", 999),
            "big_floodwait_limit": cfg.get("big_floodwait_limit", 9999),
            "delay_after_join_min": cfg.get("delay_after_join_min", 7),
            "delay_after_join_max": cfg.get("delay_after_join_max", 15),
            "delay_after_promote_min": cfg.get("delay_after_promote_min", 5),
            "delay_after_promote_max": cfg.get("delay_after_promote_max", 12),
            "cascade_seed_count": cfg.get("cascade_seed_count", 3),
            "skip_chat_invalid": cfg.get("skip_chat_invalid", True),
            "leave_after_invite_worker": cfg.get("leave_after_invite_worker", True),
            "leave_after_invite_admin": cfg.get("leave_after_invite_admin", False),
            "archive_chat": cfg.get("archive_chat", False),
            "set_spamblock_in_userdb": cfg.get("set_spamblock_in_userdb", True),
            "mode": cfg.get("mode", "direct"),
            "main_admin_phone": cfg.get("main_admin_phone", ""),
            "admin_rights": cfg.get("admin_rights") or {
                "change_info": False, "delete_messages": False, "ban_users": False,
                "manage_call": False, "pin_messages": False, "anonymous": True,
            },
            "folder_spamblock": cfg.get("folder_spamblock", "Аккаунты/После инвайта/Спамблок"),
            "folder_fresh_session": cfg.get("folder_fresh_session", "Аккаунты/После инвайта/Слишком свежая сессия для изменения прав"),
            "folder_search_limit": cfg.get("folder_search_limit", "Аккаунты/После инвайта/Лимит на поиск по номерам"),
            "folder_floodwait": cfg.get("folder_floodwait", "Аккаунты/После инвайта/Большой FloodWait"),
            "folder_frozen": cfg.get("folder_frozen", "Аккаунты/После инвайта/Заморозка"),
            "folder_dead": cfg.get("folder_dead", "Аккаунты/После инвайта/Мёртвые"),
        })
    data = request.json or {}
    cfg = ctx.user_settings.get("invite") or {}
    for k in ("pause_min", "pause_max", "max_per_acc", "hourly_limit",
              "flood_cooldown_min", "stop_on_privacy_streak"):
        if k in data:
            try: cfg[k] = int(data[k])
            except Exception: pass
    ctx.user_settings["invite"] = cfg
    try: _save_user_settings(ctx.user_settings)
    except Exception: pass
    return jsonify({"ok": True, "saved": cfg})


# ── AI engine: настройки + тестовая кнопка ─────────────────────────────

@app.route("/api/ai/get", methods=["GET"])
@login_required
def api_ai_get():
    """Возвращает текущие настройки AI + список провайдеров для UI."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    cfg = (ctx.user_settings.get("ai") or {})
    from core.ai_engine import PROVIDERS, ai_status
    return jsonify({
        "ok": True,
        "providers": PROVIDERS,
        "settings": {
            "enabled": bool(cfg.get("enabled")),
            "provider": cfg.get("provider", "openrouter"),
            "url": cfg.get("url", ""),
            "model": cfg.get("model", ""),
            "has_key": bool(cfg.get("api_key_enc")),
            "daily_limit_usd": float(cfg.get("daily_limit_usd", 5.0) or 0),
            "max_requests": int(cfg.get("max_requests", 0) or 0),
            "dialog_topics": cfg.get("dialog_topics", "работа\nвыходные\nфильмы\nеда\nспорт"),
            "dialog_personas_per_acc": cfg.get("dialog_personas_per_acc", True),
            "comment_cta": cfg.get("comment_cta", ""),
            "custom_dialog_prompt": cfg.get("custom_dialog_prompt", ""),
            "custom_comment_prompt": cfg.get("custom_comment_prompt", ""),
        },
        "status": ai_status(),
    })


@app.route("/api/ai/save", methods=["POST"])
@login_required
def api_ai_save():
    """Сохраняет настройки AI. API-ключ шифруется через secrets_storage."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    cfg = (ctx.user_settings.get("ai") or {}).copy()
    for k in ("enabled", "provider", "url", "model", "dialog_topics",
              "dialog_personas_per_acc", "comment_cta",
              "custom_dialog_prompt", "custom_comment_prompt"):
        if k in data:
            cfg[k] = data[k]
    if "daily_limit_usd" in data:
        try: cfg["daily_limit_usd"] = max(0.0, float(data["daily_limit_usd"]))
        except Exception: pass
    if "max_requests" in data:
        try: cfg["max_requests"] = max(0, int(data["max_requests"]))
        except Exception: pass
    # Ключ — шифруем. Если в payload пустой — оставляем сохранённый (no-op).
    new_key = (data.get("api_key") or "").strip()
    if new_key:
        try:
            from utils.secrets_storage import encrypt_secret
            cfg["api_key_enc"] = encrypt_secret(new_key)
        except Exception as e:
            return jsonify({"ok": False, "error": f"шифрование ключа упало: {e}"}), 500
    elif data.get("clear_key"):
        cfg["api_key_enc"] = ""
        cfg["enabled"] = False
    ctx.user_settings["ai"] = cfg
    try: _save_user_settings(ctx.user_settings)
    except Exception: pass
    return jsonify({"ok": True})


@app.route("/api/ai/test", methods=["POST"])
@login_required
def api_ai_test():
    """Тестовый запрос — отвечает короткой строкой и показывает стоимость."""
    data = request.json or {}
    provider = (data.get("provider") or "openai").strip()
    url = (data.get("url") or "").strip()
    model = (data.get("model") or "").strip()
    api_key = (data.get("api_key") or "").strip()
    # Если ключ пустой — берём сохранённый
    if not api_key:
        ctx = _get_ctx()
        cfg = (ctx.user_settings.get("ai") or {}) if ctx else {}
        enc = cfg.get("api_key_enc", "")
        if enc:
            try:
                from utils.secrets_storage import decrypt_secret
                api_key = decrypt_secret(enc) or ""
            except Exception:
                pass
    if not api_key:
        return jsonify({"ok": False, "error": "Введите API ключ или сохраните его сначала"})
    from core.ai_engine import test_connection
    result = _run_async(test_connection(provider, url, api_key, model), timeout=20)
    return jsonify(result or {"ok": False, "error": "таймаут"})


# ── AI Pools: генерация и переиспользование уникальных пулов под клиента ──
# Юзер настроил AI-провайдер → жмёт «Сгенерировать AI-пулы» один раз →
# AI генерит ему 50 имён чатов, 60 юзернеймов, 50 приветствий и 13 постов
# на каждую из 5 тем дневников. Всё хранится локально в data/ai_pool.json
# и переиспользуется без обращений к API. Это снимает риск «массовый
# fingerprint по статичным пулам».
_AI_GEN_LOCK = threading.Lock() if 'threading' in dir() else None
_AI_GEN_STATE = {"running": False, "progress": {"label": "", "done": 0, "total": 0}, "result": None, "started_at": 0}


@app.route("/api/ai/pool_status")
@login_required
def api_ai_pool_status():
    """Что есть в кеше + статус последней генерации (если идёт)."""
    from core.ai_generator import pool_status
    st = pool_status()
    st["generation"] = dict(_AI_GEN_STATE)
    return jsonify(st)


@app.route("/api/ai/regenerate_pools", methods=["POST"])
@login_required
def api_ai_regenerate_pools():
    """Запускает генерацию всех пулов в фоне. Прогресс — через /api/ai/pool_status."""
    from core.ai_generator import regenerate_all_pools
    from core.ai_engine import ai_status as _ai_status
    if not _ai_status().get("enabled"):
        return jsonify({"ok": False, "error": "AI не настроен. Откройте Настройки → AI, вставьте ключ и включите."})

    if _AI_GEN_STATE.get("running"):
        return jsonify({"ok": False, "error": "Генерация уже идёт. Подождите завершения."})

    data = request.json or {}
    counts = {
        "chat_names": int(data.get("chat_names", 50) or 50),
        "usernames": int(data.get("usernames", 60) or 60),
        "greetings": int(data.get("greetings", 50) or 50),
        "diary_posts": int(data.get("diary_posts", 13) or 13),
    }

    def _progress(label, done, total):
        _AI_GEN_STATE["progress"] = {"label": label, "done": done, "total": total}

    def _runner():
        _AI_GEN_STATE["running"] = True
        _AI_GEN_STATE["result"] = None
        _AI_GEN_STATE["started_at"] = int(time.time())
        _AI_GEN_STATE["progress"] = {"label": "Запуск...", "done": 0, "total": 0}
        try:
            res = _run_async(regenerate_all_pools(
                chat_names=counts["chat_names"],
                usernames=counts["usernames"],
                greetings=counts["greetings"],
                diary_posts=counts["diary_posts"],
                progress_cb=_progress,
            ), timeout=600) or {"ok": False, "error": "таймаут"}
            _AI_GEN_STATE["result"] = res
        except Exception as e:
            _AI_GEN_STATE["result"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
        finally:
            _AI_GEN_STATE["running"] = False

    import threading as _t
    t = _t.Thread(target=_runner, daemon=True)
    t.start()
    return jsonify({"ok": True, "started": True})


@app.route("/api/ai/clear_pool", methods=["POST"])
@login_required
def api_ai_clear_pool():
    """Сбросить AI-пулы — будут использоваться статичные фолбеки."""
    from core.ai_generator import clear_pool
    clear_pool()
    return jsonify({"ok": True})


# === Invite User Database (отдельная база, не путать с monitor_contacted) ===
def _invite_userdb_path():
    """Бд юзеров инвайтера. Хранится в data/ + ~/.telepush/ (для выживания обнов).
    Формат: {ids: {id: {username: '...', tags: ['invited','spamblock',...]}}}"""
    from config import DATA_DIR
    return DATA_DIR / "invite_userdb.json"

def _invite_userdb_backup():
    from pathlib import Path
    return Path.home() / ".telepush" / "invite_userdb.json"

def _invite_userdb_load() -> dict:
    import json
    for p in (_invite_userdb_path(), _invite_userdb_backup()):
        try:
            if p.exists():
                d = json.loads(p.read_text(encoding="utf-8"))
                if isinstance(d, dict) and "ids" in d:
                    return d
        except Exception:
            pass
    return {"ids": {}}

def _invite_userdb_save(data: dict):
    import json
    payload = json.dumps(data, ensure_ascii=False)
    for p in (_invite_userdb_path(), _invite_userdb_backup()):
        try:
            p.parent.mkdir(parents=True, exist_ok=True)
            p.write_text(payload, encoding="utf-8")
        except Exception as e:
            logger.debug("[invite-userdb] save %s: %s", p, e)


@app.route("/api/invite/userdb_count")
@login_required
def api_invite_userdb_count():
    db = _invite_userdb_load()
    return jsonify({"ok": True, "count": len(db.get("ids") or {})})


@app.route("/api/invite/userdb_list")
@login_required
def api_invite_userdb_list():
    db = _invite_userdb_load()
    items = []
    q = (request.args.get("q") or "").strip().lower().lstrip("@")
    try:
        limit = max(1, min(10000, int(request.args.get("limit", 500))))
    except Exception:
        limit = 500
    for uid, info in (db.get("ids") or {}).items():
        try:
            iid = int(uid)
        except Exception:
            continue
        username = (info or {}).get("username", "") or ""
        tags = (info or {}).get("tags") or []
        if q:
            hay = (username + " " + str(iid)).lower()
            if q not in hay:
                continue
        items.append({"id": iid, "username": username, "tags": tags})
    items.sort(key=lambda x: x["id"])
    return jsonify({"ok": True, "total": len(items), "items": items[:limit]})


@app.route("/api/invite/userdb_import", methods=["POST"])
@login_required
def api_invite_userdb_import():
    """Импорт списка id/usernames в базу инвайтера. Текст по строке."""
    import re as _re
    data = request.json or {}
    text = data.get("text") or ""
    db = _invite_userdb_load()
    ids_map = db.setdefault("ids", {})
    added = 0
    for tok in _re.split(r"[\s,;\n\r]+", text):
        tok = (tok or "").strip().lstrip("@")
        if not tok: continue
        if _re.fullmatch(r"-?\d+", tok):
            uid = str(int(tok))
            if uid not in ids_map:
                ids_map[uid] = {"username": "", "tags": []}
                added += 1
        elif _re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{3,}", tok):
            # username — храним без id, дадим резолвить позже
            key = "@" + tok.lower()
            if key not in ids_map:
                ids_map[key] = {"username": tok, "tags": []}
                added += 1
    _invite_userdb_save(db)
    return jsonify({"ok": True, "added": added, "total": len(ids_map)})


@app.route("/api/invite/userdb_remove", methods=["POST"])
@login_required
def api_invite_userdb_remove():
    data = request.json or {}
    raw_ids = data.get("ids") or []
    db = _invite_userdb_load()
    ids_map = db.setdefault("ids", {})
    removed = 0
    for v in raw_ids:
        for k in (str(v), "@" + str(v).lstrip("@").lower()):
            if k in ids_map:
                del ids_map[k]
                removed += 1
                break
    _invite_userdb_save(db)
    return jsonify({"ok": True, "removed": removed, "total": len(ids_map)})


@app.route("/api/invite/userdb_clear", methods=["POST"])
@login_required
def api_invite_userdb_clear():
    _invite_userdb_save({"ids": {}})
    return jsonify({"ok": True})


@app.route("/api/invite/userdb_export")
@login_required
def api_invite_userdb_export():
    db = _invite_userdb_load()
    ids_map = db.get("ids") or {}
    export_dir = EXPORT_DIR / "Инвайтер"
    try:
        export_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        export_dir = DATA_DIR / "exports" / "invite"
        export_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    path = export_dir / f"userdb_{stamp}.txt"
    lines = []
    for uid, info in sorted(ids_map.items(), key=lambda x: str(x[0])):
        un = (info or {}).get("username", "")
        tags = ",".join((info or {}).get("tags") or [])
        lines.append(f"{uid}\t{un}\t{tags}")
    path.write_text("\n".join(lines), encoding="utf-8")
    return jsonify({"ok": True, "path": str(path), "count": len(ids_map)})


@app.route("/api/invite/reports")
@login_required
def api_invite_reports():
    """Список последних запусков инвайт-задач (хранится в user_settings.invite_reports)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": True, "reports": []})
    reports = (ctx.user_settings.get("invite_reports") or [])[-30:]
    return jsonify({"ok": True, "reports": list(reversed(reports))})


@app.route("/api/invite/results")
@login_required
def api_invite_results():
    """Полный результат для экспорта: target → status."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"results": {}})
    job = ctx.temp.get("invite_job")
    if not job:
        return jsonify({"results": {}})
    return jsonify({"results": dict(job.results or {}), "counts": dict(job.counts or {})})


@app.route("/api/parser/scan_all_dialogs", methods=["POST"])
@login_required
def api_parser_scan_all_dialogs():
    """Парсер v2: проходит по всем диалогам аккаунта, собирает юзеров с фильтрами.
    Фильтры: premium / has_username / has_phone / not_bot / not_scam_fake.

    body: { limit_per_dialog: 200, max_dialogs: 50, filters: {...} }
    """
    ctx = _get_ctx()
    if not ctx or not ctx.active:
        return jsonify({"error": "нет активного аккаунта"}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400
    if acc.dead or getattr(acc, "frozen", False):
        return jsonify({"error": "Аккаунт мёртв/заморожен"}), 400

    data = request.json or {}
    filters = data.get("filters") or {}
    limit_per_dialog = int(data.get("limit_per_dialog", 200))
    max_dialogs = int(data.get("max_dialogs", 50))

    # Сохраняем в ctx.temp.parser чтобы UI мог через api/parser/status
    # подсматривать прогресс — переиспользуем существующий механизм.
    parser = ctx.temp.setdefault("parser", {})
    if parser.get("status") == "running":
        return jsonify({"error": "Парсинг уже запущен"}), 400
    parser.update({
        "status": "running", "mode": "all_dialogs_v2", "cancel": False,
        "scanned": 0, "started_at": time.time(), "result": [],
        "stats": {"dialogs_total": 0, "dialogs_done": 0, "users_total": 0, "users_kept": 0},
        "status_text": "Сканирую…", "progress": 0, "error": None,
    })

    async def _scan():
        try:
            dialogs = []
            async for d in acc.client.iter_dialogs(limit=max_dialogs):
                dialogs.append(d)
            parser["stats"]["dialogs_total"] = len(dialogs)
            seen_ids = set()
            results = []
            users_total = 0
            for di, d in enumerate(dialogs):
                if parser.get("cancel"):
                    parser["status"] = "stopped"
                    parser["status_text"] = "Остановлено пользователем"
                    break
                ent = d.entity
                # Только группы и каналы
                if not (hasattr(ent, "megagroup") or hasattr(ent, "broadcast") or hasattr(ent, "title")):
                    continue
                parser["status_text"] = f"[{di+1}/{len(dialogs)}] {d.name or ''}…"
                try:
                    async for p in acc.client.iter_participants(ent, limit=limit_per_dialog):
                        users_total += 1
                        if not p or not getattr(p, "id", None):
                            continue
                        if p.id in seen_ids:
                            continue
                        # Применяем фильтры
                        if filters.get("not_bot") and getattr(p, "bot", False):
                            continue
                        if filters.get("premium_only") and not getattr(p, "premium", False):
                            continue
                        if filters.get("has_username_only") and not getattr(p, "username", None):
                            continue
                        if filters.get("has_phone_only") and not getattr(p, "phone", None):
                            continue
                        if filters.get("skip_scam_fake") and (getattr(p, "scam", False) or getattr(p, "fake", False)):
                            continue
                        if filters.get("skip_deleted") and getattr(p, "deleted", False):
                            continue
                        seen_ids.add(p.id)
                        results.append({
                            "id": int(p.id),
                            "username": getattr(p, "username", "") or "",
                            "first_name": getattr(p, "first_name", "") or "",
                            "last_name": getattr(p, "last_name", "") or "",
                            "phone": getattr(p, "phone", "") or "",
                            "premium": bool(getattr(p, "premium", False)),
                            "is_bot": bool(getattr(p, "bot", False)),
                            "from_dialog": d.name or "",
                            "link": ("@" + p.username) if getattr(p, "username", "") else "",
                        })
                        parser["stats"]["users_kept"] = len(results)
                except Exception as _de:
                    logger.debug("[parser-v2] participants %s: %s", d.name, _de)
                    continue
                parser["stats"]["dialogs_done"] = di + 1
                parser["stats"]["users_total"] = users_total
                parser["progress"] = int((di + 1) * 100 / max(1, len(dialogs)))
                parser["scanned"] = users_total
                parser["result"] = results
            if not parser.get("cancel"):
                parser["status"] = "done"
                parser["progress"] = 100
                parser["status_text"] = f"Готово: найдено {len(results)} уникальных юзеров"
            logger.info("[parser-v2] scan_all_dialogs done: %d users from %d dialogs", len(results), len(dialogs))
        except Exception as e:
            parser["status"] = "stopped"
            parser["error"] = str(e)
            parser["status_text"] = f"Ошибка: {e}"
            logger.error("[parser-v2] scan crashed: %s", e, exc_info=True)

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_scan(), loop)
    return jsonify({"ok": True, "msg": "Парсинг по всем диалогам запущен"})


@app.route("/api/tg/stories")
@login_required
def api_tg_stories():
    """Список активных сторис всех контактов (Telethon stories.GetAllStories).
    Возвращает список юзеров с активными сторис + сами сторис как media."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"users": []})
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"users": []})

    async def _get():
        try:
            from telethon.tl.functions.stories import GetAllStoriesRequest
            res = await acc.client(GetAllStoriesRequest())
            users = []
            # Telethon iterates GetAllStoriesRequest pages — берём первую
            peer_stories = getattr(res, "peer_stories", []) or []
            users_map = {u.id: u for u in (getattr(res, "users", []) or [])}
            chats_map = {c.id: c for c in (getattr(res, "chats", []) or [])}
            for ps in peer_stories:
                peer = ps.peer
                pid = getattr(peer, "user_id", None) or getattr(peer, "channel_id", None)
                stories = getattr(ps, "stories", []) or []
                if not pid or not stories:
                    continue
                ent = users_map.get(pid) or chats_map.get(pid)
                name = "Аноним"
                if ent:
                    name = (
                        getattr(ent, "first_name", "") or
                        getattr(ent, "title", "") or
                        getattr(ent, "username", "") or str(pid)
                    )
                users.append({
                    "id": int(pid),
                    "name": name,
                    "stories": [
                        {
                            "id": s.id,
                            "date": s.date.isoformat() if getattr(s, "date", None) else "",
                            "caption": (getattr(s, "caption", "") or "")[:300],
                            "media_type": "photo" if getattr(s, "media", None) and "Photo" in type(s.media).__name__ else "video",
                        } for s in stories
                    ],
                })
            return users
        except ImportError:
            return {"error": "Stories API не поддерживается этой версией Telethon"}
        except Exception as e:
            logger.warning("[stories] %s", e)
            return {"error": str(e)[:200]}

    res = _run_async(_get(), timeout=15)
    if isinstance(res, dict) and res.get("error"):
        return jsonify({"users": [], "error": res["error"]})
    return jsonify({"users": res or []})


@app.route("/api/tg/story_media")
@login_required
def api_tg_story_media():
    """Скачивает медиа сторис в кэш и отдаёт. peer=user_id, story=story_id."""
    ctx = _get_ctx()
    if not ctx:
        return "no context", 500
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return "no client", 500
    peer = request.args.get("peer")
    story_id = request.args.get("story")
    if not peer or not story_id:
        return "bad args", 400

    async def _get():
        try:
            from telethon.tl.functions.stories import GetStoriesByIDRequest
            ent = await acc.client.get_input_entity(int(peer))
            res = await acc.client(GetStoriesByIDRequest(peer=ent, id=[int(story_id)]))
            stories = getattr(res, "stories", []) or []
            if not stories:
                return None
            return await acc.client.download_media(stories[0], file=bytes)
        except Exception as e:
            logger.warning("[story_media] %s", e)
            return None

    data = _run_async(_get(), timeout=30)
    if not data:
        return "not found", 404
    from flask import Response
    # Определяем тип по магическим байтам
    if data[:3] == b"\xff\xd8\xff":
        mime = "image/jpeg"
    elif data[:8].startswith(b"\x89PNG"):
        mime = "image/png"
    elif data[:4] == b"\x00\x00\x00\x18" or data[:4] == b"\x00\x00\x00 ":
        mime = "video/mp4"
    else:
        mime = "application/octet-stream"
    return Response(data, mimetype=mime)


@app.route("/api/tg/search_messages")
@login_required
def api_tg_search_messages():
    """Поиск сообщений внутри одного чата по тексту. Возвращает до 30 матчей."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"messages": []})
    chat_id = request.args.get("chat_id")
    query = (request.args.get("q") or "").strip()
    if not chat_id or not query:
        return jsonify({"messages": []})

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"messages": []})

    async def _search():
        try:
            res = []
            async for m in acc.client.iter_messages(int(chat_id), search=query, limit=30):
                sender_name = ""
                if m.sender:
                    sender_name = (getattr(m.sender, "first_name", "") or
                                   getattr(m.sender, "title", "") or
                                   str(m.sender_id))
                res.append({
                    "id": m.id,
                    "chat_id": int(chat_id),
                    "text": (m.message or m.text or "")[:300],
                    "date": m.date.isoformat() if m.date else "",
                    "out": bool(m.out),
                    "sender_id": getattr(m, "sender_id", None),
                    "sender_name": sender_name,
                })
            return res
        except Exception as e:
            logger.warning("[tg-search] %s", e)
            return []

    res = _run_async(_search(), timeout=15) or []
    return jsonify({"messages": res, "query": query})


@app.route("/api/tg/forward", methods=["POST"])
@login_required
def api_tg_forward():
    """Пересылка сообщения в другой чат. Принимает старый формат
    (from_chat, to_chat, msg_ids[]) и новый (from_chat_id, msg_id, to)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    from_chat = data.get("from_chat") or data.get("from_chat_id")
    to_chat = data.get("to_chat") or data.get("to")
    msg_ids = data.get("msg_ids") or ([data.get("msg_id")] if data.get("msg_id") else [])
    if not from_chat or not to_chat or not msg_ids:
        return jsonify({"error": "Пустые данные"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    async def _forward():
        try:
            # Резолвим to_chat — может быть числом, @username или t.me-ссылкой
            try:
                to_entity = await acc.client.get_input_entity(int(to_chat))
            except (ValueError, TypeError):
                _t = str(to_chat).strip().lstrip("@")
                if _t.startswith("https://t.me/") or _t.startswith("t.me/"):
                    _t = _t.split("t.me/", 1)[-1].split("/")[0]
                to_entity = await acc.client.get_input_entity(_t)
            await acc.client.forward_messages(to_entity, [int(m) for m in msg_ids], int(from_chat))
            return {"ok": True}
        except Exception as e:
            return {"error": str(e)[:200]}

    result = _run_async(_forward(), timeout=15)
    if result is None:
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/tg/send_file", methods=["POST"])
@login_required
def api_tg_send_file():
    """Отправка фото/файла в чат."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    chat_id = request.form.get("chat_id")
    caption = request.form.get("caption", "")
    f = request.files.get("file")
    if not chat_id or not f:
        return jsonify({"error": "Нет файла или chat_id"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    # Save temp file
    from config import DATA_DIR
    import secrets as _sec
    tmp_dir = DATA_DIR / "tmp"
    tmp_dir.mkdir(parents=True, exist_ok=True)
    fname = f.filename or "file"
    fname = fname.replace("/", "_").replace("\\", "_").replace("..", "_")[:64]
    tmp_path = tmp_dir / (_sec.token_hex(4) + "_" + fname)
    f.save(str(tmp_path))

    async def _send():
        try:
            is_photo = fname.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp'))
            if is_photo:
                msg = await acc.client.send_file(int(chat_id), str(tmp_path), caption=caption or None, force_document=False)
            else:
                msg = await acc.client.send_file(int(chat_id), str(tmp_path), caption=caption or None, force_document=True)
            return {"ok": True, "msg_id": msg.id}
        except Exception as e:
            return {"error": str(e)}
        finally:
            try:
                tmp_path.unlink(missing_ok=True)
            except Exception:
                pass

    result = _run_async(_send(), timeout=30)
    if result is None:
        try:
            tmp_path.unlink(missing_ok=True)
        except Exception:
            pass
        return jsonify({"error": "Таймаут"}), 503
    if result.get("error"):
        return jsonify(result), 400
    return jsonify(result)


@app.route("/api/tg/read", methods=["POST"])
@login_required
def api_tg_read():
    """Пометить сообщения прочитанными."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    chat_id = data.get("chat_id")
    if not chat_id:
        return jsonify({"error": "Нет chat_id"}), 400

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "Аккаунт не подключен"}), 400

    async def _read():
        try:
            await acc.client.send_read_acknowledge(int(chat_id))
            return {"ok": True}
        except Exception as e:
            return {"error": str(e)}

    result = _run_async(_read(), timeout=10)
    return jsonify(result or {"ok": True})


@app.route("/api/tg/user_info")
@login_required
def api_tg_user_info():
    """Полная инфа о юзере/чате: bio, username, ID, телефон, фото."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 400
    chat_id = request.args.get("chat_id", "")
    if not chat_id:
        return jsonify({"error": "no chat_id"}), 400
    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return jsonify({"error": "account not connected"}), 400

    async def _get():
        try:
            from telethon.tl.functions.users import GetFullUserRequest
            from telethon.tl.functions.channels import GetFullChannelRequest
            cid = int(chat_id)
            ent = await acc.client.get_entity(cid)

            if hasattr(ent, "first_name"):  # User
                full = None
                try:
                    full = await acc.client(GetFullUserRequest(ent))
                except Exception:
                    pass
                return {
                    "type": "user",
                    "id": ent.id,
                    "first_name": ent.first_name or "",
                    "last_name": getattr(ent, "last_name", "") or "",
                    "username": getattr(ent, "username", "") or "",
                    "phone": getattr(ent, "phone", "") or "",
                    "is_premium": bool(getattr(ent, "premium", False)),
                    "is_verified": bool(getattr(ent, "verified", False)),
                    "is_bot": bool(getattr(ent, "bot", False)),
                    "is_scam": bool(getattr(ent, "scam", False)),
                    "is_fake": bool(getattr(ent, "fake", False)),
                    "about": (getattr(full.full_user, "about", "") if full else "") or "",
                    "common_chats": getattr(full.full_user, "common_chats_count", 0) if full else 0,
                }
            elif hasattr(ent, "title"):  # Chat/Channel
                full = None
                try:
                    if hasattr(ent, "broadcast") or hasattr(ent, "megagroup"):
                        full = await acc.client(GetFullChannelRequest(ent))
                except Exception:
                    pass
                return {
                    "type": "channel" if getattr(ent, "broadcast", False) else "group",
                    "id": ent.id,
                    "title": ent.title,
                    "username": getattr(ent, "username", "") or "",
                    "participants_count": (full.full_chat.participants_count if full else None) or getattr(ent, "participants_count", 0),
                    "about": (getattr(full.full_chat, "about", "") if full else "") or "",
                }
            return {"type": "unknown", "id": getattr(ent, "id", 0)}
        except Exception as e:
            return {"error": str(e)[:200]}

    result = _run_async(_get(), timeout=15)
    return jsonify(result or {"error": "timeout"})


@app.route("/api/tg/avatar")
@login_required
def api_tg_avatar():
    """Аватарка пользователя/чата."""
    ctx = _get_ctx()
    if not ctx:
        return "", 404
    chat_id = request.args.get("chat_id")
    if not chat_id:
        return "", 404

    acc = ctx.accounts.get(ctx.active)
    if not acc or not acc.client:
        return "", 404

    async def _get_photo():
        try:
            from io import BytesIO
            buf = BytesIO()
            await acc.client.download_profile_photo(int(chat_id), file=buf, download_big=False)
            data = buf.getvalue()
            if not data:
                return None
            return data
        except Exception:
            return None

    photo_data = _run_async(_get_photo(), timeout=10)
    if not photo_data:
        return "", 404
    return Response(photo_data, mimetype="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})


# ── API: Parser (парсинг юзернеймов из чатов) ──────────────────────────────

@app.route("/api/parser/chats")
@login_required
def api_parser_chats():
    """Список чатов аккаунта для парсинга."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"chats": []})
    # Если нет активного — выбираем первый доступный
    if not ctx.active:
        for phone, a in list(ctx.accounts.items()):
            if not a.dead and not a.frozen:
                ctx.active = phone
                break
    if not ctx.active:
        return jsonify({"chats": []})
    acc = ctx.accounts.get(ctx.active)
    if not acc:
        return jsonify({"chats": []})
    # Если аккаунт занят задачей — не трогаем
    busy = _get_account_busy(ctx, ctx.active)
    if busy:
        return jsonify({"chats": [], "busy": True,
                         "msg": f"Аккаунт занят: {busy}. Чаты недоступны во время работы."})

    # Авто-сон убран в 1.4.55 — коннектим сразу, без кнопки «Обновить».
    if not acc.client or getattr(acc, 'idle', False):
        async def _connect():
            from core.client_builder import ensure_connected
            return await ensure_connected(acc)
        if not _run_async(_connect(), timeout=15):
            return jsonify({"chats": [], "error": "Не удалось подключить аккаунт"})

    async def _get():
        chats = []
        try:
            # Без limit — раньше стоял limit=100, из которого после фильтра «только
            # группы/каналы» у клиентов оставалось ~60. Теперь тянем всё.
            async for d in acc.client.iter_dialogs():
                ent = d.entity
                # Только группы и каналы (не ЛС)
                if not hasattr(ent, 'title'):
                    continue
                dtype = "channel"
                if hasattr(ent, "megagroup") and ent.megagroup:
                    dtype = "group"
                elif hasattr(ent, "broadcast") and ent.broadcast:
                    dtype = "channel"
                else:
                    dtype = "group"
                members = getattr(ent, 'participants_count', 0) or 0
                chats.append({
                    "id": d.id,
                    "name": d.name or str(d.id),
                    "type": dtype,
                    "members": members,
                })
        except Exception as e:
            logger.warning("[parser] chats error: %s", e)
        return chats

    # Таймаут 60с — аккаунт с 1000+ диалогов через slow-прокси может не уложиться в 15с
    result = _run_async(_get(), timeout=60)
    # Список отдали — отключаем аккаунт. При старте задачи ensure_connected
    # поднимет коннект снова. Так коннект живёт только когда реально нужен.
    try:
        async def _disc():
            from core.client_builder import release_after_task
            await release_after_task(acc)
        _run_async(_disc(), timeout=5)
    except Exception:
        pass
    return jsonify({"chats": result or []})


@app.route("/api/parser/start", methods=["POST"])
@login_required
def api_parser_start():
    """Запуск парсинга."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "нет контекста"}), 400

    data = request.json or {}
    _early_mode = data.get("mode", "members")
    _early_use_all = bool(data.get("use_all_accs", False))

    # Авто-резолв активного аккаунта. Раньше: если ctx.active = frozen/dead,
    # парсер сразу отказывал даже когда юзер выбрал «Прогнать по всем». В режиме
    # user_chats с use_all_accs нам реально нужен ЛЮБОЙ живой акк, не первый
    # попавшийся. Если активный мёртв — переключаемся на первого живого.
    acc = ctx.accounts.get(ctx.active) if ctx.active else None
    if (not acc) or acc.dead or getattr(acc, "frozen", False):
        if _early_use_all or _early_mode in ("user_chats", "search"):
            for _p, _a in list(ctx.accounts.items()):
                if not _a.dead and not getattr(_a, "frozen", False):
                    _fwu = float(getattr(_a, "floodwait_until", 0) or 0)
                    if _fwu > time.time():
                        continue
                    acc = _a
                    ctx.active = _p
                    break

    if not ctx.active or not acc:
        return jsonify({"error": "нет ни одного живого аккаунта. Добавь или разморозь в разделе «Аккаунты»."}), 400
    if getattr(acc, 'frozen', False):
        return jsonify({"error": "Аккаунт в заморозке. Выбери другой или разморозь в разделе «Аккаунты»."}), 400
    if acc.dead:
        _dr = getattr(acc, "dead_reason", "") or ""
        if _dr == "session_revoked":
            _msg = "Сессия аккаунта отозвана Telegram — переавторизуй его в «Аккаунты → Добавить» или выбери другой."
        elif _dr == "auth_key_duplicated":
            _msg = "Сессия использовалась с двух IP одновременно — переавторизуй аккаунт или смени прокси."
        else:
            _msg = "Аккаунт помечен мёртвым. Выбери другой или переавторизуй."
        return jsonify({"error": _msg}), 400
    # FloodWait: отсеиваем заранее, чтобы не висеть на ensure_connected
    _fw_until = float(getattr(acc, "floodwait_until", 0) or 0)
    if _fw_until > time.time():
        _left = int(_fw_until - time.time())
        return jsonify({"error": f"Аккаунт на FloodWait ещё {_left} сек. Подожди или выбери другой."}), 400
    chat_id = data.get("chat_id")
    chat_ids = data.get("chat_ids") or []
    chat_links = (data.get("chat_links") or "").strip()
    mode = data.get("mode", "members")  # members | writers | links | keyword | search
    filter_mode = data.get("filter", "all")
    hours = int(data.get("hours", 24))
    limit = int(data.get("limit", 5000))
    link_type = data.get("link_type", "all")
    keywords = data.get("keywords", "")
    kw_scope = data.get("kw_scope", "all")
    kw_collect = data.get("kw_collect", "links")
    # New params
    skip_admins = bool(data.get("skip_admins", False))
    strict_activity = bool(data.get("strict_activity", False))
    date_limit_days = int(data.get("date_limit_days", 0))
    unwrap_urls = bool(data.get("unwrap_urls", False))
    search_query = data.get("search_query", "")
    search_translit = bool(data.get("search_translit", True))
    search_source = data.get("search_source", "telegram")  # telegram | tgstat | both
    tgstat_token = data.get("tgstat_token", "")
    target_user = (data.get("target_user") or "").strip()
    use_all_accs = bool(data.get("use_all_accs", False))
    all_chats = bool(data.get("all_chats", False))
    # Минимум сообщений для writers/commenters — раньше был захардкожен в 1
    min_messages = max(1, int(data.get("min_messages", 1) or 1))
    # Расширенные фильтры (1.5.59)
    save_mode = data.get("save_mode", "usernames")  # usernames | usernames_ids
    roles_filter = data.get("roles_filter", "users")  # users | admins | all
    dm_access = data.get("dm_access", "all")  # all | open | closed
    online_min_hours = int(data.get("online_min_hours", 0))
    online_max_hours = int(data.get("online_max_hours", 99999))
    only_avatar = bool(data.get("only_avatar", False))
    only_premium = bool(data.get("only_premium", False))
    include_poll_voters = bool(data.get("include_poll_voters", False))

    # Мульти-чаты: из списка, из ссылок, или один
    if chat_links:
        # Парсим ссылки — будут resolved в _run()
        chat_ids = [line.strip() for line in chat_links.split("\n") if line.strip()]
    elif chat_id and not chat_ids:
        chat_ids = [chat_id]

    # Режим «все мои чаты» — сентинел, раскручивается в _run() через iter_dialogs.
    # Не валидируем chat_ids для него, иначе вернём ошибку «выберите чат».
    if all_chats and mode not in ("keyword", "search", "user_chats"):
        chat_ids = ["__all_chats__"]
        chat_id = "__all_chats__"

    if mode not in ("keyword", "search", "user_chats") and not chat_ids:
        return jsonify({"error": "выберите чат"}), 400
    # Для обратной совместимости
    if not chat_id and chat_ids:
        chat_id = chat_ids[0]
    if mode == "keyword" and not keywords:
        return jsonify({"error": "введите ключевые слова"}), 400
    if mode == "search" and not search_query:
        return jsonify({"error": "введите поисковый запрос"}), 400
    if mode == "search" and search_source in ("tgstat", "both") and not tgstat_token.strip():
        return jsonify({"error": "Для поиска через TGStat нужен токен. Получить: api.tgstat.ru → регистрация → API → скопировать токен."}), 400
    if mode == "user_chats" and not target_user:
        return jsonify({"error": "введите @юзернейм или ID"}), 400

    parser_state = ctx.temp.setdefault("parser", {})
    # Если предыдущий парсинг застрял (>10 мин без прогресса) — force reset перед новым
    if parser_state.get("status") == "running":
        elapsed = time.time() - parser_state.get("started_at", 0)
        progress = parser_state.get("progress", 0) + parser_state.get("scanned", 0)
        if elapsed > 600 and progress == 0:
            logger.warning("[parser] Предыдущий запуск завис (%ds без прогресса), сброс", int(elapsed))
            parser_state["status"] = "stopped"
            parser_state["cancel"] = True
        else:
            return jsonify({"error": "Парсинг уже запущен. Нажмите 'Остановить' или подождите."}), 400
    parser_state["status"] = "running"
    parser_state["progress"] = 0
    parser_state["scanned"] = 0
    parser_state["result"] = None
    parser_state["error"] = None
    parser_state["started_at"] = time.time()
    parser_state["mode"] = mode
    parser_state["cancel"] = False
    parser_state["status_text"] = "Подготовка..."

    async def _run():
        from core.parser import (
            gather_members, gather_writers, gather_links, keyword_search,
            gather_channel_commenters, global_search, global_search_multi, tgstat_search,
        )
        from core.client_builder import ensure_connected

        if mode != "search" or search_source != "tgstat":
            # сбрасываем предыдущую причину фейла — чтобы ensure_connected перепроставил
            try: acc.last_fail_reason = ""
            except Exception: pass
            if not await ensure_connected(acc):
                parser_state["status"] = "error"
                _reason = getattr(acc, "last_fail_reason", "") or ""
                parser_state["error"] = (
                    f"Не удалось подключить аккаунт: {_reason}" if _reason
                    else "не удалось подключить аккаунт"
                )
                return

        class _ParserCancelled(Exception):
            """Флаг отмены от юзера — прерывает парсинг на следующей итерации прогресса."""
            pass

        async def _progress(found, scanned, total_queries=0):
            if parser_state.get("cancel"):
                raise _ParserCancelled()
            parser_state["progress"] = found
            parser_state["scanned"] = scanned
            # Для режима «По словам» показываем «Сканирую X/Y чатов» чтобы статус двигался
            if mode == "keyword" and total_queries:
                parser_state["status_text"] = f"Сканирую чаты: {scanned}/{total_queries} • найдено {found}"

        # Сентинел «все мои чаты» — раскручиваем в полный список групп+каналов
        # (как в telegram_link_collector, но без выбора руками).
        _effective_chat_ids = list(chat_ids)
        if all_chats and _effective_chat_ids == ["__all_chats__"]:
            parser_state["status_text"] = "Собираю все чаты аккаунта..."
            _all_ids = []
            try:
                async for _d in acc.client.iter_dialogs():
                    _ent = _d.entity
                    # Только группы и каналы, ЛС игнорим
                    if not hasattr(_ent, "title"):
                        continue
                    _is_group = bool(getattr(_ent, "megagroup", False))
                    _is_channel = bool(getattr(_ent, "broadcast", False)) or (
                        hasattr(_ent, "broadcast") is False and hasattr(_ent, "title")
                    )
                    if _is_group or _is_channel:
                        _all_ids.append(int(_d.id))
            except Exception as _ie:
                parser_state["status"] = "error"
                parser_state["error"] = f"Не удалось получить список чатов: {_ie}"
                logger.error("[parser] iter_dialogs failed: %s", _ie)
                return
            if not _all_ids:
                parser_state["status"] = "error"
                parser_state["error"] = "У аккаунта нет чатов/каналов для парсинга."
                return
            _effective_chat_ids = _all_ids
            parser_state["status_text"] = f"Найдено чатов: {len(_all_ids)}"
            logger.info("[parser] all_chats expanded: %d dialogs", len(_all_ids))

        # Resolve ссылки в chat_id если нужно — с таймаутом чтоб не висеть на приватных чатах
        _resolved_ids = []
        _resolve_errors = []
        for _cid in _effective_chat_ids:
            if parser_state.get("cancel"):
                raise _ParserCancelled()
            _cid_str = str(_cid).strip()
            parser_state["status_text"] = f"Резолвлю ссылку: {_cid_str[:50]}..."
            try:
                # Числовой ID без знаков → используем как chat_id напрямую.
                # Иначе (username без @, ссылка, +invite) — резолвим через get_entity.
                # Раньше else-ветка делала int(_cid_str) и падала на "svobodnoia"
                # → "All chats failed to resolve: invalid literal for int()".
                _looks_numeric = _cid_str.lstrip("-").isdigit()
                if not _looks_numeric:
                    # Приватные invite-ссылки: аккаунт должен быть участником — сначала пробуем
                    # через get_entity с таймаутом 30с, при неудаче вступаем через invite hash
                    try:
                        _ent = await asyncio.wait_for(acc.client.get_entity(_cid_str), timeout=30)
                        _resolved_ids.append(_ent.id)
                    except asyncio.TimeoutError:
                        _resolve_errors.append(f"{_cid_str}: таймаут 30с")
                        logger.warning("[parser] resolve timeout: %s", _cid_str)
                        continue
                    except Exception as re_inner:
                        # Для invite-ссылок пробуем join через check+import
                        _msg = str(re_inner)[:100]
                        if "+" in _cid_str or "joinchat" in _cid_str.lower():
                            try:
                                from telethon.tl.functions.messages import ImportChatInviteRequest
                                _hash = _cid_str.rstrip("/").split("/")[-1].lstrip("+")
                                parser_state["status_text"] = "Вступаю в приватный чат..."
                                _r = await asyncio.wait_for(
                                    acc.client(ImportChatInviteRequest(_hash)), timeout=30
                                )
                                if hasattr(_r, "chats") and _r.chats:
                                    _resolved_ids.append(_r.chats[0].id)
                                    continue
                            except Exception as join_err:
                                _msg = f"не удалось вступить: {str(join_err)[:80]}"
                        _resolve_errors.append(f"{_cid_str}: {_msg}")
                        logger.warning("[parser] resolve %s failed: %s", _cid_str, re_inner)
                else:
                    _resolved_ids.append(int(_cid_str))
            except _ParserCancelled:
                raise
            except Exception as _re:
                _resolve_errors.append(f"{_cid_str}: {str(_re)[:100]}")
                logger.warning("[parser] resolve %s failed: %s", _cid, _re)
        parser_state["status_text"] = f"Зарезолвлено: {len(_resolved_ids)} / {len(chat_ids)}"
        # Если ни один чат не зарезолвился — сразу ошибка, не висим впустую
        if chat_ids and not _resolved_ids and mode not in ("keyword", "search"):
            parser_state["status"] = "error"
            parser_state["error"] = "Ни один чат не удалось открыть:\n" + "\n".join(_resolve_errors[:5])
            logger.error("[parser] All chats failed to resolve: %s", _resolve_errors)
            return

        # Мульти-чат: парсим каждый, агрегируем
        _all_results = []
        _all_stats = {}
        _target_chats = _resolved_ids if _resolved_ids else [int(chat_id)] if chat_id else []

        try:
            # user_chats — найти чаты где состоит указанный юзер
            if mode == "user_chats":
                parser_state["status_text"] = f"Резолвлю юзера: {target_user[:50]}"
                _uid_str = target_user.strip()
                _uid_str = _uid_str.replace("https://t.me/", "").replace("http://t.me/", "").replace("@", "").strip()
                if _uid_str.startswith("+"):
                    _uid_str = _uid_str[1:]

                try:
                    _user_ent = await asyncio.wait_for(
                        acc.client.get_entity(int(_uid_str) if _uid_str.isdigit() else _uid_str),
                        timeout=20,
                    )
                except Exception as re:
                    parser_state["status"] = "error"
                    parser_state["error"] = f"Юзер не найден: {re}"
                    return

                # 1. Сначала — смотрим что есть в общей базе (fire-and-forget, быстро)
                _seen_chat_ids = set()
                try:
                    from utils.parsed_sync import fetch_user_chats
                    _srv = fetch_user_chats(str(getattr(_user_ent, "id", "")))
                    if _srv.get("ok") and _srv.get("chats"):
                        for _sc in _srv["chats"]:
                            _cid = _sc.get("chat_id")
                            if not _cid or _cid in _seen_chat_ids:
                                continue
                            _seen_chat_ids.add(_cid)
                            _uname = _sc.get("username", "")
                            _all_results.append({
                                "chat_id": _cid,
                                "title": _sc.get("title", ""),
                                "username": _uname,
                                "link": _sc.get("link") or (f"https://t.me/{_uname}" if _uname else ""),
                                "type": _sc.get("chat_type") or "group",
                                "members": _sc.get("members", 0),
                            })
                except Exception as se:
                    logger.debug("[user_chats] server lookup skip: %s", se)

                # 2. Список аккаунтов для прогона через get_common_chats
                if use_all_accs:
                    _probe_accs = [a for p, a in list(ctx.accounts.items())
                                   if not a.dead and not getattr(a, "frozen", False)]
                else:
                    _probe_accs = [acc]

                parser_state["status_text"] = f"Опрашиваю {len(_probe_accs)} аккаунтов..."
                _new_chats_for_upload = []  # Собираем найденные сейчас — для пополнения общей БД
                from core.client_builder import ensure_connected as _ec
                from telethon.tl.functions.messages import GetCommonChatsRequest
                from telethon.tl.types import InputUser

                for _idx, _probe_acc in enumerate(_probe_accs):
                    if parser_state.get("cancel"):
                        raise _ParserCancelled()
                    parser_state["status_text"] = f"Аккаунт {_idx+1}/{len(_probe_accs)}: {_probe_acc.phone}"
                    try:
                        if not await _ec(_probe_acc):
                            continue
                        # get_common_chats через raw API — стабильнее
                        try:
                            _u_input = await _probe_acc.client.get_input_entity(_user_ent.id)
                            _common = await asyncio.wait_for(
                                _probe_acc.client(GetCommonChatsRequest(
                                    user_id=_u_input,
                                    max_id=0,
                                    limit=100,
                                )),
                                timeout=30,
                            )
                            _chats_list = getattr(_common, "chats", []) or []
                        except Exception as ce:
                            logger.debug("[user_chats] %s: %s", _probe_acc.phone, ce)
                            continue

                        for _ch in _chats_list:
                            _cid = getattr(_ch, "id", None)
                            if not _cid or _cid in _seen_chat_ids:
                                continue
                            _seen_chat_ids.add(_cid)
                            _title = getattr(_ch, "title", "") or ""
                            _uname = getattr(_ch, "username", "") or ""
                            _is_broadcast = bool(getattr(_ch, "broadcast", False))
                            _is_megagroup = bool(getattr(_ch, "megagroup", False))
                            _members = getattr(_ch, "participants_count", 0) or 0
                            _link = f"https://t.me/{_uname}" if _uname else ""
                            _ch_type = "channel" if _is_broadcast else ("group" if _is_megagroup else "chat")
                            _all_results.append({
                                "chat_id": _cid,
                                "title": _title,
                                "username": _uname,
                                "link": _link,
                                "type": _ch_type,
                                "members": _members,
                            })
                            # Пополняем общую БД тем что нашли через get_common_chats
                            _new_chats_for_upload.append({
                                "chat_id": _cid, "title": _title, "username": _uname,
                                "chat_type": _ch_type, "members": _members,
                            })
                        await _progress(len(_all_results), _idx + 1)
                    except _ParserCancelled:
                        raise
                    except Exception as e:
                        logger.debug("[user_chats] %s error: %s", _probe_acc.phone, e)

                _all_stats = {
                    "target_user": target_user,
                    "target_user_id": getattr(_user_ent, "id", 0),
                    "target_username": getattr(_user_ent, "username", "") or "",
                    "target_name": (getattr(_user_ent, "first_name", "") or "") + " " + (getattr(_user_ent, "last_name", "") or ""),
                    "accounts_used": len(_probe_accs),
                    "chats_found": len(_all_results),
                }

                # Пополняем общую БД только новыми связями (не дублируем то что пришло с сервера)
                if _new_chats_for_upload:
                    try:
                        from utils.parsed_sync import upload_user_chats
                        upload_user_chats(
                            user_id=int(getattr(_user_ent, "id", 0) or 0),
                            user_username=getattr(_user_ent, "username", "") or "",
                            chats=_new_chats_for_upload,
                        )
                    except Exception as ue:
                        logger.debug("[user_chats] sync upload skip: %s", ue)
            # Search mode — отдельно, не по чатам
            elif mode == "search":
                all_results = []
                seen_usernames = set()
                _min_p = int(data.get("min_participants", 0) or 0)
                _expand = data.get("expand_mode", "full")
                _do_tokens = _expand in ("full",)
                if search_source in ("telegram", "both"):
                    # Multi-acc парсинг: при «Все»/«Выбрать» распределяем запросы
                    # по выбранным акк-парсерам в раунд-робин. Один акк попал
                    # во FloodWait — остальные продолжают; нагрузка делится; быстрее.
                    _search_clients = []
                    if use_all_accs:
                        for _p, _a in list(ctx.accounts.items()):
                            if _a.dead or getattr(_a, "frozen", False):
                                continue
                            try:
                                if await ensure_connected(_a) and _a.client:
                                    _search_clients.append(_a.client)
                            except Exception as _ce:
                                logger.debug("[parser-search] connect %s: %s", _p, _ce)
                    if not _search_clients:
                        _search_clients = [acc.client]
                    if len(_search_clients) > 1:
                        tg_results, tg_stats = await global_search_multi(
                            _search_clients, search_query,
                            limit=min(limit, 100), translit=search_translit,
                            expand_tokens=_do_tokens,
                            min_participants=_min_p,
                            progress_cb=_progress,
                        )
                    else:
                        tg_results, tg_stats = await global_search(
                            _search_clients[0], search_query,
                            limit=min(limit, 100), translit=search_translit,
                            expand_tokens=_do_tokens,
                            min_participants=_min_p,
                            progress_cb=_progress,
                        )
                    for r in tg_results:
                        r["source"] = "telegram"
                        uname = (r.get("username") or "").lower()
                        if uname and uname not in seen_usernames:
                            seen_usernames.add(uname)
                            all_results.append(r)
                        elif not uname:
                            all_results.append(r)
                if search_source in ("tgstat", "both") and tgstat_token:
                    tgs_results, tgs_stats = await tgstat_search(
                        tgstat_token, search_query, limit=min(limit, 50),
                    )
                    for r in tgs_results:
                        uname = (r.get("username") or "").lower()
                        if uname and uname not in seen_usernames:
                            seen_usernames.add(uname)
                            all_results.append(r)
                _all_results = all_results
                _all_stats = {"total": len(all_results), "source": search_source}
            else:
              for _cidx, _current_cid in enumerate(_target_chats):
                if parser_state.get("cancel"):
                    raise _ParserCancelled()
                parser_state["status_text"] = f"Чат {_cidx+1}/{len(_target_chats)}"

                if mode == "writers":
                    is_broadcast = False
                    try:
                        ent = await acc.client.get_entity(_current_cid)
                        from telethon.tl.types import Channel as _Ch
                        is_broadcast = isinstance(ent, _Ch) and not getattr(ent, 'megagroup', False)
                    except Exception:
                        pass
                    if is_broadcast:
                        result, stats = await gather_channel_commenters(
                            acc.client, _current_cid,
                            limit_posts=min(limit, 500), filter_mode=filter_mode,
                            hours=hours, date_limit_days=date_limit_days,
                            progress_cb=_progress,
                        )
                    else:
                        result, stats = await gather_writers(
                            acc.client, _current_cid,
                            limit_messages=limit, filter_mode=filter_mode,
                            hours=hours, min_messages=min_messages,
                            strict_activity=strict_activity,
                            date_limit_days=date_limit_days, skip_admins=skip_admins,
                            progress_cb=_progress,
                            extra_filters={
                                "only_avatar": only_avatar,
                                "only_premium": only_premium,
                                "online_min_hours": online_min_hours,
                                "online_max_hours": online_max_hours,
                                "dm_access": dm_access,
                            },
                        )
                elif mode == "links":
                    result, stats = await gather_links(
                        acc.client, _current_cid,
                        limit_messages=limit, link_type=link_type,
                        date_limit_days=date_limit_days, unwrap_urls=unwrap_urls,
                        progress_cb=_progress,
                    )
                elif mode == "keyword":
                    kw_list = [k.strip() for k in keywords.replace(',', '\n').replace(';', '\n').split('\n') if k.strip()]
                    result, stats = await keyword_search(
                        acc.client, kw_list,
                        scope=kw_scope, limit_per_chat=min(limit, 200),
                        collect_links=(kw_collect in ("links", "both")),
                        collect_usernames=(kw_collect in ("usernames", "both")),
                        unwrap_urls=unwrap_urls,
                        progress_cb=_progress,
                    )
                else:
                    result, stats = await gather_members(
                        acc.client, _current_cid,
                        limit=limit, filter_mode=filter_mode,
                        hours=hours, skip_admins=skip_admins,
                        strict_activity=strict_activity,
                        progress_cb=_progress,
                        extra_filters={
                            "save_mode": save_mode,
                            "roles_filter": roles_filter,
                            "only_avatar": only_avatar,
                            "only_premium": only_premium,
                            "online_min_hours": online_min_hours,
                            "online_max_hours": online_max_hours,
                            "dm_access": dm_access,
                            "include_poll_voters": include_poll_voters,
                        },
                    )

                # Агрегация мульти-чатов
                if isinstance(result, list):
                    _existing = set(str(r).lower() if isinstance(r, str) else "" for r in _all_results)
                    for item in result:
                        key = str(item).lower() if isinstance(item, str) else str(item.get("username", "")).lower() if isinstance(item, dict) else ""
                        if key and key not in _existing:
                            _existing.add(key)
                            _all_results.append(item)
                        elif not key:
                            _all_results.append(item)
                for k, v in stats.items():
                    if isinstance(v, int):
                        _all_stats[k] = _all_stats.get(k, 0) + v

                await _progress(len(_all_results), _cidx + 1)

            # Финал
            if mode not in ("search", "user_chats"):
                _all_stats["chats_parsed"] = len(_target_chats)
            parser_state["status"] = "finished"
            parser_state["result"] = _all_results
            parser_state["stats"] = _all_stats
            logger.info("[parser] Done: %d items, mode=%s", len(_all_results), mode)
        except _ParserCancelled:
            # Сохраняем частичный результат — не теряем то что уже напарсили
            _all_stats["chats_parsed"] = _all_stats.get("chats_parsed", 0)
            _all_stats["cancelled"] = True
            parser_state["status"] = "stopped"
            parser_state["result"] = _all_results
            parser_state["stats"] = _all_stats
            logger.info("[parser] Остановлен пользователем: %d items собрано", len(_all_results))
        except Exception as e:
            parser_state["status"] = "error"
            parser_state["error"] = str(e)
            logger.error("[parser] Error: %s", e)
        finally:
            # Коннект живёт только во время задачи — отключаемся после парсинга.
            try:
                if mode != "search" or search_source != "tgstat":
                    from core.client_builder import release_after_task
                    await release_after_task(acc)
            except Exception:
                pass

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)

    return jsonify({"ok": True})


@app.route("/api/parsed/stats")
@login_required
def api_parsed_stats_proxy():
    """Статистика общей БД на сервере TelePush."""
    try:
        from utils.parsed_sync import fetch_stats
        return jsonify(fetch_stats() or {"ok": False})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/parser/stop", methods=["POST"])
@login_required
def api_parser_stop():
    """Останавливает запущенный парсинг. Частичный результат сохраняется.
    Если corutine зависла и не реагирует на cancel — force_reset=1 сбрасывает статус принудительно."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 400
    data = request.json or {}
    force = bool(data.get("force", False))
    parser = ctx.temp.get("parser", {})
    if parser.get("status") != "running" and not force:
        return jsonify({"ok": False, "error": "Парсинг не запущен"}), 400
    parser["cancel"] = True
    if force:
        parser["status"] = "stopped"
        parser["status_text"] = "Принудительно остановлено"
        logger.warning("[parser] FORCE reset by user")
    else:
        logger.info("[parser] Stop requested by user")
    return jsonify({"ok": True, "msg": "Запрос на остановку отправлен"})


@app.route("/api/parser/status")
@login_required
def api_parser_status():
    """Статус парсинга."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"status": "idle"})
    parser = ctx.temp.get("parser", {})
    return jsonify({
        "status": parser.get("status", "idle"),
        "progress": parser.get("progress", 0),
        "scanned": parser.get("scanned", 0),
        "error": parser.get("error"),
        "stats": parser.get("stats"),
        "count": len(parser.get("result") or []),
        "elapsed": int(time.time() - parser.get("started_at", time.time())),
        "status_text": parser.get("status_text", ""),
    })


@app.route("/api/parser/result")
@login_required
def api_parser_result():
    """Результат парсинга — список юзернеймов."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"usernames": []})
    parser = ctx.temp.get("parser", {})
    result = parser.get("result") or []
    return jsonify({"usernames": result, "count": len(result)})


@app.route("/api/parser/export")
@login_required
def api_parser_export():
    """Сохраняет результат парсинга в файл на диске.
    В webview-приложении Content-Disposition:attachment не работает,
    поэтому пишем в папку Экспорт/ рядом с приложением и возвращаем путь.
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "Нет данных"}), 404
    parser = ctx.temp.get("parser", {})
    result = parser.get("result") or []
    if not result:
        return jsonify({"ok": False, "error": "Нет результатов — сначала запустите парсинг"}), 404

    fmt = request.args.get("fmt", "txt")
    if fmt not in ("txt", "csv", "spintax", "xlsx"):
        fmt = "txt"

    # Для search/user_chats mode — результаты это список dict
    is_rich = isinstance(result, list) and result and isinstance(result[0], dict)
    if is_rich:
        items = []
        for r in result:
            if r.get("link"):
                items.append(r["link"])
            elif r.get("username"):
                items.append(f"@{r['username']}")
            elif r.get("chat_id"):
                items.append(f"id={r['chat_id']} ({r.get('title', '')})")
    else:
        items = result

    # Раскладываем по подпапкам в зависимости от режима парсинга:
    #   Экспорт/Парсинг участников/      (members)
    #   Экспорт/Парсинг писавших/        (writers / channel_commenters)
    #   Экспорт/Сбор ссылок/             (links)
    #   Экспорт/Поиск по словам/         (keyword)
    #   Экспорт/Глобальный поиск/        (search)
    #   Экспорт/Чаты юзера/              (user_chats)
    _mode_to_folder = {
        "members": "\u041f\u0430\u0440\u0441\u0438\u043d\u0433 \u0443\u0447\u0430\u0441\u0442\u043d\u0438\u043a\u043e\u0432",
        "writers": "\u041f\u0430\u0440\u0441\u0438\u043d\u0433 \u043f\u0438\u0441\u0430\u0432\u0448\u0438\u0445",
        "links": "\u0421\u0431\u043e\u0440 \u0441\u0441\u044b\u043b\u043e\u043a",
        "keyword": "\u041f\u043e\u0438\u0441\u043a \u043f\u043e \u0441\u043b\u043e\u0432\u0430\u043c",
        "search": "\u0413\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0439 \u043f\u043e\u0438\u0441\u043a",
        "user_chats": "\u0427\u0430\u0442\u044b \u044e\u0437\u0435\u0440\u0430",
    }
    _parser_mode = parser.get("mode") or "members"
    _subfolder = _mode_to_folder.get(_parser_mode, "\u041f\u0440\u043e\u0447\u0435\u0435")  # Прочее
    from config import DATA_DIR
    export_dir = EXPORT_DIR / _subfolder
    try:
        export_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        export_dir = DATA_DIR / "exports" / _subfolder
        export_dir.mkdir(parents=True, exist_ok=True)

    stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")

    # XLSX (по факту CSV с BOM, открывается в Excel)
    if fmt == "xlsx" and is_rich:
        try:
            import csv as _csv
            fname = f"{stamp}_({len(result)}).csv"
            out_path = export_dir / fname
            with open(out_path, "wb") as fh:
                fh.write(b"\xef\xbb\xbf")  # UTF-8 BOM
            import io
            with open(out_path, "a", encoding="utf-8", newline="") as fh:
                writer = _csv.writer(fh, delimiter=";")
                writer.writerow(["Название", "Username", "Участников", "Тип", "Ссылка", "Запрос", "Источник"])
                for r in result:
                    writer.writerow([
                        r.get("name") or r.get("title", ""),
                        r.get("username", ""),
                        r.get("members", 0),
                        r.get("type", ""),
                        r.get("link", ""),
                        r.get("query", ""),
                        r.get("source") or r.get("found_by", ""),
                    ])
            return jsonify({"ok": True, "path": str(out_path), "folder": str(export_dir), "count": len(result)})
        except Exception as e:
            logger.warning("XLSX export error: %s", e)

    from core.parser import format_export
    content = format_export(items, fmt)

    ext_map = {"txt": "txt", "csv": "csv", "spintax": "txt", "xlsx": "csv"}
    ext = ext_map.get(fmt, "txt")
    fname = f"{stamp}_({len(items)}).{ext}"
    out_path = export_dir / fname

    try:
        if isinstance(content, bytes):
            out_path.write_bytes(content)
        else:
            out_path.write_text(content, encoding="utf-8")
    except Exception as e:
        logger.error("Export write failed: %s", e)
        return jsonify({"ok": False, "error": f"Не удалось записать файл: {e}"}), 500

    return jsonify({"ok": True, "path": str(out_path), "folder": str(export_dir), "count": len(items)})


@app.route("/api/parser/export_folders", methods=["GET", "POST"])
@login_required
def api_parser_export_folders():
    """Категоризированный экспорт: создаёт подпапку и раскладывает результат
    по типам (каналы, группы, инвайты, юзернеймы, посты, всё)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "Нет данных"}), 404
    parser = ctx.temp.get("parser", {})
    result = parser.get("result") or []
    if not result:
        return jsonify({"ok": False, "error": "Нет результатов — сначала запустите парсинг"}), 404

    # Нормализуем в плоский список строк
    items = []
    if isinstance(result, list) and result and isinstance(result[0], dict):
        for r in result:
            if r.get("link"):
                items.append(r["link"])
            elif r.get("username"):
                items.append(f"@{r['username']}")
    else:
        items = list(result)

    import re as _re
    buckets = {
        "invites": [],         # t.me/+hash — инвайты (группа или канал, не знаем наверняка)
        "public_channels": [], # t.me/username (без /123) — каналы и группы с юзернеймом
        "posts": [],           # t.me/username/123 — посты в каналах
        "usernames": [],       # @username — юзеры/боты
        "other": [],
    }
    seen = set()
    for raw in items:
        s = (raw or "").strip()
        if not s or s.lower() in seen:
            continue
        seen.add(s.lower())
        # @username
        if s.startswith("@"):
            buckets["usernames"].append(s)
            continue
        # t.me/+hash или t.me/joinchat/hash
        if _re.match(r"^(?:https?://)?t\.me/(?:\+|joinchat/)[A-Za-z0-9_\-]+", s, _re.I):
            buckets["invites"].append(s)
            continue
        # t.me/username/123 — пост
        if _re.match(r"^(?:https?://)?t\.me/(?:s/)?[A-Za-z0-9_]{4,}/\d+", s, _re.I):
            buckets["posts"].append(s)
            continue
        # t.me/username
        if _re.match(r"^(?:https?://)?t\.me/[A-Za-z0-9_]{4,}/?$", s, _re.I):
            buckets["public_channels"].append(s)
            continue
        buckets["other"].append(s)

    from config import DATA_DIR
    base = EXPORT_DIR  # Documents/TelePush/Экспорт
    try:
        base.mkdir(parents=True, exist_ok=True)
    except Exception:
        base = DATA_DIR / "exports"
        base.mkdir(parents=True, exist_ok=True)

    stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    # Экспорт/Парсинг по категориям/<stamp>/
    folder = base / "\u041f\u0430\u0440\u0441\u0438\u043d\u0433 \u043f\u043e \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u044f\u043c" / stamp
    folder.mkdir(parents=True, exist_ok=True)

    file_map = {
        "invites": ("\u0418\u043d\u0432\u0430\u0439\u0442\u044b.txt", "Инвайты"),
        "public_channels": ("\u041a\u0430\u043d\u0430\u043b\u044b_\u0438_\u0433\u0440\u0443\u043f\u043f\u044b.txt", "Каналы и группы"),
        "posts": ("\u041f\u043e\u0441\u0442\u044b.txt", "Посты"),
        "usernames": ("\u042e\u0437\u0435\u0440\u043d\u0435\u0439\u043c\u044b.txt", "Юзернеймы"),
        "other": ("\u041f\u0440\u043e\u0447\u0435\u0435.txt", "Прочее"),
    }
    summary = {}
    for key, (fname, label) in file_map.items():
        lst = buckets.get(key) or []
        if not lst:
            continue
        (folder / fname).write_text("\n".join(lst) + "\n", encoding="utf-8")
        summary[label] = len(lst)

    # Плюс общий all.txt
    all_items = []
    for key in ("invites", "public_channels", "posts", "usernames", "other"):
        all_items.extend(buckets.get(key) or [])
    (folder / "\u0412\u0441\u0451.txt").write_text("\n".join(all_items) + "\n", encoding="utf-8")

    # Открываем папку в Проводнике
    try:
        if sys.platform == "win32":
            os.startfile(str(folder))
        else:
            import subprocess
            subprocess.Popen(["xdg-open", str(folder)])
    except Exception as e:
        logger.debug("[export_folders] open: %s", e)

    return jsonify({
        "ok": True, "folder": str(folder),
        "summary": summary, "total": len(all_items),
    })


# ── Monitor (реал-тайм наблюдение за чатами) ─────────────────────────────────

@app.route("/api/monitor/config", methods=["GET"])
@login_required
def api_monitor_config():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({})
    cfg = ctx.user_settings.get("monitor") or {}
    pool = list(getattr(ctx, "monitor_jobs", None) or [])
    legacy = getattr(ctx, "monitor_job", None)
    if legacy and legacy not in pool:
        pool.append(legacy)
    running_jobs = [j for j in pool if getattr(j, "status", "") == "running"]
    running = bool(running_jobs)
    # Сводная статистика по всему пулу (multi-acc поддержка)
    matched_total = sum(int(getattr(j, "matched", 0) or 0) for j in pool)
    auto_dm_sent_total = sum(int(getattr(j, "auto_dm_sent", 0) or 0) for j in pool)
    auto_dm_dup_total = sum(int(getattr(j, "auto_dm_skipped_dup", 0) or 0) for j in pool)
    resolved_total = sum(len(getattr(j, "resolved_chats", []) or []) for j in pool)
    started_at_min = min((getattr(j, "started_at", 0) or 0) for j in pool) if pool else 0
    failed_chats_first = list(getattr(pool[0], "failed_chats", []) or [])[:5] if pool else []
    csv_paths = [getattr(j, "csv_path", "") for j in pool if getattr(j, "csv_path", "")]
    pool_phones = [getattr(getattr(j, "acc", None), "phone", "") for j in pool]
    stats = {}
    if pool:
        stats = {
            "running": running,
            "started_at": started_at_min,
            "matched": matched_total,
            "resolved": resolved_total,
            "failed": failed_chats_first,
            "csv_path": csv_paths[0] if csv_paths else "",
            "csv_paths": csv_paths,
            "auto_dm_sent": auto_dm_sent_total,
            "auto_dm_skipped_dup": auto_dm_dup_total,
            "phones": pool_phones,
            "jobs_running": len(running_jobs),
            "jobs_total": len(pool),
        }
    from utils.secrets_storage import decrypt_secret, mask_secret
    _enc = cfg.get("bot_token_enc", "")
    _tok = decrypt_secret(_enc) if _enc else ""
    return jsonify({
        "chats": cfg.get("chats", ""),
        "keywords": cfg.get("keywords", ""),
        "ignore_words": cfg.get("ignore_words", ""),
        "target": cfg.get("target", "me"),
        "phone": cfg.get("phone", ""),
        "phones": cfg.get("phones", []),
        "multi_mode": cfg.get("multi_mode", "redundant"),
        "autostart": bool(cfg.get("autostart", False)),
        "auto_dm_text": cfg.get("auto_dm_text", ""),
        "bot_token_masked": mask_secret(_tok),
        "bot_token_set": bool(_tok),
        "bot_chat_id": cfg.get("bot_chat_id", ""),
        "stats": stats,
        "running": running,
    })


@app.route("/api/monitor/autostart", methods=["POST"])
@login_required
def api_monitor_autostart():
    """Сохранить флаг автозапуска мониторинга — поднимется при старте программы."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    enabled = bool(data.get("enabled", False))
    cfg = ctx.user_settings.setdefault("monitor", {})
    cfg["autostart"] = enabled
    try:
        _save_user_settings(ctx.user_settings)
    except Exception as e:
        logger.debug("[monitor] save settings: %s", e)
    return jsonify({"ok": True, "autostart": enabled})


# ── Нейрокомменты ──────────────────────────────────────────────────────

@app.route("/api/neuro_comments/start", methods=["POST"])
@login_required
def api_neuro_comments_start():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    existing = getattr(ctx, "neuro_comments_job", None)
    if existing and existing.status == "running":
        return jsonify({"error": "Уже запущен"}), 400

    data = request.json or {}
    channels_raw = (data.get("channels") or "").strip()
    if not channels_raw:
        return jsonify({"error": "Укажите каналы"}), 400
    channels = []
    for line in channels_raw.replace(",", "\n").split("\n"):
        s = line.strip().lstrip("@")
        if s:
            # из t.me/xxx или t.me/+ берём username
            if "t.me/" in s:
                s = s.split("t.me/")[-1].split("/")[0]
            channels.append(s)
    if not channels:
        return jsonify({"error": "Каналы не распознаны"}), 400

    phones_in = data.get("phones") or []
    if not isinstance(phones_in, list):
        phones_in = []
    accs = []
    for ph in phones_in:
        ph = str(ph).strip()
        a = ctx.accounts.get(ph)
        if a and not a.dead and not getattr(a, "frozen", False):
            accs.append(a)
    if not accs:
        return jsonify({"error": "Выберите живые аккаунты"}), 400

    # Проверим что AI настроен — без него комментинг бесполезен
    try:
        from core.ai_engine import ai_status
        if not ai_status().get("enabled"):
            return jsonify({"error": "AI не настроен. Перейди в Настройки → AI и введи API ключ."}), 400
    except Exception:
        return jsonify({"error": "AI движок недоступен"}), 500

    from core.neuro_comments import NeuroCommentJob, run_neuro_comments_job
    job = NeuroCommentJob(
        owner_id=session.get("uid") or 0,
        accounts=accs,
        channels=channels,
        cta=(data.get("cta") or "").strip(),
        custom_prompt=(data.get("custom_prompt") or "").strip(),
        poll_interval_min=max(1, int(data.get("poll_interval_min", 5))),
        recency_min=max(5, int(data.get("recency_min", 60))),
        delay_between_acc_min=max(1, int(data.get("delay_between_acc_min", 2))),
        delay_between_acc_max=max(2, int(data.get("delay_between_acc_max", 15))),
        max_comments_per_post=max(1, int(data.get("max_comments_per_post", 1))),
        max_per_acc_per_hour=max(1, int(data.get("max_per_acc_per_hour", 5))),
        max_per_acc_per_day=max(1, int(data.get("max_per_acc_per_day", 30))),
    )
    ctx.neuro_comments_job = job

    job.ad_config = _build_ad_dict(ctx, request.json)
    if any((job.ad_config or {}).values()):
        _flags = ', '.join(k for k,v in (job.ad_config or {}).items() if v)
        logging.getLogger("telepush.neuro_comments").info("[защита] включено: %s", _flags)

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        async def _run():
            ad = job.ad_config or {}
            nc_log = logging.getLogger("telepush.neuro_comments")
            if ad.get("pre_spam_check") and accs:
                nc_log.info("[защита] Проверяю %d акков-комментаторов на спам-блок...", len(accs))
                ok_list, blocked = await _apply_ad_pre_check(list(accs), ad, "neuro_comments")
                if blocked:
                    nc_log.warning("[защита] %d акков в спам-блоке исключены", len(blocked))
                if not ok_list:
                    job.status = "stopped"
                    return
                # Заменим job.accounts
                try:
                    job.accounts = ok_list
                except Exception:
                    pass

            online_tasks = []
            online_stop = None
            human_tasks = []
            human_stop = None
            if ad.get("online"):
                nc_log.info("[защита] Держу %d акков в онлайне", len(getattr(job, 'accounts', accs)))
                from utils.anti_detect import keep_online_loop
                online_stop = asyncio.Event()
                for a in (getattr(job, 'accounts', accs) or []):
                    if getattr(a, 'client', None):
                        online_tasks.append(asyncio.create_task(
                            keep_online_loop(a.client, online_stop, module="neuro_comments")))

            # Human activity loop (read/mark/view)
            if any(ad.get(k) for k in ('read_messages', 'mark_read', 'view_profiles', 'view_photos')):
                nc_log.info("[защита] Включена имитация человеческой активности")
                from utils.anti_detect import human_activity_loop
                human_stop = asyncio.Event()
                for a in (getattr(job, 'accounts', accs) or []):
                    if getattr(a, 'client', None):
                        human_tasks.append(asyncio.create_task(
                            human_activity_loop(a, ad, human_stop, module="neuro_comments")))

            try:
                await run_neuro_comments_job(job)
            except Exception as e:
                logger.error("[neuro-comments] crashed: %s", e, exc_info=True)
            finally:
                if online_stop:
                    online_stop.set()
                    for t in online_tasks:
                        try:
                            await asyncio.wait_for(t, timeout=2)
                        except Exception:
                            t.cancel()
                if human_stop:
                    human_stop.set()
                    for t in human_tasks:
                        try:
                            await asyncio.wait_for(t, timeout=2)
                        except Exception:
                            t.cancel()

        asyncio.run_coroutine_threadsafe(_run(), loop)

    return jsonify({"ok": True, "channels": len(channels), "accounts": len(accs)})


@app.route("/api/neuro_comments/status")
@login_required
def api_neuro_comments_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"status": "idle"})
    job = getattr(ctx, "neuro_comments_job", None)
    if not job:
        return jsonify({"status": "idle"})
    return jsonify({
        "status": job.status,
        "channels": len(job.channels),
        "accounts": len(job.accounts),
        "counts": job.counts,
        "started_at": job.started_at,
        "log": job.log_lines[-50:],
    })


@app.route("/api/neuro_comments/stop", methods=["POST"])
@login_required
def api_neuro_comments_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = getattr(ctx, "neuro_comments_job", None)
    if not job or job.status != "running":
        return jsonify({"ok": True})
    job.stop_flag = True
    return jsonify({"ok": True})


@app.route("/api/monitor/start", methods=["POST"])
@login_required
def api_monitor_start():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    raw_chats = (data.get("chats") or "").strip()
    raw_kws = (data.get("keywords") or "").strip()
    raw_iws = (data.get("ignore_words") or "").strip()
    target = (data.get("target") or "me").strip() or "me"
    # Поддерживаем и одиночный phone, и список phones (multi-acc мониторинг).
    # Если phones задан — используем его, иначе fallback на phone.
    phones_in = data.get("phones")
    if phones_in and isinstance(phones_in, list):
        phones_list = [str(p).strip() for p in phones_in if str(p).strip()]
    else:
        phones_list = []
    phone = (data.get("phone") or ctx.active or "").strip()
    if not phones_list and phone:
        phones_list = [phone]
    # Если клиент ничего не передал — берём акк из папки «Мониторинг».
    # Раньше fallback шёл сразу на ctx.active (последний выбранный в любом
    # модуле), и стартовал не тот акк который лежит в папке Мониторинг.
    if not phones_list:
        for ph, acc in ctx.accounts.items():
            if (getattr(acc, "folder_name", "") in ("Мониторинг", "Monitoring")
                    and not acc.dead and not getattr(acc, "frozen", False)):
                phones_list = [ph]
                break
    # split — каждый акк сидит на своих чатах (равномерное разбиение).
    # redundant — все акки сидят во всех чатах (надёжнее на случай бана).
    multi_mode = (data.get("multi_mode") or "redundant").strip()
    if multi_mode not in ("split", "redundant"):
        multi_mode = "redundant"
    auto_dm_text = (data.get("auto_dm_text") or "").strip()
    bot_token = (data.get("bot_token") or "").strip()
    bot_chat_id = (data.get("bot_chat_id") or "").strip()
    # Флаг «не меняй сохранённый токен» — если клиент оставил поле пустым,
    # значит хочет использовать уже сохранённый (чтобы не вводить заново).
    bot_token_keep = bool(data.get("bot_token_keep"))
    if bot_token_keep and not bot_token:
        try:
            from utils.secrets_storage import decrypt_secret
            _prev_enc = (ctx.user_settings.get("monitor") or {}).get("bot_token_enc", "")
            if _prev_enc:
                bot_token = decrypt_secret(_prev_enc)
        except Exception:
            pass

    if not raw_chats:
        return jsonify({"error": "Нужен список чатов"}), 400
    if not raw_kws:
        return jsonify({"error": "Нужны ключевые слова"}), 400
    if not phones_list:
        return jsonify({"error": "Выберите аккаунт(ы)"}), 400

    # Фильтруем: оставляем только живых
    accs_alive = []
    for ph in phones_list:
        a = ctx.accounts.get(ph)
        if not a:
            continue
        if a.dead or getattr(a, "frozen", False):
            continue
        accs_alive.append(a)
    if not accs_alive:
        return jsonify({"error": "Все выбранные акки мёртвые/замороженные"}), 400

    # Стопаем существующие jobs (single + pool)
    existing = getattr(ctx, "monitor_job", None)
    if existing:
        try: existing.stop_flag = True
        except Exception: pass
    pool = getattr(ctx, "monitor_jobs", None) or []
    for j in pool:
        try: j.stop_flag = True
        except Exception: pass

    chats = [x.strip() for x in re.split(r"[\n\r]+", raw_chats) if x.strip()]
    kws = [x.strip() for x in re.split(r"[\n\r,;]+", raw_kws) if x.strip()]
    iws = [x.strip() for x in re.split(r"[\n\r,;]+", raw_iws) if x.strip()]

    # Шифруем bot_token перед записью на диск. Ключ деривируется из HWID —
    # если user_settings.json утечёт, токен не прочитать на другой машине.
    from utils.secrets_storage import encrypt_secret
    _prev_mon = ctx.user_settings.get("monitor") or {}
    if bot_token:
        bot_token_enc = encrypt_secret(bot_token)
    elif bot_token_keep:
        # Оставляем старый зашифрованный токен как был
        bot_token_enc = _prev_mon.get("bot_token_enc", "")
    else:
        # Клиент явно очистил поле — убираем токен
        bot_token_enc = ""

    # Сохраняем конфиг чтобы пережить рестарт
    ctx.user_settings["monitor"] = {
        "chats": raw_chats, "keywords": raw_kws, "target": target,
        "ignore_words": raw_iws,
        "phone": accs_alive[0].phone,  # для совместимости со старым кодом
        "phones": [a.phone for a in accs_alive],
        "multi_mode": multi_mode,
        "autostart": True,
        "auto_dm_text": auto_dm_text,
        "bot_token_enc": bot_token_enc,
        "bot_chat_id": bot_chat_id,
    }
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass

    from core.monitor import MonitorJob, run_monitor_job
    from core.client_builder import ensure_connected

    # Определяем какие чаты слушает каждый акк:
    # split — равномерное разбиение списка
    # redundant — все слушают всё (но shared dedup не даст дублей)
    if multi_mode == "split" and len(accs_alive) > 1:
        chunks = [chats[i::len(accs_alive)] for i in range(len(accs_alive))]
    else:
        chunks = [list(chats) for _ in accs_alive]

    # Общие dedupe-структуры на пул — один матч в одном чате не дублится
    # на остальных акках если они тоже там сидят.
    shared_msgs = set()
    shared_text_hashes = {}

    new_pool = []
    for acc, my_chats in zip(accs_alive, chunks):
        if not my_chats:
            continue
        job = MonitorJob(
            owner_id=session.get("uid") or 0,
            acc=acc,
            chat_inputs=my_chats,
            keywords=kws,
            ignore_words=iws,
            target=target,
            auto_dm_text=auto_dm_text,
            bot_token=bot_token,
            bot_chat_id=bot_chat_id,
        )
        job._shared_msgs = shared_msgs
        job._shared_text_hashes = shared_text_hashes
        new_pool.append(job)

    ctx.monitor_jobs = new_pool
    ctx.monitor_job = new_pool[0] if new_pool else None  # legacy field — первый в пуле

    _ad_cfg = _build_ad_dict(ctx, request.json)
    if any(_ad_cfg.values()):
        _flags = ', '.join(k for k,v in _ad_cfg.items() if v)
        logging.getLogger("telepush.monitor").info("[защита] включено: %s", _flags)

    def _make_runner(job, acc):
        job.ad_config = _ad_cfg
        async def _run():
            if not await ensure_connected(acc):
                job.status = "stopped"
                _set_job_auth_error(job, acc, "monitor")
                return

            online_stop, online_task = await _start_online_loop(acc.client, _ad_cfg, "monitor")
            if online_stop:
                logging.getLogger("telepush.monitor").info("[защита] %s — онлайн режим", acc.phone)

            try:
                await run_monitor_job(job)
            except Exception as e:
                logger.error("[monitor:%s] crashed: %s", acc.phone, e, exc_info=True)
                job.status = "stopped"
            finally:
                await _stop_online_loop(online_stop, online_task)
        return _run

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        for job in new_pool:
            asyncio.run_coroutine_threadsafe(_make_runner(job, job.acc)(), loop)

    logger.info("[monitor] запущен пул из %d акков (mode=%s)", len(new_pool), multi_mode)
    return jsonify({
        "ok": True,
        "chats": len(chats),
        "keywords": len(kws),
        "phones": [j.acc.phone for j in new_pool],
        "mode": multi_mode,
    })


@app.route("/api/monitor/contacted_count")
@login_required
def api_monitor_contacted_count():
    """Сколько юзеров уже получили авто-DM от мониторинга."""
    try:
        from core.monitor import _load_contacted
        ids = _load_contacted()
        return jsonify({"ok": True, "count": len(ids)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/contacted_reset", methods=["POST"])
@login_required
def api_monitor_contacted_reset():
    """Очистить список получивших авто-DM — чтобы можно было запустить новую волну."""
    try:
        from core.monitor import _save_contacted
        _save_contacted(set())
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/contacted_list")
@login_required
def api_monitor_contacted_list():
    """Возвращает список user_id из игнор-базы. Поддерживает поиск (?q=) и
    пагинацию (?offset=&limit=). Без q возвращаются все, отсортированные по id."""
    try:
        from core.monitor import _load_contacted
        ids = sorted(_load_contacted())
        q = (request.args.get("q") or "").strip()
        if q:
            ids = [i for i in ids if q in str(i)]
        try:
            offset = max(0, int(request.args.get("offset", 0)))
            limit = max(1, min(5000, int(request.args.get("limit", 500))))
        except Exception:
            offset, limit = 0, 500
        return jsonify({
            "ok": True,
            "total": len(ids),
            "ids": ids[offset:offset + limit],
            "offset": offset,
            "limit": limit,
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/contacted_remove", methods=["POST"])
@login_required
def api_monitor_contacted_remove():
    """Удалить один или несколько user_id из игнор-базы. body: {ids:[...]}."""
    try:
        from core.monitor import _load_contacted, _save_contacted
        data = request.json or {}
        raw = data.get("ids") or ([data.get("id")] if data.get("id") else [])
        to_del = set()
        for v in raw:
            try:
                to_del.add(int(str(v).strip()))
            except Exception:
                continue
        if not to_del:
            return jsonify({"ok": False, "error": "Не указаны id для удаления"}), 400
        ids = _load_contacted()
        before = len(ids)
        ids -= to_del
        _save_contacted(ids)
        return jsonify({"ok": True, "removed": before - len(ids), "total": len(ids)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/contacted_import", methods=["POST"])
@login_required
def api_monitor_contacted_import():
    """Импорт user_id в игнор-базу. Принимает текст (по одному id на строке
    или через запятую/пробел). Невалидные строки пропускает. Дубли с
    существующей базой объединяются.

    body: {text: "<...>", mode: "merge"|"replace"}
    """
    try:
        from core.monitor import _load_contacted, _save_contacted
        data = request.json or {}
        text = data.get("text") or ""
        mode = (data.get("mode") or "merge").strip()
        import re as _re
        new_ids = set()
        for tok in _re.split(r"[\s,;\n\r]+", text):
            tok = (tok or "").strip().lstrip("@")
            if not tok:
                continue
            # Только цифры (с опциональным минусом для каналов)
            if _re.fullmatch(r"-?\d+", tok):
                try:
                    new_ids.add(int(tok))
                except Exception:
                    continue
        if mode == "replace":
            base = set()
        else:
            base = _load_contacted()
        before = len(base)
        merged = base | new_ids
        _save_contacted(merged)
        return jsonify({
            "ok": True,
            "added": len(merged) - before,
            "total": len(merged),
            "parsed": len(new_ids),
        })
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/contacted_export")
@login_required
def api_monitor_contacted_export():
    """Скачать игнор-базу как plain text (по id на строке).
    Файл сохраняется в Экспорт/Игнор-база/ + возвращается путь."""
    try:
        from core.monitor import _load_contacted
        ids = sorted(_load_contacted())
        export_dir = EXPORT_DIR / "Игнор-база"  # Игнор-база
        try:
            export_dir.mkdir(parents=True, exist_ok=True)
        except Exception:
            export_dir = DATA_DIR / "exports" / "ignore_db"
            export_dir.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
        path = export_dir / f"ignore_db_{stamp}.txt"
        path.write_text("\n".join(str(i) for i in ids), encoding="utf-8")
        return jsonify({"ok": True, "path": str(path), "count": len(ids)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/detect_chat_id", methods=["POST"])
@login_required
def api_monitor_detect_chat_id():
    """Вызывает Bot API getUpdates и вытаскивает chat_id из последнего
    `/start` сообщения боту. Клиент написал боту со своего Telegram — мы
    видим его user_id в updates и автоматически подставляем в поле."""
    data = request.json or {}
    token = (data.get("token") or "").strip()
    # Если токен пустой — берём сохранённый
    if not token:
        ctx = _get_ctx()
        if ctx:
            from utils.secrets_storage import decrypt_secret
            _enc = (ctx.user_settings.get("monitor") or {}).get("bot_token_enc", "")
            if _enc:
                token = decrypt_secret(_enc)
    if not token:
        return jsonify({"ok": False, "error": "Сначала вставь Bot Token в поле слева"}), 400
    import requests as _req
    try:
        r = _req.get(f"https://api.telegram.org/bot{token}/getUpdates",
                     timeout=10, verify=True)
        d = r.json()
        if not d.get("ok"):
            return jsonify({"ok": False, "error": f"[{d.get('error_code','?')}] {d.get('description','')} — проверь правильность токена"})
        updates = d.get("result", []) or []
        if not updates:
            return jsonify({"ok": False, "error": "Бот не получал сообщений. Напиши боту /start со своего личного Telegram, подожди 5 сек и нажми кнопку ещё раз."})
        # Ищем самое свежее сообщение где есть from
        chat_id = None
        user_info = ""
        for upd in reversed(updates):
            msg = upd.get("message") or upd.get("edited_message") or {}
            chat = msg.get("chat") or {}
            frm = msg.get("from") or {}
            if chat.get("id"):
                chat_id = chat.get("id")
                parts = []
                if frm.get("first_name"): parts.append(frm.get("first_name"))
                if frm.get("last_name"): parts.append(frm.get("last_name"))
                if frm.get("username"): parts.append(f"@{frm.get('username')}")
                user_info = " ".join(parts) if parts else ""
                break
        if not chat_id:
            return jsonify({"ok": False, "error": "В свежих сообщениях бота нет chat_id"})
        return jsonify({"ok": True, "chat_id": str(chat_id), "user_info": user_info})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)[:200]}), 500


@app.route("/api/monitor/test_bot", methods=["POST"])
@login_required
def api_monitor_test_bot():
    """Проверка связки бот+чат: пытаемся отправить тестовое сообщение через
    Bot API. Возвращаем точную причину если не получилось."""
    data = request.json or {}
    token = (data.get("token") or "").strip()
    chat_id = (data.get("chat_id") or "").strip()

    # Если token пустой, а клиент просит «проверь сохранённый» — достаём из config
    if not token:
        ctx = _get_ctx()
        if ctx:
            from utils.secrets_storage import decrypt_secret
            _enc = (ctx.user_settings.get("monitor") or {}).get("bot_token_enc", "")
            if _enc:
                token = decrypt_secret(_enc)
            if not chat_id:
                chat_id = (ctx.user_settings.get("monitor") or {}).get("bot_chat_id", "")

    if not token:
        return jsonify({"ok": False, "error": "Bot Token не задан"}), 400
    if not chat_id:
        return jsonify({"ok": False, "error": "Chat ID не задан"}), 400

    import requests as _req
    try:
        r = _req.post(
            f"https://api.telegram.org/bot{token}/sendMessage",
            json={"chat_id": chat_id,
                  "text": "✅ Тест от TelePush: связка бот+чат работает, уведомления придут сюда."},
            timeout=15, verify=True,
        )
        d = r.json()
        if d.get("ok"):
            return jsonify({"ok": True, "message": "Тестовое сообщение ушло в чат — проверь бота"})
        else:
            _desc = d.get("description", "") or str(d)
            _code = d.get("error_code", "?")
            _hint = ""
            dl = _desc.lower()
            if _code == 401: _hint = " → токен неверный или отозван"
            elif _code == 400 and "chat not found" in dl:
                _hint = " → Chat ID неверный. Напиши боту /start со своего Telegram, потом используй @userinfobot чтобы узнать свой настоящий id"
            elif _code == 403 and "blocked" in dl:
                _hint = " → ты заблокировал бота, разблокируй и напиши /start"
            elif _code == 403:
                _hint = " → напиши боту /start с того Telegram где chat_id=" + str(chat_id)
            return jsonify({"ok": False, "error": f"[{_code}] {_desc}{_hint}"})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)[:200]}), 500


@app.route("/api/monitor/contacted_add", methods=["POST"])
@login_required
def api_monitor_contacted_add():
    """Ручное добавление юзера в игнор. Принимает user_id (число) или @username.
    После добавления монитор будет пропускать все сообщения этого юзера."""
    data = request.json or {}
    raw = (data.get("user") or "").strip()
    if not raw:
        return jsonify({"ok": False, "error": "Введи user_id или @username"}), 400

    async def _resolve():
        ctx = _get_ctx()
        if not ctx:
            return None
        # Номер мониторящего аккаунта — через него резолвим username
        job = getattr(ctx, "monitor_job", None)
        acc = getattr(job, "acc", None) if job else None
        if not acc:
            # Фоллбек: берём первый живой аккаунт
            for p, a in ctx.accounts.items():
                if not a.dead and not getattr(a, "frozen", False):
                    acc = a
                    break
        if not acc:
            return None
        from core.client_builder import ensure_connected
        if not await ensure_connected(acc):
            return None
        try:
            target = raw.lstrip("@").strip()
            ent = await acc.client.get_entity(target)
            return int(getattr(ent, "id", 0) or 0)
        except Exception as e:
            logger.debug("[contacted_add] resolve fail %s: %s", raw, e)
            return None

    # Если уже число — парсим напрямую
    try:
        uid = int(raw.lstrip("@").strip())
    except ValueError:
        uid = None

    if uid is None:
        # Резолвим @username через аккаунт (требует event loop)
        uid = _run_async(_resolve(), timeout=15)

    if not uid:
        return jsonify({"ok": False, "error": "Не удалось получить user_id"}), 400

    from core.monitor import _load_contacted, _save_contacted
    c = _load_contacted()
    c.add(int(uid))
    _save_contacted(c)
    return jsonify({"ok": True, "user_id": uid, "total": len(c)})


@app.route("/api/monitor/open_log", methods=["POST"])
@login_required
def api_monitor_open_log():
    """Открыть CSV-лог матчей мониторинга в Проводнике."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False, "error": "no context"}), 404
    job = getattr(ctx, "monitor_job", None)
    path = getattr(job, "csv_path", "") if job else ""
    if not path:
        # Fallback — открываем всю папку Экспорт
        folder = EXPORT_DIR
        try:
            folder.mkdir(parents=True, exist_ok=True)
            if sys.platform == "win32":
                os.startfile(str(folder))
            return jsonify({"ok": True, "folder": str(folder)})
        except Exception as e:
            return jsonify({"ok": False, "error": str(e)}), 500
    try:
        from pathlib import Path as _P
        _p = _P(path)
        # Пытаемся открыть CSV прямо в Excel (если установлен). Если Excel не найден —
        # показываем файл в проводнике, как раньше. CSV как текст в блокноте — редко
        # что кому нужно, обычно клиенты просят «в экселе».
        if sys.platform == "win32":
            if _p.exists():
                import subprocess
                _opened = False
                # Попытка 1: Excel (EXCEL.EXE должен быть в PATH после установки Office)
                for _excel in ("excel", "EXCEL.EXE"):
                    try:
                        subprocess.Popen([_excel, str(_p)], shell=False)
                        _opened = True
                        break
                    except Exception:
                        continue
                if not _opened:
                    # Попытка 2: start через cmd (Windows откроет ассоциированной программой,
                    # если .csv привязан к Excel — откроется в нём; иначе в Блокноте).
                    try:
                        os.startfile(str(_p))
                        _opened = True
                    except Exception:
                        pass
                if not _opened:
                    # Последний fallback — открыть папку с выделением файла
                    subprocess.Popen(["explorer", "/select,", str(_p)])
            else:
                os.startfile(str(_p.parent))
        else:
            import subprocess
            subprocess.Popen(["xdg-open", str(_p.parent)])
        return jsonify({"ok": True, "path": path})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/monitor/stop", methods=["POST"])
@login_required
def api_monitor_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    matched = 0
    # Стопаем все джобы пула + legacy single. Снимаем handler из Telethon,
    # чтобы освободить память и не получать события задним числом.
    pool = list(getattr(ctx, "monitor_jobs", None) or [])
    legacy = getattr(ctx, "monitor_job", None)
    if legacy and legacy not in pool:
        pool.append(legacy)
    for job in pool:
        try: matched += int(getattr(job, "matched", 0) or 0)
        except Exception: pass
        try: job.stop_flag = True
        except Exception: pass
        try: job.status = "stopped"
        except Exception: pass
        try:
            _h = getattr(job, "_handler", None)
            _cli = getattr(getattr(job, "acc", None), "client", None)
            if _h is not None and _cli is not None:
                _cli.remove_event_handler(_h)
        except Exception:
            pass
    ctx.monitor_jobs = []
    ctx.monitor_job = None
    # Отключаем автостарт чтобы scheduler не поднял мониторинг снова до того
    # как клиент сам решит запустить
    mon = ctx.user_settings.get("monitor") or {}
    mon["autostart"] = False
    ctx.user_settings["monitor"] = mon
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass
    return jsonify({"ok": True, "matched": matched, "stopped": len(pool)})


@app.route("/api/parser/open_export_folder", methods=["POST"])
@login_required
def api_parser_open_export_folder():
    """Открыть папку с экспортами в Проводнике."""
    folder = EXPORT_DIR
    if not folder.exists():
        folder = DATA_DIR / "exports"
    try:
        folder.mkdir(parents=True, exist_ok=True)
        if sys.platform == "win32":
            os.startfile(str(folder))
        else:
            import subprocess
            subprocess.Popen(["xdg-open", str(folder)])
        return jsonify({"ok": True, "path": str(folder)})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e), "path": str(folder)}), 500


@app.route("/api/parser/use_for_dm", methods=["POST"])
@login_required
def api_parser_use_for_dm():
    """Отправить результат парсинга в DM рассылку."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    parser = ctx.temp.get("parser", {})
    result = parser.get("result") or []
    if not result:
        return jsonify({"error": "нет результатов парсинга"}), 400

    # Добавляем в DM targets
    existing = ctx.user_spam.get("targets") or []
    existing_set = set(t.lower() if isinstance(t, str) else t for t in existing)
    added = 0
    for u in result:
        key = u.lower()
        if key not in existing_set:
            existing.append(u)
            existing_set.add(key)
            added += 1
    ctx.user_spam["targets"] = existing
    return jsonify({"ok": True, "added": added, "total": len(existing)})


@app.route("/api/parser/use_for_join", methods=["POST"])
@login_required
def api_parser_use_for_join():
    """Отправить спарсенные ссылки в подписку."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    parser = ctx.temp.get("parser", {})
    result = parser.get("result") or []
    if not result:
        return jsonify({"error": "нет результатов парсинга"}), 400

    # Фильтруем только t.me ссылки
    links = [u for u in result if 't.me/' in u]
    existing = ctx.temp.get("links") or []
    existing_set = set(l.lower() for l in existing)
    added = 0
    for link in links:
        if link.lower() not in existing_set:
            existing.append(link)
            existing_set.add(link.lower())
            added += 1
    ctx.temp["links"] = existing
    return jsonify({"ok": True, "added": added, "total": len(existing)})



# ── API: Profile Filler ──────────────────────────────────────────────────────

@app.route("/api/profile/update", methods=["POST"])
@login_required
def api_profile_update():
    """Обновить профиль аккаунта (имя, био). Поддержка спинтакса и генерации."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    data = request.json or {}
    phone = data.get("phone", ctx.active)
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 404

    from core.client_builder import ensure_connected
    from core.profile import update_profile
    from bot_ui.utils import spintax_expand
    from utils.names import generate_full

    # Генерация имён
    fn = data.get("first_name")
    ln = data.get("last_name")
    un = data.get("username")
    ab = data.get("about")

    if data.get("gen_name") or data.get("gen_surname"):
        import random
        gender = random.choice(["male", "female"])
        gn, gs = generate_full(gender)
        if data.get("gen_name"):
            fn = gn
        if data.get("gen_surname"):
            ln = gs

    if data.get("gen_username"):
        un = _generate_username(fn, ln, phone)

    # Спинтакс
    if fn:
        fn = spintax_expand(fn)
    if ln:
        ln = spintax_expand(ln)
    if ab:
        ab = spintax_expand(ab)

    async def _do():
        if not await ensure_connected(acc):
            return {"ok": False, "error": "не удалось подключить"}
        return await update_profile(acc, first_name=fn, last_name=ln, about=ab, username=un)

    result = _run_async(_do(), timeout=15)
    return jsonify(result or {"error": "таймаут"})


@app.route("/api/profile/generate_name")
@login_required
def api_profile_generate_name():
    """Сгенерировать случайное имя+фамилию для предпросмотра."""
    from utils.names import generate_full
    fn, ln = generate_full()
    return jsonify({"first_name": fn, "last_name": ln})


def _generate_username(first_name: str = "", last_name: str = "", phone: str = "") -> str:
    """Генерирует username из имени/фамилии + случайных цифр.
    Telegram разрешает a-z, 0-9, _. 5-32 символа. Начинается с буквы."""
    import random, re
    # Транслит имя+фамилия → латиница
    _TR = {
        'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i',
        'й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t',
        'у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'',
        'э':'e','ю':'yu','я':'ya',
    }
    base = (first_name + last_name).lower()
    out = "".join(_TR.get(c, c) for c in base)
    out = re.sub(r'[^a-z0-9]', '', out)
    if not out or not out[0].isalpha():
        out = "user" + out
    # Добавляем суффикс для уникальности: 4 случайные цифры
    out = out[:20] + "_" + str(random.randint(1000, 9999))
    # Telegram: минимум 5 символов
    if len(out) < 5:
        out = out + str(random.randint(10000, 99999))
    return out[:32]


@app.route("/api/profile/generate_username")
@login_required
def api_profile_generate_username():
    """Предпросмотр сгенерированного username."""
    ctx = _get_ctx()
    fn = ""
    ln = ""
    if ctx and ctx.active:
        acc = ctx.accounts.get(ctx.active)
        if acc:
            fn = getattr(acc, "first_name", "") or ""
            ln = getattr(acc, "last_name", "") or ""
    return jsonify({"username": _generate_username(fn, ln, ctx.active if ctx else "")})


@app.route("/api/profile/avatar_folder_scan", methods=["POST"])
@login_required
def api_profile_avatar_folder_scan():
    """Считает сколько .jpg/.png файлов в указанной папке. Нужно чтобы клиент
    мог проверить правильность пути до того как жать Применить."""
    data = request.json or {}
    folder = (data.get("folder") or "").strip()
    if not folder:
        return jsonify({"ok": False, "error": "пустой путь"}), 400
    from pathlib import Path as _P
    p = _P(folder)
    if not p.exists() or not p.is_dir():
        return jsonify({"ok": False, "error": "папка не найдена"}), 404
    exts = (".jpg", ".jpeg", ".png", ".webp")
    files = [f for f in p.iterdir() if f.is_file() and f.suffix.lower() in exts]
    samples = [f.name for f in files[:3]]
    return jsonify({"ok": True, "count": len(files), "samples": samples})


@app.route("/api/profile/avatar_folder_open", methods=["POST"])
@login_required
def api_profile_avatar_folder_open():
    """Открывает указанную папку с фото в Проводнике."""
    data = request.json or {}
    folder = (data.get("folder") or "").strip()
    if not folder:
        return jsonify({"ok": False, "error": "пустой путь"}), 400
    from pathlib import Path as _P
    p = _P(folder)
    if not p.exists():
        return jsonify({"ok": False, "error": "папка не найдена"}), 404
    try:
        if sys.platform == "win32":
            os.startfile(str(p))
        else:
            import subprocess as _sp
            _sp.Popen(["xdg-open", str(p)])
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


# Счётчик использованных фото для режима «Ко всем аккаунтам» — разным аккам разное.
_avatar_folder_used: dict = {}


@app.route("/api/profile/avatar_from_folder", methods=["POST"])
@login_required
def api_profile_avatar_from_folder():
    """Берёт случайное (ещё не использованное в этой сессии) фото из папки и ставит
    как аватар текущему выбранному аккаунту. При массовом apply-all каждый акк
    получает разное фото — пока папка не закончится, потом снова с начала."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phone = data.get("phone") or ctx.active
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"ok": False, "error": "аккаунт не найден"}), 404

    folder = (data.get("folder") or "").strip()
    if not folder:
        return jsonify({"ok": False, "error": "пустой путь"}), 400
    from pathlib import Path as _P
    p = _P(folder)
    if not p.exists() or not p.is_dir():
        return jsonify({"ok": False, "error": "папка не найдена"}), 404

    exts = (".jpg", ".jpeg", ".png", ".webp")
    files = sorted([f for f in p.iterdir() if f.is_file() and f.suffix.lower() in exts])
    if not files:
        return jsonify({"ok": False, "error": "в папке нет jpg/png/webp"}), 404

    # Берём фото которое ещё не использовалось в этой папке
    used = _avatar_folder_used.setdefault(str(p), set())
    import random
    available = [f for f in files if f.name not in used]
    if not available:
        # Прошли полный круг — начинаем заново
        used.clear()
        available = list(files)
    chosen = random.choice(available)
    used.add(chosen.name)

    try:
        photo_bytes = chosen.read_bytes()
    except Exception as e:
        return jsonify({"ok": False, "error": f"не смог прочитать {chosen.name}: {e}"}), 500

    from core.client_builder import ensure_connected
    from core.profile import update_avatar

    async def _do():
        if not await ensure_connected(acc):
            return {"ok": False, "error": "не удалось подключить"}
        return await update_avatar(acc, photo_bytes)

    result = _run_async(_do(), timeout=30) or {"ok": False, "error": "таймаут"}
    if result.get("ok"):
        result["file"] = chosen.name
    return jsonify(result)


@app.route("/api/profile/avatar_emoji", methods=["POST"])
@login_required
def api_profile_avatar_emoji():
    """Генерирует png-аватарку из эмодзи + цвета фона и устанавливает как аватар.
    Нужно потому что клиенты часто просят «нейтральную» аватарку без фото."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    phone = (request.json or {}).get("phone") or ctx.active
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 404

    data = request.json or {}
    user_emoji = (data.get("emoji") or "").strip()
    bg_hex = (data.get("bg") or "#3b82f6").strip()

    # Подборка эмодзи для случайного выбора (нейтральные, без лиц — безопасно)
    DEFAULT_EMOJIS = [
        "🎭", "🎨", "🎪", "🎯", "🎲", "🎹", "🎸", "🎺", "🎻", "🎬",
        "🌟", "⭐", "✨", "🔥", "💎", "🌈", "🌸", "🌺", "🌻", "🌼",
        "🍀", "🌴", "🌵", "🎃", "🎄", "🎁", "🎀", "🎮", "🎧", "📸",
        "🚀", "✈", "🚗", "⚽", "🏀", "🎾", "🏆", "🥇", "💡", "🔮",
    ]

    import random
    emoji_char = user_emoji if user_emoji else random.choice(DEFAULT_EMOJIS)

    # Генерация png 512x512 через Pillow
    try:
        from PIL import Image, ImageDraw, ImageFont
        import io as _io
        # Парсим hex цвет
        try:
            _h = bg_hex.lstrip("#")
            if len(_h) == 3:
                _h = "".join(c*2 for c in _h)
            bg = tuple(int(_h[i:i+2], 16) for i in (0, 2, 4))
        except Exception:
            bg = (59, 130, 246)  # default blue

        img = Image.new("RGB", (512, 512), bg)
        draw = ImageDraw.Draw(img)

        # Пытаемся найти эмодзи-шрифт. Segoe UI Emoji есть на Windows 10+.
        font = None
        for font_path in [
            "C:\\Windows\\Fonts\\seguiemj.ttf",
            "C:\\Windows\\Fonts\\seguisb.ttf",
            "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
        ]:
            try:
                font = ImageFont.truetype(font_path, 320)
                break
            except Exception:
                continue
        if font is None:
            font = ImageFont.load_default()

        # Центрируем эмодзи
        try:
            bbox = draw.textbbox((0, 0), emoji_char, font=font, embedded_color=True)
            w = bbox[2] - bbox[0]
            h = bbox[3] - bbox[1]
            x = (512 - w) // 2 - bbox[0]
            y = (512 - h) // 2 - bbox[1]
            draw.text((x, y), emoji_char, font=font, embedded_color=True)
        except Exception:
            draw.text((150, 100), emoji_char, font=font)

        buf = _io.BytesIO()
        img.save(buf, format="JPEG", quality=92)
        photo_bytes = buf.getvalue()
    except Exception as e:
        return jsonify({"ok": False, "error": f"Не удалось сгенерировать аватарку: {type(e).__name__}: {e}"}), 500

    from core.client_builder import ensure_connected
    from core.profile import update_avatar

    async def _do():
        if not await ensure_connected(acc):
            return {"ok": False, "error": "не удалось подключить"}
        return await update_avatar(acc, photo_bytes)

    result = _run_async(_do(), timeout=20) or {"ok": False, "error": "таймаут"}
    if result.get("ok"):
        result["used_emoji"] = emoji_char
    return jsonify(result)


@app.route("/api/profile/avatar", methods=["POST"])
@login_required
def api_profile_avatar():
    """Загрузить аватарку."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    phone = request.form.get("phone", ctx.active)
    acc = ctx.accounts.get(phone)
    if not acc:
        return jsonify({"error": "аккаунт не найден"}), 404

    if "file" not in request.files:
        return jsonify({"error": "нет файла"}), 400
    photo = request.files["file"].read()
    if not photo:
        return jsonify({"error": "пустой файл"}), 400

    from core.client_builder import ensure_connected
    from core.profile import update_avatar

    async def _do():
        if not await ensure_connected(acc):
            return {"ok": False, "error": "не удалось подключить"}
        return await update_avatar(acc, photo)

    result = _run_async(_do(), timeout=20)
    return jsonify(result or {"error": "таймаут"})


@app.route("/api/profile/fill_bulk", methods=["POST"])
@login_required
def api_profile_fill_bulk():
    """Массовое заполнение профилей."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    data = request.json or {}
    phones = data.get("phones") or [ctx.active]
    accounts = [ctx.accounts[p] for p in phones if p in ctx.accounts]
    if not accounts:
        return jsonify({"error": "нет аккаунтов"}), 400

    from core.profile import fill_profiles_bulk

    fill_state = ctx.temp.setdefault("profile_fill", {})
    fill_state["status"] = "running"
    fill_state["total"] = len(accounts)
    fill_state["done"] = 0

    async def _fill():
        async def _progress(done, total):
            fill_state["done"] = done

        result = await fill_profiles_bulk(
            accounts,
            first_name=data.get("first_name"),
            last_name=data.get("last_name"),
            about=data.get("about"),
            gen_name=data.get("gen_name", False),
            gen_surname=data.get("gen_surname", False),
            progress_cb=_progress,
        )
        fill_state["status"] = "done"
        fill_state["result"] = result

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_fill(), loop)

    return jsonify({"ok": True, "total": len(accounts)})


# ── API: Auto-Responder ─────────────────────────────────────────────────────

@app.route("/api/autoresponder/start", methods=["POST"])
@login_required
def api_autoresponder_start():
    """Запустить автоответчик."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    data = request.json or {}
    phones = data.get("phones") or [ctx.active]
    text = data.get("text", "").strip()
    if not text:
        return jsonify({"error": "введите текст ответа"}), 400

    delay_min = float(data.get("delay_min", 3))
    delay_max = float(data.get("delay_max", 8))
    once = data.get("once_per_user", True)
    backfill_hours = float(data.get("backfill_hours", 0) or 0)
    # Новые опции автоответчика (1.5.49)
    stop_words = data.get("stop_words") or []
    if isinstance(stop_words, str):
        stop_words = [s.strip() for s in stop_words.split(",") if s.strip()]
    simulate_typing = bool(data.get("simulate_typing", False))
    mark_as_read = bool(data.get("mark_as_read", False))

    from core.client_builder import ensure_connected
    from core.profile import start_autoresponder

    started = 0
    backfill_total = 0
    errors = []

    async def _start():
        nonlocal started, backfill_total
        for phone in phones:
            acc = ctx.accounts.get(phone)
            if not acc:
                continue
            if not await ensure_connected(acc):
                errors.append(f"{phone}: offline")
                continue
            r = await start_autoresponder(acc, text, delay_min, delay_max, once,
                                          backfill_hours=backfill_hours,
                                          stop_words=stop_words,
                                          simulate_typing=simulate_typing,
                                          mark_as_read=mark_as_read)
            if r.get("ok"):
                started += 1
                backfill_total += int(r.get("backfill_replies", 0))
            else:
                errors.append(f"{phone}: {r.get('error', '?')}")

    # Таймаут увеличил — backfill-скан может занимать до 30с на акк
    _run_async(_start(), timeout=max(30, 10 + len(phones) * 20))
    return jsonify({"ok": True, "started": started,
                    "backfill_replies": backfill_total, "errors": errors})


@app.route("/api/autoresponder/stop", methods=["POST"])
@login_required
def api_autoresponder_stop():
    """Остановить автоответчик."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    data = request.json or {}
    phones = data.get("phones") or [ctx.active]

    from core.profile import stop_autoresponder

    stopped = 0
    total_replied = 0
    async def _stop():
        nonlocal stopped, total_replied
        for phone in phones:
            acc = ctx.accounts.get(phone)
            if not acc:
                continue
            r = await stop_autoresponder(acc)
            if r.get("ok"):
                stopped += 1
                total_replied += r.get("replied", 0)

    _run_async(_stop(), timeout=15)
    return jsonify({"ok": True, "stopped": stopped, "replied": total_replied})


@app.route("/api/autoresponder/status")
@login_required
def api_autoresponder_status():
    """Статус автоответчиков."""
    from core.profile import get_all_autoresponders
    return jsonify({"responders": get_all_autoresponders()})


# ── API: Proxy ───────────────────────────────────────────────────────────────

_PROXY_FILE = DATA_DIR / "proxies.json"

def _load_proxies():
    proxies = []
    try:
        if _PROXY_FILE.exists():
            proxies = json.loads(_PROXY_FILE.read_text(encoding="utf-8"))
    except Exception:
        pass

    # Авто-импорт из proxies.txt
    txt_file = DATA_DIR / "proxies.txt"
    try:
        if txt_file.exists():
            existing = {p.get("addr", "") for p in proxies}
            lines = txt_file.read_text(encoding="utf-8").splitlines()
            added = 0
            for line in lines:
                addr = line.strip()
                if addr and not addr.startswith("#") and addr not in existing:
                    proxies.append({"addr": addr, "status": "unchecked", "alive": None})
                    existing.add(addr)
                    added += 1
            if added:
                _save_proxies(proxies)
                logger.info("[proxy] Импортировано %d прокси из proxies.txt", added)
            txt_file.unlink(missing_ok=True)
    except Exception as e:
        logger.debug("[proxy] proxies.txt import: %s", e)

    return proxies

def _save_proxies(proxies):
    try:
        _PROXY_FILE.write_text(json.dumps(proxies, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception as e:
        logger.warning("[proxy] save error: %s", e)

@app.route("/api/proxy/list")
@login_required
def api_proxy_list():
    proxies = _load_proxies()
    return jsonify({"proxies": proxies})


@app.route("/api/proxy/add", methods=["POST"])
@login_required
def api_proxy_add():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    text = data.get("text", "")
    default_type = data.get("default_type", "socks5")
    lines = [l.strip() for l in text.splitlines() if l.strip() and not l.strip().startswith("#")]
    proxies = _load_proxies()
    existing = {p.get("addr", "") for p in proxies}
    added = 0
    for line in lines:
        addr = line.strip()
        # Убираем лишние кавычки/пробелы
        addr = addr.strip("'\"")
        if not addr:
            continue
        # Сохраняем без схемы — определим при проверке
        if "://" not in addr:
            addr = addr  # raw формат, протокол определится автоматически
        if addr not in existing:
            proxies.append({"addr": addr, "status": "unchecked", "alive": None, "proto": ""})
            existing.add(addr)
            added += 1
    _save_proxies(proxies)
    logger.info("[proxy] Добавлено %d новых прокси, всего: %d", added, len(proxies))
    return jsonify({"ok": True, "added": added, "count": len(proxies)})


def _fetch_proxy_geo_once(proxy_url: str, _req, timeout: int = 8) -> dict:
    """Один запрос через прокси к ip-api.com. Возвращает {ip, country, cc, city}."""
    try:
        r = _req.get("http://ip-api.com/json/?fields=status,country,countryCode,city,query",
                     proxies={"http": proxy_url, "https": proxy_url},
                     timeout=timeout, verify=False)
        data = r.json()
        if data.get("status") != "success":
            return {}
        return {
            "ip": data.get("query", ""),
            "country": data.get("country", ""),
            "cc": data.get("countryCode", ""),
            "city": data.get("city", ""),
        }
    except Exception as e:
        logger.debug("[proxy-geo] %s: %s", proxy_url[:30], e)
        return {}


def _fetch_proxy_geo(proxy_url: str, _req, timeout: int = 8) -> dict:
    """Детектим rotating-прокси: делаем 3 запроса подряд через прокси.
    Если exit-IP одинаковый → статический прокси (показываем гео).
    Если IP разные → residential pool, помечаем как rotating — клиенту
    показываем что реальная страна варьируется (proxyma.io / iproyal / bright).

    Возвращает {ip, country, cc, city, rotating, seen_ips}.
    """
    samples = []
    for _ in range(3):
        g = _fetch_proxy_geo_once(proxy_url, _req, timeout=timeout)
        if g and g.get("ip"):
            samples.append(g)
    if not samples:
        return {}
    unique_ips = {s["ip"] for s in samples}
    unique_countries = {s.get("cc", "") for s in samples if s.get("cc")}
    result = dict(samples[0])
    if len(unique_ips) > 1:
        result["rotating"] = True
        result["seen_ips"] = sorted(unique_ips)
        if len(unique_countries) > 1:
            # Рекламировали одно, но выпадают разные страны — пересобираем поле
            # так чтобы в UI было ясно что гео не стабильно.
            result["country"] = " / ".join(sorted({s.get("country", "?") for s in samples}))
            result["cc"] = "??"
            result["city"] = ""
    else:
        result["rotating"] = False
    return result


@app.route("/api/proxy/check", methods=["POST"])
@login_required
def api_proxy_check():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    import requests as _req
    proxies = _load_proxies()
    for p in proxies:
        addr = p["addr"]
        ok, proto, err, resolved = _check_proxy(addr, _req)
        p["alive"] = ok
        p["status"] = f"alive ({proto})" if ok else "dead"
        p["error"] = "" if ok else err
        p["proto"] = proto if ok else ""
        if ok and resolved:
            p["resolved_url"] = resolved
            # Резолвим гео exit-IP чтобы клиент видел через какую страну
            # на самом деле выходит трафик. Proxy-провайдеры часто дают
            # не ту локацию которую обещали в рекламе.
            geo = _fetch_proxy_geo(resolved, _req)
            if geo:
                p["exit_ip"] = geo.get("ip", "")
                p["country"] = geo.get("country", "")
                p["cc"] = geo.get("cc", "")
                p["city"] = geo.get("city", "")
                p["rotating"] = bool(geo.get("rotating"))
                p["seen_ips"] = geo.get("seen_ips", [])
            else:
                p["exit_ip"] = ""
                p["country"] = ""
                p["cc"] = ""
                p["city"] = ""
                p["rotating"] = False
                p["seen_ips"] = []
        if not ok:
            logger.debug("[proxy] %s -> dead (%s)", addr[:40], err)
    _save_proxies(proxies)
    alive = sum(1 for p in proxies if p["alive"])
    return jsonify({"ok": True, "total": len(proxies), "alive": alive})


@app.route("/api/proxy/recheck", methods=["POST"])
@login_required
def api_proxy_recheck():
    """Перепроверить один прокси по индексу."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    import requests as _req
    data = request.json or {}
    idx = data.get("index", -1)
    proxies = _load_proxies()
    if idx < 0 or idx >= len(proxies):
        return jsonify({"error": "invalid index"}), 400
    p = proxies[idx]
    ok, proto, err, resolved = _check_proxy(p["addr"], _req)
    p["alive"] = ok
    p["status"] = f"alive ({proto})" if ok else "dead"
    p["error"] = "" if ok else err
    p["proto"] = proto if ok else ""
    if ok and resolved:
        p["resolved_url"] = resolved
        geo = _fetch_proxy_geo(resolved, _req)
        if geo:
            p["exit_ip"] = geo.get("ip", "")
            p["country"] = geo.get("country", "")
            p["cc"] = geo.get("cc", "")
            p["city"] = geo.get("city", "")
            p["rotating"] = bool(geo.get("rotating"))
            p["seen_ips"] = geo.get("seen_ips", [])
    _save_proxies(proxies)
    return jsonify({"ok": True, "alive": ok, "proto": proto, "error": err,
                    "country": p.get("country", ""), "city": p.get("city", ""),
                    "exit_ip": p.get("exit_ip", ""),
                    "rotating": p.get("rotating", False)})


@app.route("/api/proxy/remove", methods=["POST"])
@login_required
def api_proxy_remove():
    """Удалить прокси по индексу."""
    data = request.json or {}
    idx = data.get("index", -1)
    proxies = _load_proxies()
    if idx < 0 or idx >= len(proxies):
        return jsonify({"error": "invalid index"}), 400
    proxies.pop(idx)
    _save_proxies(proxies)
    return jsonify({"ok": True})


@app.route("/api/proxy/bulk_bind", methods=["POST"])
@login_required
def api_proxy_bulk_bind():
    """Массовая привязка прокси к аккаунтам.
    target: 'all' | 'all_no_proxy' | 'alive'
    mode:   'round_robin' | 'random' | 'one_each'
    """
    import random as _rnd
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    target = data.get("target", "all_no_proxy")
    mode = data.get("mode", "round_robin")

    # 1. Берём только живые прокси (status alive)
    proxies = _load_proxies()
    alive = [p for p in proxies if p.get("alive") is True]
    if not alive:
        return jsonify({"error": "Нет живых прокси. Сначала «Проверить все» — нужен хотя бы 1 рабочий."}), 400

    # 2. Отбираем целевых акков
    target_accs = []
    for phone, acc in ctx.accounts.items():
        if target == "alive" and (acc.dead or getattr(acc, "frozen", False)):
            continue
        if target == "all_no_proxy" and getattr(acc, "proxy", None):
            continue
        target_accs.append(acc)

    if not target_accs:
        return jsonify({"error": "Нет аккаунтов под условие"}), 400

    # 3. Распределяем
    def _bind(acc, proxy_addr: str) -> bool:
        """Записывает прокси в .json файл аккаунта + чистит acc.proxy
        чтобы при следующем connect подхватилось из json."""
        try:
            from models import ProxyResolved
            # Парсим в формат [host, port, user, pass]
            pr = ProxyResolved.parse(proxy_addr)
            if not pr or not pr.json_vector:
                return False
            # Сохраняем в json акка
            from config import USERS_DIR
            json_path = USERS_DIR / f"{acc.phone}.json"
            if not json_path.exists():
                # Файл может быть в другой папке (Прогрев, Заморозка...) — найдём
                for sub in (USERS_DIR.parent.iterdir() if USERS_DIR.parent.exists() else []):
                    if sub.is_dir():
                        cand = sub / f"{acc.phone}.json"
                        if cand.exists():
                            json_path = cand
                            break
            if json_path.exists():
                import json as _json
                meta = _json.loads(json_path.read_text(encoding="utf-8"))
                meta["proxy"] = pr.json_vector
                json_path.write_text(_json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
            # Сбрасываем acc.proxy — на следующем connect подхватит из json
            acc.proxy = pr.pysocks_tuple if hasattr(pr, 'pysocks_tuple') else None
            return True
        except Exception as e:
            logger.debug("[bulk-bind] %s: %s", acc.phone, e)
            return False

    bound = 0
    if mode == "one_each":
        n = min(len(alive), len(target_accs))
        for i in range(n):
            if _bind(target_accs[i], alive[i].get("addr", "")):
                bound += 1
    else:
        pool = list(alive)
        if mode == "random":
            _rnd.shuffle(pool)
        for i, acc in enumerate(target_accs):
            p = pool[i % len(pool)]
            if _bind(acc, p.get("addr", "")):
                bound += 1

    logger.info("[proxy-bulk] target=%s mode=%s bound=%d/%d (proxies=%d)",
                target, mode, bound, len(target_accs), len(alive))
    return jsonify({"ok": True, "bound": bound, "total_accs": len(target_accs),
                    "proxies": len(alive)})


@app.route("/api/proxy/bulk_unbind", methods=["POST"])
@login_required
def api_proxy_bulk_unbind():
    """Снять прокси со всех аккаунтов (записывает proxy=null в json + очищает acc.proxy)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    from config import USERS_DIR
    import json as _json
    unbound = 0
    for phone, acc in ctx.accounts.items():
        if getattr(acc, "proxy", None):
            try:
                # Найдём json акка
                json_path = None
                for sub in (USERS_DIR.parent.iterdir() if USERS_DIR.parent.exists() else []):
                    if sub.is_dir():
                        cand = sub / f"{acc.phone}.json"
                        if cand.exists():
                            json_path = cand
                            break
                if json_path:
                    meta = _json.loads(json_path.read_text(encoding="utf-8"))
                    meta["proxy"] = None
                    json_path.write_text(_json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
                acc.proxy = None
                unbound += 1
            except Exception:
                pass
    logger.info("[proxy-bulk] unbound %d accounts", unbound)
    return jsonify({"ok": True, "unbound": unbound})


def _normalize_proxy_url(addr: str, proto: str = "socks5") -> str:
    """Нормализует любой формат прокси в <proto>://user:pass@host:port.
    Схему `socks` (без версии) всегда превращаем в `socks5` — requests
    не понимает `socks://` и падает с ValueError."""
    addr = addr.strip()
    scheme = proto
    rest = addr
    if "://" in addr:
        scheme, rest = addr.split("://", 1)
    if scheme == "socks":
        scheme = "socks5"

    if "@" in rest:
        return f"{scheme}://{rest}"

    parts = rest.split(":")
    if len(parts) == 4:
        return f"{scheme}://{parts[2]}:{parts[3]}@{parts[0]}:{parts[1]}"
    if len(parts) == 2:
        return f"{scheme}://{parts[0]}:{parts[1]}"
    return f"{scheme}://{rest}"


def _parse_proxy_parts(addr: str):
    """Вытаскивает (host, port, user, password, given_scheme) из любого формата.
    Возвращает None при невалидном формате."""
    addr = addr.strip().strip("'\"")
    scheme = ""
    rest = addr
    if "://" in addr:
        scheme, rest = addr.split("://", 1)
        if scheme == "socks":
            scheme = "socks5"
    user = password = ""
    if "@" in rest:
        cred, hostpart = rest.rsplit("@", 1)
        if ":" in cred:
            user, password = cred.split(":", 1)
        else:
            user = cred
        if ":" not in hostpart:
            return None
        host, port_s = hostpart.rsplit(":", 1)
    else:
        parts = rest.split(":")
        if len(parts) == 4:
            host, port_s, user, password = parts
        elif len(parts) == 2:
            host, port_s = parts
        else:
            return None
    try:
        port = int(port_s)
    except ValueError:
        return None
    return host.strip(), port, user, password, scheme


def _socks5_probe(host: str, port: int, user: str, password: str, timeout: int = 10) -> tuple:
    """SOCKS5-проба через PySocks с реальным CONNECT к api.telegram.org.

    Используем PySocks (ту же библиотеку что Telethon) — если она может
    открыть соединение через прокси, значит проксия точно рабочая для наших
    нужд. Раньше делали ручной RFC1929 greeting + auth check — но некоторые
    residential-прокси (proxyma.io, iproyal) отвечают на auth-packet
    нестандартно (версия 5 вместо 1 в ответе), и мы ошибочно говорили
    «неверный пароль» хотя у Raptor/Telethon всё работало.

    Возвращает (ok, error_text).
    """
    import socket
    try:
        import socks as _socks
    except Exception:
        # PySocks нет — fallback на наш raw probe (старый код)
        return _socks5_probe_raw(host, port, user, password, timeout)

    s = _socks.socksocket()
    s.settimeout(timeout)
    try:
        s.set_proxy(_socks.SOCKS5, host, port,
                    username=user or None, password=password or None)
    except Exception as e:
        return False, f"ошибка настройки прокси: {type(e).__name__}"

    # CONNECT через прокси до реального Telegram-сайта. Если прокси нас
    # пускает до телеги — он гарантированно пригоден для Telethon.
    try:
        s.connect(("149.154.167.50", 443))  # DC2 Telegram
        return True, ""
    except _socks.ProxyConnectionError as e:
        return False, f"нет связи с прокси ({e})"
    except _socks.GeneralProxyError as e:
        _msg = str(e).lower()
        if "auth" in _msg or "authentication" in _msg:
            return False, "неверный логин/пароль"
        if "not a socks" in _msg or "wrong version" in _msg:
            return False, "не SOCKS5"
        return False, f"SOCKS: {e}"
    except _socks.SOCKS5AuthError:
        return False, "неверный логин/пароль"
    except _socks.SOCKS5Error as e:
        return False, f"SOCKS5 error: {e}"
    except socket.timeout:
        return False, "таймаут"
    except OSError as e:
        return False, f"нет связи ({e.errno or e.strerror or 'OSError'})"
    except Exception as e:
        return False, f"{type(e).__name__}: {str(e)[:60]}"
    finally:
        try: s.close()
        except Exception: pass


def _socks5_probe_raw(host: str, port: int, user: str, password: str, timeout: int = 10) -> tuple:
    """Fallback: ручной SOCKS5 handshake по RFC1928/1929 если PySocks не стоит."""
    import socket
    try:
        s = socket.create_connection((host, port), timeout=timeout)
    except socket.timeout:
        return False, "таймаут TCP"
    except ConnectionRefusedError:
        return False, "порт закрыт"
    except OSError as e:
        return False, f"нет связи ({e.errno or e.strerror or 'OSError'})"
    try:
        s.settimeout(timeout)
        s.sendall(b"\x05\x02\x00\x02")
        hdr = s.recv(2)
        if len(hdr) < 2 or hdr[0] != 0x05:
            return False, "не SOCKS5"
        method = hdr[1]
        if method == 0xFF:
            return False, "прокси не принимает ни один метод"
        if method == 0x00:
            return True, ""
        if method == 0x02:
            if not user or not password:
                return False, "нужен логин/пароль"
            u = user.encode("utf-8"); p = password.encode("utf-8")
            req = b"\x01" + bytes([len(u)]) + u + bytes([len(p)]) + p
            s.sendall(req)
            resp = s.recv(2)
            if len(resp) < 2:
                return False, "прокси не ответил на auth"
            if resp[1] == 0x00:
                return True, ""
            return False, "неверный логин/пароль"
        return False, f"неизвестный метод 0x{method:02x}"
    except socket.timeout:
        return False, "таймаут SOCKS handshake"
    except Exception as e:
        return False, f"{type(e).__name__}"
    finally:
        try: s.close()
        except Exception: pass


def _http_connect_probe(host: str, port: int, user: str, password: str, timeout: int = 10) -> tuple:
    """HTTP CONNECT-проба к api.telegram.org:443. Возвращает (ok, error_text)."""
    import socket, base64
    try:
        s = socket.create_connection((host, port), timeout=timeout)
    except socket.timeout:
        return False, "таймаут TCP"
    except ConnectionRefusedError:
        return False, "порт закрыт"
    except OSError as e:
        return False, f"нет связи ({e.errno or e.strerror or 'OSError'})"
    try:
        s.settimeout(timeout)
        req = f"CONNECT api.telegram.org:443 HTTP/1.1\r\nHost: api.telegram.org:443\r\n"
        if user or password:
            token = base64.b64encode(f"{user}:{password}".encode()).decode()
            req += f"Proxy-Authorization: Basic {token}\r\n"
        req += "\r\n"
        s.sendall(req.encode())
        resp = s.recv(256).decode("latin-1", errors="replace")
        first = resp.split("\r\n", 1)[0] if resp else ""
        if not first:
            return False, "пустой ответ"
        # HTTP/1.1 200 Connection established
        parts = first.split(" ", 2)
        if len(parts) >= 2 and parts[1].startswith("2"):
            return True, ""
        if "407" in first:
            return False, "нужен логин/пароль (407)"
        if "403" in first:
            return False, "доступ запрещён (403)"
        return False, first[:60]
    except socket.timeout:
        return False, "таймаут HTTP CONNECT"
    except Exception as e:
        return False, f"{type(e).__name__}"
    finally:
        try: s.close()
        except Exception: pass


def _check_proxy(addr: str, _req) -> tuple:
    """Автоопределение протокола через прямые TCP-пробы + HTTP-верификацию.
    Returns (ok, proto, error, resolved_url).

    Алгоритм:
      1) Парсим host:port:user:pass из любого формата (scheme:// опционально).
      2) SOCKS5 raw-probe: открываем TCP, шлём greeting, смотрим ответ.
         Различаем: not SOCKS5 / нужен auth / неверный пароль / таймаут / refused.
      3) Если SOCKS5 handshake прошёл — подтверждаем через requests
         (чтобы убедиться что Telegram доступен).
      4) Иначе пробуем HTTP CONNECT raw — отличаем 407/403/no-route.
    """
    parsed = _parse_proxy_parts(addr)
    if not parsed:
        return False, "", "неверный формат (ожидаю ip:port или ip:port:логин:пароль)", ""
    host, port, user, password, given_scheme = parsed

    # Если схема задана явно (socks4/http/…) — сначала её и проверяем
    order_scheme = []
    if given_scheme in ("socks5", "socks5h"):
        order_scheme = ["socks5", "http"]
    elif given_scheme == "socks4":
        order_scheme = ["socks4", "socks5", "http"]
    elif given_scheme in ("http", "https"):
        order_scheme = ["http", "socks5"]
    else:
        order_scheme = ["socks5", "http", "socks4"]

    attempts = []

    for scheme in order_scheme:
        if scheme == "socks5":
            ok, err = _socks5_probe(host, port, user, password, timeout=10)
            if ok:
                url = f"socks5://{user}:{password}@{host}:{port}" if user else f"socks5://{host}:{port}"
                # Подтверждаем что через прокси виден Telegram
                try:
                    r = _req.get("https://api.telegram.org",
                                 proxies={"https": url, "http": url},
                                 timeout=15, verify=False)
                    body = (r.text or "")[:400].lower()
                    if ("error_code" in body) or ("telegram" in body) or r.status_code in (200, 301, 302, 404):
                        return True, "socks5", "", url
                    attempts.append(f"socks5 OK, но Telegram вернул {r.status_code}")
                    # handshake ок, но Telegram недоступен — всё равно считаем живым прокси,
                    # Telethon может работать через него на MTProto-порт
                    return True, "socks5", "", url
                except Exception as e:
                    # handshake прошёл — прокси SOCKS5-совместим. Telegram не доступен
                    # из-за сети/DNS, но для Telethon важен только MTProto.
                    logger.debug("[proxy] %s socks5 handshake ok, TG verify failed: %s", addr[:30], e)
                    return True, "socks5", "", url
            attempts.append(f"socks5: {err}")

        elif scheme == "socks4":
            # socks4 не поддерживает auth — если есть user/pass, пропускаем
            if user or password:
                attempts.append("socks4: не поддерживает логин/пароль")
                continue
            # Быстрая TCP-проверка
            import socket
            try:
                s = socket.create_connection((host, port), timeout=8)
                s.close()
                url = f"socks4://{host}:{port}"
                try:
                    r = _req.get("https://api.telegram.org",
                                 proxies={"https": url, "http": url},
                                 timeout=12, verify=False)
                    if r.status_code < 500:
                        return True, "socks4", "", url
                except Exception:
                    pass
                attempts.append("socks4: TCP ок, но не ответил")
            except Exception as e:
                attempts.append(f"socks4: {type(e).__name__}")

        elif scheme == "http":
            ok, err = _http_connect_probe(host, port, user, password, timeout=10)
            if ok:
                url = f"http://{user}:{password}@{host}:{port}" if user else f"http://{host}:{port}"
                return True, "http", "", url
            attempts.append(f"http: {err}")

    err_text = "; ".join(attempts) if attempts else "не удалось подключиться"
    return False, "", err_text, ""


# ── API: Templates ───────────────────────────────────────────────────────────

_BUILTIN_TEMPLATES = [
    {"id": "w1", "cat": "work", "name": "Работа — простая", "text": "Есть тема поднять денег. Завтра утром. {Пару|Несколько} человек. {Готов|Интересно}?"},
    {"id": "w2", "cat": "work", "name": "Работа — с плюшками", "text": "Есть {тема|дело} на завтра. {Обучаем|Покажем всё}. {Оплата сразу|В тот же день}. {Кто свободен|Готов}?"},
    {"id": "s1", "cat": "sales", "name": "Продажи — товар", "text": "{Привет|Добрый день}! Есть {отличное|интересное} предложение. {Рассказать|Подробнее}?"},
    {"id": "c1", "cat": "crypto", "name": "Крипто — заработок", "text": "{Привет|Хей}! {Нашёл|Есть} способ {заработать|поднять} на крипте. {Расскажу|Покажу} как. {Интересно|Хочешь}?"},
]


_CUSTOM_TPL_FILE = DATA_DIR / "custom_templates.json"
_HIDDEN_TPL_FILE = DATA_DIR / "hidden_templates.json"

def _load_hidden_templates():
    try:
        if _HIDDEN_TPL_FILE.exists():
            return set(json.loads(_HIDDEN_TPL_FILE.read_text(encoding="utf-8")))
    except Exception:
        pass
    return set()

def _save_hidden_templates(ids):
    try:
        _HIDDEN_TPL_FILE.write_text(json.dumps(list(ids), ensure_ascii=False), encoding="utf-8")
    except Exception:
        pass

def _load_custom_templates():
    try:
        if _CUSTOM_TPL_FILE.exists():
            return json.loads(_CUSTOM_TPL_FILE.read_text(encoding="utf-8"))
    except Exception:
        pass
    return []

def _save_custom_templates(tpls):
    try:
        _CUSTOM_TPL_FILE.write_text(json.dumps(tpls, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception as e:
        logger.warning("[templates] save error: %s", e)


@app.route("/api/templates")
@login_required
def api_templates():
    custom = _load_custom_templates()
    hidden = _load_hidden_templates()
    all_tpl = _BUILTIN_TEMPLATES + [dict(t, cat="custom") for t in custom]
    # Фильтруем скрытые
    all_tpl = [t for t in all_tpl if t.get("id") not in hidden]
    cat = request.args.get("cat", "all")
    if cat != "all":
        all_tpl = [t for t in all_tpl if t["cat"] == cat]
    return jsonify({"templates": all_tpl})


@app.route("/api/templates/create", methods=["POST"])
@login_required
def api_templates_create():
    data = request.json or {}
    name = data.get("name", "").strip()
    text = data.get("text", "").strip()
    if not name or not text:
        return jsonify({"error": "введите название и текст"}), 400
    custom = _load_custom_templates()
    tpl_id = f"u{int(time.time())}"
    custom.append({"id": tpl_id, "name": name, "text": text})
    _save_custom_templates(custom)
    return jsonify({"ok": True, "id": tpl_id})


@app.route("/api/templates/delete", methods=["POST"])
@login_required
def api_templates_delete():
    data = request.json or {}
    tpl_id = data.get("id", "")
    if not tpl_id:
        return jsonify({"error": "no id"}), 400
    # Удаляем custom или скрываем builtin
    custom = _load_custom_templates()
    was_custom = any(t.get("id") == tpl_id for t in custom)
    if was_custom:
        custom = [t for t in custom if t.get("id") != tpl_id]
        _save_custom_templates(custom)
    else:
        # Builtin — скрываем
        hidden = _load_hidden_templates()
        hidden.add(tpl_id)
        _save_hidden_templates(hidden)
    return jsonify({"ok": True})


# ── API: Analytics ───────────────────────────────────────────────────────────

def _aggregate_quota_events(days_back: int) -> tuple[list, int, int]:
    """Агрегирует quota_events за последние N дней одним запросом + totals.
    Возвращает (days[], total_joins, total_spam). Заменяет N+1 цикл на 2 SQL запроса."""
    from core.database import store
    import time as _t
    now = int(_t.time())
    start_ts = now - days_back * 86400

    with store._lock:
        # Одним запросом — группируем по дню и виду события
        rows = store.conn.execute("""
            SELECT CAST((? - ts) / 86400 AS INTEGER) AS bucket,
                   kind,
                   COUNT(*) AS cnt
              FROM quota_events
             WHERE ts >= ? AND ts < ?
             GROUP BY bucket, kind
        """, (now, start_ts, now)).fetchall()
        total_joins = store.conn.execute(
            "SELECT COUNT(*) FROM quota_events WHERE kind='join'"
        ).fetchone()[0]
        total_spam = store.conn.execute(
            "SELECT COUNT(*) FROM quota_events WHERE kind IN ('spam','spam_user')"
        ).fetchone()[0]

    by_day: Dict[int, Dict[str, int]] = {i: {"joins": 0, "spam": 0} for i in range(days_back)}
    for bucket, kind, cnt in rows:
        b = int(bucket)
        if b < 0 or b >= days_back:
            continue
        if kind == "join":
            by_day[b]["joins"] += cnt
        elif kind in ("spam", "spam_user"):
            by_day[b]["spam"] += cnt

    days = [{"day": i, "joins": by_day[i]["joins"], "spam": by_day[i]["spam"]}
            for i in range(days_back)]
    days.reverse()
    return days, int(total_joins or 0), int(total_spam or 0)


@app.route("/api/analytics")
@login_required
def api_analytics():
    try:
        days, total_joins, total_spam = _aggregate_quota_events(7)
        return jsonify({
            "ok": True,
            "days": days,
            "totals": {"joins": total_joins, "spam": total_spam},
        })
    except Exception as e:
        logger.warning("[analytics] %s", e)
        return jsonify({"ok": True, "days": [], "totals": {"joins": 0, "spam": 0}})


@app.route("/api/analytics/export")
@login_required
def api_analytics_export():
    """Экспорт аналитики в Excel."""
    try:
        from openpyxl import Workbook
        import io, time as _t
        from datetime import datetime

        wb = Workbook()
        ws = wb.active
        ws.title = "Analytics"
        ws.append(["Date", "Joins", "Spam Sent"])

        days, _, _ = _aggregate_quota_events(30)
        now = int(_t.time())
        # days отсортирован от старых к новым; day index i означает i дней назад
        for d in days:
            # day 0 = сегодня, day 1 = вчера; старт в начале суток
            day_start = now - (d["day"] + 1) * 86400
            day_str = datetime.fromtimestamp(day_start).strftime("%Y-%m-%d")
            ws.append([day_str, d["joins"], d["spam"]])

        buf = io.BytesIO()
        wb.save(buf)
        buf.seek(0)
        from flask import send_file
        return send_file(buf, mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
                        as_attachment=True, download_name="telepush_analytics.xlsx")
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ── API: Warmup (Прогрев) ─────────────────────────────────────────────────────

@app.route("/api/warmup/status")
@login_required
def api_warmup_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    from core.warmup import get_account_age, format_account_age, STAGES, get_day_limits, update_schedule
    from core.warmup_ai import get_ai_stats

    job = getattr(ctx, "warmup_job", None)
    result = {"running": False, "accounts": [], "pairs": [], "log": []}

    # Warmup accounts
    for phone, acc in list(ctx.accounts.items()):
        if getattr(acc, "folder_name", "") in ("\u041f\u0440\u043e\u0433\u0440\u0435\u0432", "\u041e\u0442\u043b\u0451\u0436\u043a\u0430", "Warmup"):
            age = get_account_age(acc)
            geo_code, geo_flag = _detect_geo(phone)
            result["accounts"].append({
                "phone": phone,
                "username": getattr(acc, "username", "") or "",
                "first_name": getattr(acc, "first_name", "") or "",
                "folder": getattr(acc, "folder_name", ""),
                "age_text": format_account_age(acc),
                "age_days": age["days"],
                "age_source": age.get("source", "unknown"),
                "is_premium": getattr(acc, "is_premium", False),
                "has_proxy": bool(acc.proxy),
                "dead": acc.dead,
                "idle": getattr(acc, "idle", False),
                "geo": geo_code,
                "flag": geo_flag,
            })

    if job and job.status in ("running", "paused"):
        result["running"] = True
        result["status"] = job.status
        sched = job.schedule
        update_schedule(sched)
        stage_info = STAGES.get(sched.stage, {})
        result["schedule"] = {
            "current_day": sched.current_day,
            "total_days": sched.total_days,
            "stage": sched.stage,
            "stage_label": stage_info.get("label", ""),
            "stage_icon": stage_info.get("icon", ""),
            "max_peers": sched.max_peers_today,
            "msgs_per_day": sched.msgs_per_day,
            "pct": int(sched.current_day / max(sched.total_days, 1) * 100),
        }
        result["total_messages"] = job.total_messages_sent
        result["messages_today"] = job.messages_today
        result["pairs"] = [
            {
                "phone_a": p.phone_a, "phone_b": p.phone_b,
                "topic": p.topic, "state": p.state,
                "exchanged": p.messages_exchanged,
            }
            for p in job.pairs[:50]
        ]
        result["log"] = job.log_lines[-30:]

    result["ai_stats"] = get_ai_stats()
    return jsonify(result)


@app.route("/api/warmup/start", methods=["POST"])
@login_required
def api_warmup_start():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    existing = getattr(ctx, "warmup_job", None)
    if existing and existing.status in ("running", "paused"):
        return jsonify({"error": "Прогрев уже запущен. Сначала нажмите «Стоп», потом запустите заново."}), 400

    data = request.json or {}
    total_days = int(data.get("total_days", 21))
    start_day = max(1, int(data.get("start_day", 1) or 1))
    if start_day > total_days:
        start_day = total_days
    auto_move = bool(data.get("auto_move", True))
    from models import WarmupJob, WarmupSchedule
    from core.warmup import build_warmup_pairs, run_warmup_job, get_day_limits, save_pairs
    import asyncio

    # Count warmup accounts
    warmup_accs = {
        phone: acc for phone, acc in list(ctx.accounts.items())
        if getattr(acc, "folder_name", "") in ("\u041f\u0440\u043e\u0433\u0440\u0435\u0432", "Warmup")
        and not acc.dead and not acc.frozen
    }
    if len(warmup_accs) < 2:
        return jsonify({"error": f"Нужно минимум 2 аккаунта в папке 'Прогрев', сейчас: {len(warmup_accs)}"}), 400
    # Принудительно ставим warmup-флаг — иначе build_warmup_pairs (фильтр по
    # acc.warmup) видит только подмножество и кидает "got 1", хотя api пропустила
    # старт по folder_name.
    for _acc in warmup_accs.values():
        _acc.warmup = True

    # Override msgs_per_day: 0 = авто, иначе фикс на весь цикл.
    # 2 источника: общий лимит на пул (msgs_per_day_override, controls наверху) или
    # лимит на акк (dm_msgs_per_day_per_acc, в карточке «Переписка между аккаунтами»).
    # Per-acc приоритетнее — юзеру понятнее «N сообщений/день каждым».
    try:
        msgs_override = int(data.get("msgs_per_day_override", 0) or 0)
    except Exception:
        msgs_override = 0
    try:
        per_acc = int(data.get("dm_msgs_per_day_per_acc", 0) or 0)
    except Exception:
        per_acc = 0
    if per_acc > 0:
        msgs_override = per_acc * max(1, len(warmup_accs))
    msgs_override = max(0, min(2000, msgs_override))

    # Build schedule — учитываем выбранный стартовый день.
    # ВАЖНО: started_at сдвигаем на (start_day-1) суток назад, иначе
    # update_schedule_state() (calendar-day diff) перезатрёт current_day на 1.
    # Тот же приём, что и ручной «следующий день» (core/warmup.py:1307).
    peers, msgs, stage = get_day_limits(start_day)
    schedule = WarmupSchedule(
        total_days=total_days,
        current_day=start_day,
        started_at=time.time() - (start_day - 1) * 86400,
        stage=stage,
        max_peers_today=peers,
        msgs_per_day=(msgs_override if msgs_override > 0 else msgs),
        auto_move_when_done=auto_move,
        msgs_per_day_override=msgs_override,
    )
    if start_day > 1:
        logger.info("[warmup] starting from day %d/%d (stage=%s, peers=%d, msgs=%d)",
                    start_day, total_days, stage, peers, msgs)

    # Build pairs
    pairs = build_warmup_pairs(ctx.accounts, max_peers=peers)
    save_pairs(pairs)

    # Create job
    gen_mode = data.get("gen_mode", "auto")
    scenarios_in = data.get("scenarios") or {}
    job = WarmupJob(
        owner_id=0,
        pairs=pairs,
        schedule=schedule,
        config={
            "active_hours_start": int(data.get("active_hours_start", 9)),
            "active_hours_end": int(data.get("active_hours_end", 22)),
            "min_delay": int(data.get("min_delay", 60)),
            "max_delay": int(data.get("max_delay", 300)),
            "burst_size": int(data.get("burst_size", 3)),
            "burst_pause_min": int(data.get("burst_pause_min", 600)),
            "burst_pause_max": int(data.get("burst_pause_max", 1800)),
            # Quiet hours — мёртвые ночные часы (по локальному времени аккаунта,
            # определяется через country-code номера в core.quiet_hours).
            "quiet_hours_enabled": bool(data.get("quiet_hours_enabled", True)),
            "quiet_hours_start": int(data.get("quiet_hours_start", 23)),
            "quiet_hours_end": int(data.get("quiet_hours_end", 7)),
            "gen_mode": gen_mode,
            "scenarios": {
                "dm": bool(scenarios_in.get("dm", True)),
                "channels": bool(scenarios_in.get("channels", False)),
                "reactions": bool(scenarios_in.get("reactions", False)),
                "chats": bool(scenarios_in.get("chats", False)),
                # own_chat / own_channel — модули "свой чат / свой канал" (warmup_own_chat_id /
                # warmup_own_channel_id). Раньше эти ключи терялись здесь — UI отправлял true,
                # но runtime никогда не видел их → warmup ни разу не писал в свой чат/канал.
                "own_chat": bool(scenarios_in.get("own_chat", False)),
                "own_channel": bool(scenarios_in.get("own_channel", False)),
                # diaries — личные каналы-дневники (warmup_diary.py).
                # Когда галка включена и созданы дневники — runtime крутит сценарии
                # diary_post / diary_subscribe / diary_react с дня 4+.
                "diaries": bool(scenarios_in.get("diaries", False)),
            },
            "channels_list": [s.strip().lstrip("@") for s in (data.get("channels_list") or []) if s and s.strip()],
            "chats_list": [s.strip().lstrip("@") for s in (data.get("chats_list") or []) if s and s.strip()],
            "channels_per_day": int(data.get("channels_per_day", 3)),
            "chat_messages_per_day": int(data.get("chat_messages_per_day", 2)),
            "reactions_per_day": int(data.get("reactions_per_day", 5)),
            "reply_to_incoming": bool(data.get("reply_to_incoming", True)),
            "threads": int(data.get("threads", 1)),
            "work_until_midnight": bool(data.get("work_until_midnight", False)),
            "manual_next_day": bool(data.get("manual_next_day", False)),
            "per_account_day": data.get("per_account_day") or {},
            "warmup_mode": str(data.get("warmup_mode", "classic")),
            "hub_phone": str(data.get("hub_phone", "")),
            "id_format": bool(data.get("id_format", False)),
            "duplicate_dm_after_reply": bool(data.get("duplicate_dm_after_reply", True)),
        },
    )
    job.pause_event.set()
    ctx.warmup_job = job

    # Сохраняем снимок запуска, чтобы при рестарте (в т.ч. автообновлении) прогрев
    # поднялся сам — иначе клиент теряет его и не замечает до проверки.
    try:
        from core.warmup_resume import save_snapshot
        save_snapshot(session.get("uid") or 0, data, schedule.current_day)
    except Exception as _rs_err:
        logger.debug("[warmup] save_snapshot failed: %s", _rs_err)

    job.ad_config = _build_ad_dict(ctx, request.json)

    async def _run():
        ad = job.ad_config or {}
        wm_log = logging.getLogger("telepush.warmup")
        warmup_accs_list = [a for a in ctx.accounts.values()
                            if getattr(a, 'folder_name', '') in ("Прогрев", "Warmup")]

        if ad.get("pre_spam_check") and warmup_accs_list:
            wm_log.info("[защита] Проверяю %d акков прогрева на спам-блок...", len(warmup_accs_list))
            ok_list, blocked = await _apply_ad_pre_check(warmup_accs_list, ad, "warmup")
            if blocked:
                wm_log.warning("[защита] %d акков в спам-блоке исключены из прогрева", len(blocked))

        # Online keep-loop + human activity для всех акков прогрева
        online_tasks = []
        online_stop = None
        human_tasks = []
        human_stop = None
        if ad.get("online"):
            wm_log.info("[защита] Держу %d акков прогрева в онлайне", len(warmup_accs_list))
            from utils.anti_detect import keep_online_loop
            online_stop = asyncio.Event()
            for a in warmup_accs_list:
                if getattr(a, 'client', None):
                    online_tasks.append(asyncio.create_task(
                        keep_online_loop(a.client, online_stop, module="warmup")))

        # Human activity loop (read/mark/view) — additive, не блокирует runner
        if any(ad.get(k) for k in ('read_messages', 'mark_read', 'view_profiles', 'view_photos')):
            wm_log.info("[защита] Включена имитация человеческой активности")
            from utils.anti_detect import human_activity_loop
            human_stop = asyncio.Event()
            for a in warmup_accs_list:
                if getattr(a, 'client', None):
                    human_tasks.append(asyncio.create_task(
                        human_activity_loop(a, ad, human_stop, module="warmup")))

        try:
            await run_warmup_job(job, ctx)
        except Exception as e:
            logger.error("[warmup] Job crashed: %s", e)
            job.status = "stopped"
        finally:
            if online_stop:
                online_stop.set()
                for t in online_tasks:
                    try:
                        await asyncio.wait_for(t, timeout=2)
                    except Exception:
                        t.cancel()
            if human_stop:
                human_stop.set()
                for t in human_tasks:
                    try:
                        await asyncio.wait_for(t, timeout=2)
                    except Exception:
                        t.cancel()
            try:
                from core.client_builder import release_after_task
                for _a in list(ctx.accounts.values()):
                    if getattr(_a, "folder_name", "") in ("Прогрев", "Warmup"):
                        try:
                            await release_after_task(_a)
                        except Exception:
                            pass
            except Exception:
                pass

    if any((job.ad_config or {}).values()):
        _flags = ', '.join(k for k,v in (job.ad_config or {}).items() if v)
        logging.getLogger("telepush.warmup").info("[защита] включено: %s", _flags)

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)

    return jsonify({"ok": True, "pairs": len(pairs), "days": total_days})


@app.route("/api/warmup/reset_acc_day", methods=["POST"])
@login_required
def api_warmup_reset_acc_day():
    """Сбрасывает конкретный акк на day 1 прогрева (для новичков в папке)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    phone = (data.get("phone") or "").strip()
    if not phone:
        return jsonify({"error": "Укажите phone"}), 400
    # Храним в user_settings как индивидуальный "сдвиг дня"
    per_acc = ctx.user_settings.setdefault("warmup_per_acc_day", {})
    per_acc[phone] = 1
    _save_user_settings(ctx.user_settings)
    # Если есть активный job — применить немедленно
    job = getattr(ctx, "warmup_job", None)
    if job and job.status == "running":
        job.config.setdefault("per_account_day", {})[phone] = 1
        job.log_lines.append(f"[{datetime.now().strftime('%H:%M:%S')}] ♻ {phone} сброшен на день 1")
    return jsonify({"ok": True, "phone": phone, "day": 1})


@app.route("/api/warmup/next_day", methods=["POST"])
@login_required
def api_warmup_next_day():
    """Ручной запуск следующего дня (если включён manual_next_day)."""
    ctx = _get_ctx()
    if not ctx or not getattr(ctx, "warmup_job", None):
        return jsonify({"error": "Прогрев не запущен"}), 400
    job = ctx.warmup_job
    job.config["next_day_requested"] = True
    return jsonify({"ok": True, "msg": "Следующий день запустится в активные часы"})


@app.route("/api/warmup/stop", methods=["POST"])
@login_required
def api_warmup_stop():
    ctx = _get_ctx()
    if not ctx or not getattr(ctx, "warmup_job", None):
        return jsonify({"error": "Нет активного прогрева"}), 404
    job = ctx.warmup_job
    job.stop_flag = True
    job.status = "stopped"
    job.pause_event.set()  # unblock if paused
    # Очищаем ссылку, чтобы старт с новыми настройками не блокировался
    # (фоновая корутина сама завершится при следующем цикле по stop_flag)
    ctx.warmup_job = None
    try:
        from core.warmup_resume import clear_snapshot
        clear_snapshot()
    except Exception:
        pass
    logger.info("[warmup] stopped and cleared from ctx")
    return jsonify({"ok": True})


@app.route("/api/warmup/pause", methods=["POST"])
@login_required
def api_warmup_pause():
    ctx = _get_ctx()
    if not ctx or not getattr(ctx, "warmup_job", None):
        return jsonify({"error": "Нет активного прогрева"}), 404
    job = ctx.warmup_job
    if job.status == "running":
        job.status = "paused"
        job.pause_event.clear()
    elif job.status == "paused":
        job.status = "running"
        job.pause_event.set()
    return jsonify({"ok": True, "status": job.status})


@app.route("/api/warmup/config", methods=["GET", "POST"])
@login_required
def api_warmup_config():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    s = ctx.user_settings
    if request.method == "GET":
        import json as _j
        scn_raw = s.get("warmup_scenarios_json", "") or ""
        try:
            scn = _j.loads(scn_raw) if scn_raw else {}
        except Exception:
            scn = {}
        return jsonify({
            "gemini_api_key": s.get("gemini_api_key", ""),
            "warmup_gen_mode": s.get("warmup_gen_mode", "auto"),
            "warmup_total_days": int(s.get("warmup_total_days", 21)),
            "warmup_active_start": int(s.get("warmup_active_start", 9)),
            "warmup_active_end": int(s.get("warmup_active_end", 22)),
            "warmup_min_delay": int(s.get("warmup_min_delay", 60)),
            "warmup_max_delay": int(s.get("warmup_max_delay", 300)),
            "warmup_auto_move": bool(s.get("warmup_auto_move", True)),
            "warmup_quiet_hours_enabled": bool(s.get("warmup_quiet_hours_enabled", True)),
            "warmup_quiet_hours_start": int(s.get("warmup_quiet_hours_start", 23)),
            "warmup_quiet_hours_end": int(s.get("warmup_quiet_hours_end", 7)),
            "scenarios": scn,
        })
    data = request.json or {}
    for key in ["gemini_api_key", "warmup_gen_mode", "warmup_scenarios_json"]:
        if key in data:
            s[key] = str(data[key])
    for key in ["warmup_total_days", "warmup_active_start", "warmup_active_end",
                "warmup_min_delay", "warmup_max_delay",
                "warmup_quiet_hours_start", "warmup_quiet_hours_end"]:
        if key in data:
            s[key] = int(data[key])
    if "warmup_auto_move" in data:
        s["warmup_auto_move"] = bool(data["warmup_auto_move"])
    if "warmup_quiet_hours_enabled" in data:
        s["warmup_quiet_hours_enabled"] = bool(data["warmup_quiet_hours_enabled"])
    _save_user_settings(s)
    return jsonify({"ok": True})


@app.route("/api/warmup/defaults")
@login_required
def api_warmup_defaults():
    """Возвращает дефолтные списки популярных каналов/эмодзи для пресета.
    Если есть github_channels_pool.json (юзер подтянул свежие из GitHub) — мержим."""
    from core.warmup_defaults import DEFAULT_CHANNELS, DEFAULT_CHATS, REACTION_EMOJIS
    channels = list(DEFAULT_CHANNELS)
    try:
        from config import DATA_DIR
        import json as _json
        gpool = DATA_DIR / "github_channels_pool.json"
        if gpool.exists():
            extra = _json.loads(gpool.read_text(encoding="utf-8")) or []
            seen = set(c.lower() for c in channels)
            for u in extra:
                if u and u.lower() not in seen:
                    channels.append(u)
                    seen.add(u.lower())
    except Exception:
        pass
    return jsonify({
        "channels": channels,
        "chats": DEFAULT_CHATS,
        "emojis": REACTION_EMOJIS,
    })


# ── GitHub/raw-источник публичных каналов: клиент сам качает (мимо нашего сервера).
# Дефолтных источников нет — юзер вписывает URL вручную (свой gist, github repo,
# pastebin, любой raw-список юзернеймов). Так каждый клиент берёт свой источник
# → у разных клиентов разные пулы каналов → меньше fingerprint бот-сети.


@app.route("/api/warmup/fetch_github_channels", methods=["POST"])
@login_required
def api_warmup_fetch_github_channels():
    """Клиент тянет с указанного URL список каналов и мержит в локальный пул.
    URL передаётся в теле запроса. Если пусто — берётся последний сохранённый
    в user_settings.warmup_github_url. Если и его нет — возвращаем ошибку."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    custom_url = (data.get("url") or "").strip()
    if not custom_url:
        custom_url = (ctx.user_settings.get("warmup_github_url") or "").strip()
    if not custom_url:
        return jsonify({"ok": False, "error": "Укажите URL списка каналов (raw-файл со списком юзернеймов, по одному на строку)."}), 400
    if not custom_url.startswith(("http://", "https://")):
        return jsonify({"ok": False, "error": "URL должен начинаться с http:// или https://"}), 400

    # Запоминаем URL для следующего раза
    ctx.user_settings["warmup_github_url"] = custom_url
    try:
        _save_user_settings(ctx.user_settings)
    except Exception:
        pass

    sources = [custom_url]

    import urllib.request
    import re as _re
    collected: list = []
    errors: list = []
    for url in sources:
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "TelePush/1.0"})
            with urllib.request.urlopen(req, timeout=15) as resp:
                if resp.status != 200:
                    errors.append(f"{url}: HTTP {resp.status}")
                    continue
                raw = resp.read().decode("utf-8", errors="ignore")
        except Exception as e:
            errors.append(f"{url}: {type(e).__name__}: {e}"[:200])
            continue

        for line in raw.splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            # Принимаем форматы: @username, t.me/username, https://t.me/username
            m = _re.search(r"(?:@|t\.me/|/)([a-zA-Z][a-zA-Z0-9_]{3,31})\b", line)
            if m:
                collected.append(m.group(1).lower())
            elif _re.match(r"^[a-zA-Z][a-zA-Z0-9_]{3,31}$", line):
                collected.append(line.lower())

    # Уникализируем
    seen = set()
    unique = []
    for u in collected:
        if u in seen:
            continue
        seen.add(u)
        unique.append(u)

    if not unique:
        return jsonify({
            "ok": False,
            "error": "Ничего не найдено. Источники GitHub не отвечают.",
            "details": errors,
        }), 502

    # Сохраняем в data/github_channels_pool.json — это опциональное расширение пула
    try:
        from config import DATA_DIR
        import json as _json
        path = DATA_DIR / "github_channels_pool.json"
        existing = []
        if path.exists():
            try:
                existing = _json.loads(path.read_text(encoding="utf-8")) or []
            except Exception:
                existing = []
        merged = list(existing)
        for u in unique:
            if u not in merged:
                merged.append(u)
        path.write_text(_json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
        return jsonify({
            "ok": True,
            "fetched": len(unique),
            "total_in_pool": len(merged),
            "errors": errors,
        })
    except Exception as e:
        return jsonify({"ok": False, "error": f"{type(e).__name__}: {e}"}), 500


@app.route("/api/warmup/clear_github_pool", methods=["POST"])
@login_required
def api_warmup_clear_github_pool():
    """Очищает локальный GitHub-пул каналов."""
    try:
        from config import DATA_DIR
        path = DATA_DIR / "github_channels_pool.json"
        if path.exists():
            path.unlink()
        return jsonify({"ok": True})
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/support_info")
def api_support_info():
    """Возвращает ссылку на саппорт. Используется UI для редиректа клиента
    в нужное место при ошибках AI / лицензии / общих вопросах.
    Источник истины — config.SUPPORT_TG_URL (читается из ENV TELEPUSH_SUPPORT_URL
    или из дефолта). НЕ login_required — это публичная инфа."""
    try:
        from config import SUPPORT_TG_URL
        return jsonify({"url": SUPPORT_TG_URL or "https://t.me/robi_telegram"})
    except Exception:
        return jsonify({"url": "https://t.me/robi_telegram"})


@app.route("/api/warmup/github_status")
@login_required
def api_warmup_github_status():
    """Возвращает сохранённый URL и количество каналов в локальном пуле."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"url": "", "pool_size": 0})
    url = (ctx.user_settings.get("warmup_github_url") or "").strip()
    pool_size = 0
    try:
        from config import DATA_DIR
        import json as _json
        path = DATA_DIR / "github_channels_pool.json"
        if path.exists():
            data = _json.loads(path.read_text(encoding="utf-8")) or []
            pool_size = len(data)
    except Exception:
        pass
    return jsonify({"url": url, "pool_size": pool_size})


def _get_warmup_ready_accounts(ctx, max_accs: int = 50):
    """Возвращает список (phone, acc) прогреваемых аккаунтов (живых)."""
    return [
        (phone, acc) for phone, acc in list(ctx.accounts.items())
        if getattr(acc, "folder_name", "") in ("\u041f\u0440\u043e\u0433\u0440\u0435\u0432", "Warmup")
        and not acc.dead and not acc.frozen
    ][:max_accs]


@app.route("/api/warmup/create_own_chat", methods=["POST"])
@login_required
def api_warmup_create_own_chat():
    """Создаёт приватную супергруппу (или несколько подгрупп) с прогреваемыми
    аккаунтами для имитации диалога в «своём чате».

    Параметры запроса:
      title          — имя чата (или пусто → рандом из AI/статичного пула)
      max            — максимум акков (default 30)
      subgroup_size  — размер подгруппы (default 0 = один общий чат для всех).
                       Если задан 4-7 — акки бьются на N чатов по subgroup_size.
                       Это снимает «одинаковый набор участников» — антифрод TG
                       видит несколько маленьких комьюнити вместо одной большой
                       одинаковой сетки.
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    import random as _random
    from core.warmup_defaults import OWN_CHAT_TITLE_TEMPLATES
    from core.ai_generator import pool_chat_names as _pool_chat_names
    _ai_pool = _pool_chat_names()
    _fallback_pool = list(_ai_pool) if _ai_pool else list(OWN_CHAT_TITLE_TEMPLATES)
    user_title = (data.get("title") or "").strip()
    max_accs = int(data.get("max", 30))
    subgroup_size = int(data.get("subgroup_size", 0) or 0)

    warmup_accs = _get_warmup_ready_accounts(ctx, max_accs)
    if len(warmup_accs) < 2:
        return jsonify({"error": f"Нужно минимум 2 аккаунта, есть {len(warmup_accs)}"}), 400

    from core.warmup import create_warmup_group, get_last_group_invite_link
    from core.warmup_groups import split_into_subgroups
    from core.client_builder import ensure_connected

    # Решаем — один чат или несколько подгрупп
    if subgroup_size >= 2 and len(warmup_accs) > subgroup_size:
        subgroups = split_into_subgroups(warmup_accs, subgroup_size)
    else:
        subgroups = [warmup_accs]

    # Каждой подгруппе — уникальное имя из пула (без коллизий между группами)
    used_titles = set()
    pool_copy = list(_fallback_pool)
    _random.shuffle(pool_copy)

    def _pick_title(idx: int) -> str:
        if user_title and idx == 0:
            return user_title
        # Из пула, без повторов
        for t in pool_copy:
            if t not in used_titles:
                used_titles.add(t)
                return t
        # Если пул кончился — варьируем суффиксом
        base = _random.choice(_fallback_pool)
        return f"{base} {idx+1}"

    async def _build_one(group_accs, title):
        ready = []
        for phone, acc in group_accs:
            if await ensure_connected(acc) and acc.client:
                ready.append((acc.client, phone))
        if len(ready) < 2:
            return None, None, f"Подключилось {len(ready)}/{len(group_accs)}", []
        chat_id, access_hash, info = await create_warmup_group(ready, title)
        members = [phone for _cli, phone in ready]
        return chat_id, access_hash, info, members

    async def _do():
        results = []
        for idx, group in enumerate(subgroups):
            title = _pick_title(idx)
            cid, ahash, info, members = await _build_one(group, title)
            link = ""
            try:
                link = get_last_group_invite_link() or ""
            except Exception:
                pass
            results.append({
                "chat_id": cid, "title": title, "invite_link": link,
                "members": members, "info": info,
            })
        return results

    results = _run_async(_do(), timeout=180 * max(1, len(subgroups))) or []
    successful = [r for r in results if r.get("chat_id")]
    if not successful:
        first_err = (results[0].get("info") if results else "не удалось создать")
        return jsonify({"ok": False, "error": first_err}), 500

    # Сохраняем нормализованный список. Для backward-compat также пишем
    # legacy одиночные поля (warmup_own_chat_id и т.п.) из первого чата —
    # старые места кода, которые ещё не используют helpers, продолжат работать.
    ctx.user_settings["warmup_own_chats"] = [
        {"chat_id": r["chat_id"], "title": r["title"],
         "invite_link": r.get("invite_link", ""),
         "members": r.get("members", [])}
        for r in successful
    ]
    first = successful[0]
    ctx.user_settings["warmup_own_chat_id"] = first["chat_id"]
    ctx.user_settings["warmup_own_chat_title"] = first["title"]
    ctx.user_settings["warmup_own_chat_invite_link"] = first.get("invite_link", "")
    _save_user_settings(ctx.user_settings)

    return jsonify({
        "ok": True,
        "groups_created": len(successful),
        "groups": [{"chat_id": r["chat_id"], "title": r["title"], "members_count": len(r.get("members", []))}
                   for r in successful],
        "info": "; ".join(r.get("info", "") for r in successful)[:300],
    })


@app.route("/api/warmup/refresh_own_chat", methods=["POST"])
@login_required
def api_warmup_refresh_own_chat():
    """Догоняет участников по ВСЕМ чатам прогрева (один общий или подгруппы).

    Для каждого чата ищет акк-creator (тот кто уже внутри) и добавляет туда тех
    акков из подгруппы которые ещё не попали. Если у чата задан members-список
    (подгруппы) — догоняем только этих, иначе всех warmup-акков (legacy)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    from core.warmup_groups import normalize_own_chats
    chats = normalize_own_chats(ctx.user_settings or {})
    if not chats:
        return jsonify({"error": "Чат прогрева не создан"}), 400

    warmup_accs = _get_warmup_ready_accounts(ctx, 100)
    if len(warmup_accs) < 2:
        return jsonify({"error": f"Нужно минимум 2 акка, есть {len(warmup_accs)}"}), 400

    from core.warmup import refresh_warmup_group_members
    from core.client_builder import ensure_connected

    async def _do_one(chat_info, all_accs):
        chat_id = int(chat_info["chat_id"])
        invite_link = chat_info.get("invite_link", "") or ""
        members_filter = set(str(m).lstrip("+").strip() for m in (chat_info.get("members") or []))

        # Если у этого чата есть members-фильтр (подгруппы) — берём только этих акков.
        # Иначе (legacy одиночный чат) — берём всех warmup-акков.
        if members_filter:
            target_accs = [(p, a) for p, a in all_accs
                            if str(p).lstrip("+").strip() in members_filter]
        else:
            target_accs = all_accs

        creator = None
        others = []
        for phone, acc in target_accs:
            if not await ensure_connected(acc) or not acc.client:
                continue
            if creator is None:
                try:
                    await acc.client.get_input_entity(chat_id)
                    creator = (acc.client, phone)
                    continue
                except Exception:
                    pass
            others.append((acc.client, phone))

        if creator is None:
            return 0, f"чат {chat_id}: нет акк-создателя (никто не имеет доступа)", invite_link

        joined, info = await refresh_warmup_group_members(creator, others, chat_id, invite_link)
        # Если новая ссылка пришла — захватываем
        new_link = invite_link
        if isinstance(info, str) and "ссылка: " in info:
            try:
                nl = info.split("ссылка: ", 1)[1].strip()
                if nl.startswith("http"):
                    new_link = nl
            except Exception:
                pass
        return joined, info, new_link

    async def _do_all():
        results = []
        for c in chats:
            try:
                joined, info, new_link = await _do_one(c, warmup_accs)
            except Exception as e:
                results.append({"chat_id": c["chat_id"], "joined": 0, "info": f"err: {e}", "invite_link": c.get("invite_link", "")})
                continue
            results.append({"chat_id": c["chat_id"], "joined": joined, "info": info, "invite_link": new_link})
        return results

    results = _run_async(_do_all(), timeout=180 * max(1, len(chats))) or []
    # Сохраняем обновлённые ссылки
    if results:
        new_chats = []
        for orig, res in zip(chats, results):
            new_chats.append({
                "chat_id": orig["chat_id"],
                "title": orig.get("title", ""),
                "invite_link": res.get("invite_link", "") or orig.get("invite_link", ""),
                "members": orig.get("members", []),
            })
        ctx.user_settings["warmup_own_chats"] = new_chats
        # Также обновляем legacy (первый чат) для совместимости
        if new_chats:
            ctx.user_settings["warmup_own_chat_invite_link"] = new_chats[0]["invite_link"]
        _save_user_settings(ctx.user_settings)

    total_joined = sum(int(r.get("joined", 0) or 0) for r in results)
    return jsonify({
        "ok": True,
        "chats_processed": len(results),
        "total_joined": total_joined,
        "details": results,
        "info": "; ".join(str(r.get("info", "")) for r in results)[:400],
    })


@app.route("/api/warmup/create_diaries", methods=["POST"])
@login_required
def api_warmup_create_diaries():
    """Создаёт личные каналы-дневники для всех прогреваемых аккаунтов.

    Каждый акк получает свою тему (балансом — не больше N акков на тему),
    публикует первый пост, сохраняется в user_settings.warmup_diaries.
    """
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    requested_themes = data.get("themes") or ["work", "learning", "travel", "sport", "freelance"]
    max_accs = int(data.get("max", 50))

    warmup_accs = _get_warmup_ready_accounts(ctx, max_accs)
    if not warmup_accs:
        return jsonify({"error": "Нет прогреваемых аккаунтов"}), 400

    from core.warmup_diary import create_diary_channel, pick_theme_for_account, pick_channel_title
    from core.warmup_defaults import DIARY_THEMES
    from core.client_builder import ensure_connected

    # Фильтруем темы: только те что попросил пользователь и которые реально есть
    valid_themes = {t: DIARY_THEMES[t] for t in requested_themes if t in DIARY_THEMES}
    if not valid_themes:
        return jsonify({"error": "Не указано ни одной валидной темы"}), 400

    existing = ctx.user_settings.get("warmup_diaries") or {}
    used_themes = {}
    for d in existing.values():
        t = d.get("theme")
        if t:
            used_themes[t] = used_themes.get(t, 0) + 1

    async def _do_all():
        results = {"created": 0, "skipped": 0, "failed": 0, "details": []}
        for phone, acc in warmup_accs:
            if phone in existing and existing[phone].get("channel_id"):
                results["skipped"] += 1
                continue
            if not await ensure_connected(acc) or not acc.client:
                results["failed"] += 1
                continue
            # Балансируем тему среди допустимых
            avail = [t for t in valid_themes if used_themes.get(t, 0) < max(1, len(warmup_accs) // len(valid_themes) + 1)]
            theme = (avail and __import__("random").choice(avail)) or __import__("random").choice(list(valid_themes.keys()))
            title = pick_channel_title(theme)
            diary = await create_diary_channel(acc.client, phone, theme, title)
            if diary:
                existing[phone] = diary
                used_themes[theme] = used_themes.get(theme, 0) + 1
                results["created"] += 1
                results["details"].append({"phone": phone, "theme": theme, "title": title, "link": diary.get("invite_link", "")})
            else:
                results["failed"] += 1
            await asyncio.sleep(__import__("random").uniform(3, 8))
        return results

    res = _run_async(_do_all(), timeout=600) or {"created": 0, "failed": 0, "skipped": 0, "details": []}
    if res.get("created"):
        ctx.user_settings["warmup_diaries"] = existing
        _save_user_settings(ctx.user_settings)
    return jsonify({"ok": True, **res})


@app.route("/api/warmup/diaries_status")
@login_required
def api_warmup_diaries_status():
    """Возвращает текущее состояние дневников: сколько создано, темы, активность."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"diaries": []})
    diaries = ctx.user_settings.get("warmup_diaries") or {}
    items = []
    for phone, d in diaries.items():
        items.append({
            "phone": phone,
            "theme": d.get("theme", ""),
            "title": d.get("title", ""),
            "channel_id": d.get("channel_id", 0),
            "invite_link": d.get("invite_link", ""),
            "post_idx": d.get("post_idx", 0),
            "subscribed_count": len(d.get("subscribed_to") or []),
        })
    return jsonify({"diaries": items, "total": len(items)})


@app.route("/api/warmup/diaries_clear", methods=["POST"])
@login_required
def api_warmup_diaries_clear():
    """Стирает запись о дневниках в настройках (но не удаляет каналы в Телеграме —
    их потом можно вручную удалить через клиент или просто не использовать)."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    ctx.user_settings.pop("warmup_diaries", None)
    _save_user_settings(ctx.user_settings)
    return jsonify({"ok": True})


@app.route("/api/warmup/create_own_channel", methods=["POST"])
@login_required
def api_warmup_create_own_channel():
    """Создаёт приватный канал, даёт всем аккаунтам права админов для публикаций."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    title = (data.get("title") or "").strip() or "Канал прогрева"
    max_accs = int(data.get("max", 30))

    warmup_accs = _get_warmup_ready_accounts(ctx, max_accs)
    if len(warmup_accs) < 2:
        return jsonify({"error": f"Нужно минимум 2 аккаунта, есть {len(warmup_accs)}"}), 400

    from core.warmup import create_warmup_channel
    from core.client_builder import ensure_connected

    async def _do():
        ready = []
        for phone, acc in warmup_accs:
            if await ensure_connected(acc) and acc.client:
                ready.append((acc.client, phone))
        if len(ready) < 2:
            return None, None, f"Подключилось {len(ready)}/{len(warmup_accs)} аккаунтов"
        return await create_warmup_channel(ready, title)

    chan_id, access_hash, info = _run_async(_do(), timeout=180) or (None, None, "timeout")
    if chan_id:
        ctx.user_settings["warmup_own_channel_id"] = chan_id
        ctx.user_settings["warmup_own_channel_title"] = title
        _save_user_settings(ctx.user_settings)
        return jsonify({"ok": True, "channel_id": chan_id, "title": title, "info": info})
    return jsonify({"ok": False, "error": info or "Не удалось создать"}), 500


@app.route("/api/warmup/own_entities")
@login_required
def api_warmup_own_entities():
    """Возвращает созданные свой чат(ы)/канал."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    s = ctx.user_settings or {}
    from core.warmup_groups import normalize_own_chats
    chats = normalize_own_chats(s)
    return jsonify({
        # Legacy одиночный — для старого UI и совместимости
        "own_chat_id": s.get("warmup_own_chat_id", 0),
        "own_chat_title": s.get("warmup_own_chat_title", ""),
        # Подгруппы — массив. UI может показать все.
        "own_chats": [
            {"chat_id": c["chat_id"], "title": c.get("title", ""),
             "invite_link": c.get("invite_link", ""),
             "members_count": len(c.get("members") or [])}
            for c in chats
        ],
        "own_chats_count": len(chats),
        "own_channel_id": s.get("warmup_own_channel_id", 0),
        "own_channel_title": s.get("warmup_own_channel_title", ""),
    })


@app.route("/api/warmup/check_key", methods=["POST"])
@login_required
def api_warmup_check_key():
    data = request.json or {}
    api_key = data.get("api_key", "")
    import asyncio
    from core.warmup_ai import check_gemini_key
    loop = asyncio.new_event_loop()
    result = loop.run_until_complete(check_gemini_key(api_key))
    loop.close()
    return jsonify(result)


@app.route("/api/warmup/blacklist", methods=["GET", "POST"])
@login_required
def api_warmup_blacklist():
    from config import DATA_DIR
    bl_path = DATA_DIR / "\u0442\u0440\u0438\u0433\u0435\u0440\u0430.txt"
    if request.method == "GET":
        text = ""
        try:
            if bl_path.exists():
                text = bl_path.read_text(encoding="utf-8")
        except Exception:
            pass
        # If file doesn't exist, create with default blacklist
        if not text:
            from core.warmup import _BUILTIN_BLACKLIST
            text = "\n".join(_BUILTIN_BLACKLIST)
            try:
                bl_path.parent.mkdir(parents=True, exist_ok=True)
                bl_path.write_text(text, encoding="utf-8")
            except Exception:
                pass
        return jsonify({"text": text})
    data = request.json or {}
    text = data.get("text", "")
    try:
        bl_path.write_text(text, encoding="utf-8")
    except Exception as e:
        return jsonify({"error": str(e)}), 500
    return jsonify({"ok": True})


@app.route("/api/warmup/log")
@login_required
def api_warmup_log():
    ctx = _get_ctx()
    if not ctx or not getattr(ctx, "warmup_job", None):
        return jsonify({"log": []})
    return jsonify({"log": ctx.warmup_job.log_lines[-50:]})


# ── Reauthentication API ──────────────────────────────────────────────────────

def _get_reauth_job(ctx):
    if not hasattr(ctx, "reauth_job") or ctx.reauth_job is None:
        from core.reauth import ReauthJob
        ctx.reauth_job = ReauthJob()
    return ctx.reauth_job


@app.route("/api/reauth/presets")
@login_required
def api_reauth_presets():
    """Возвращает список пресетов + текущий custom."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"presets": []})
    from core.reauth_presets import PRESET_LIST
    custom = (ctx.user_settings or {}).get("reauth_custom_preset") or {}
    return jsonify({"presets": PRESET_LIST, "custom": custom})


@app.route("/api/reauth/custom_preset", methods=["GET", "POST"])
@login_required
def api_reauth_custom_preset():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    if request.method == "GET":
        return jsonify({"custom": (ctx.user_settings or {}).get("reauth_custom_preset") or {}})
    data = request.json or {}
    preset = {
        "api_id": int(data.get("api_id", 0) or 0),
        "api_hash": str(data.get("api_hash", "")),
        "device_model": str(data.get("device_model", "")),
        "system_version": str(data.get("system_version", "")),
        "app_version": str(data.get("app_version", "")),
        "lang_code": str(data.get("lang_code", "en")),
        "system_lang_code": str(data.get("system_lang_code", "en-US")),
    }
    if not preset["api_id"] or not preset["api_hash"]:
        return jsonify({"error": "api_id и api_hash обязательны"}), 400
    ctx.user_settings["reauth_custom_preset"] = preset
    _save_user_settings(ctx.user_settings)
    return jsonify({"ok": True})


@app.route("/api/reauth/start", methods=["POST"])
@login_required
def api_reauth_start():
    """Запуск переавторизации выбранных аккаунтов."""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500

    job = _get_reauth_job(ctx)
    if job.status == "running":
        return jsonify({"error": "Переавторизация уже запущена"}), 400

    data = request.json or {}
    phones = data.get("phones") or []
    preset = data.get("preset", "tdesktop")
    custom = (ctx.user_settings or {}).get("reauth_custom_preset") if preset == "custom" else None
    new_2fa = (data.get("new_2fa") or "").strip()
    close_others = bool(data.get("close_others", True))
    delete_dialog = bool(data.get("delete_dialog", True))
    continue_on_limit = bool(data.get("continue_on_limit", False))
    keep_original = bool(data.get("keep_original", True))

    if not phones:
        return jsonify({"error": "Не выбраны аккаунты"}), 400
    if preset == "custom" and not custom:
        return jsonify({"error": "Свои параметры не заданы"}), 400

    # Собираем список session путей
    jobs = []
    for phone in phones:
        acc = ctx.accounts.get(phone)
        if not acc or not acc.session_path or not acc.session_path.exists():
            continue
        existing_2fa = None
        if acc.json_path and acc.json_path.exists():
            try:
                import json as _j
                meta = _j.loads(acc.json_path.read_text(encoding="utf-8"))
                existing_2fa = meta.get("twoFA") or None
            except Exception:
                pass
        jobs.append((phone, acc.session_path, existing_2fa))

    if not jobs:
        return jsonify({"error": "Нет валидных сессий"}), 400

    job.kind = "reauth"
    job.status = "running"
    job.total = len(jobs)
    job.done = 0
    job.ok = 0
    job.fail = 0
    job.stop_flag = False
    job.started_at = time.time()
    job.log_lines = []
    job.log(f"Старт переавторизации: {len(jobs)} акк., пресет={preset}, keep_original={keep_original}")

    async def _run():
        from core.reauth import reauthenticate_account
        from core.client_builder import load_proxy

        for phone, sess_path, existing_2fa in jobs:
            if job.stop_flag:
                job.log("⏹ Остановлено пользователем")
                break
            job.log(f"→ {phone}")
            try:
                proxy = load_proxy(phone)
                proxy_tuple = proxy.pysocks_tuple if proxy else None
            except Exception:
                proxy_tuple = None

            try:
                success, msg = await reauthenticate_account(
                    old_session_path=sess_path,
                    phone=phone,
                    preset=preset,
                    custom_params=custom,
                    new_2fa=new_2fa,
                    close_others=close_others,
                    delete_dialog=delete_dialog,
                    continue_on_limit=continue_on_limit,
                    keep_original=keep_original,
                    proxy_tuple=proxy_tuple,
                    existing_2fa=existing_2fa,
                    log_fn=job.log,
                )
                if success:
                    job.ok += 1
                    job.log(f"✅ {phone}: {msg}")
                else:
                    job.fail += 1
                    job.log(f"❌ {phone}: {msg}")
            except Exception as e:
                job.fail += 1
                job.log(f"❌ {phone}: CRASH {e}")
            job.done += 1

        job.status = "done"
        job.finished_at = time.time()
        job.log(f"Готово. OK={job.ok}, FAIL={job.fail}")

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    return jsonify({"ok": True, "total": len(jobs)})


@app.route("/api/reauth/status")
@login_required
def api_reauth_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = _get_reauth_job(ctx)
    return jsonify(job.to_dict())


@app.route("/api/reauth/stop", methods=["POST"])
@login_required
def api_reauth_stop():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = _get_reauth_job(ctx)
    job.stop_flag = True
    return jsonify({"ok": True})


@app.route("/api/reauth/tdata_to_session", methods=["POST"])
@login_required
def api_reauth_tdata_to_session():
    """Конвертер tdata → session. body: {folder, depth, move_tdata, password_filename}"""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = _get_reauth_job(ctx)
    if job.status == "running":
        return jsonify({"error": "Есть активная задача"}), 400

    data = request.json or {}
    folder = (data.get("folder") or "").strip()
    if not folder:
        # Дефолт — папка Аккаунты/Конвертер tdata → session
        from core.reauth import _tdata_in_dir
        folder_path = _tdata_in_dir()
    else:
        folder_path = Path(folder)
        if not folder_path.exists():
            return jsonify({"error": f"Папка не найдена: {folder}"}), 400

    depth = int(data.get("depth", 4) or 4)
    # "Не убирать сконвертированные" → move_tdata=False
    keep_originals = bool(data.get("keep_originals", False))
    move_tdata = not keep_originals
    password_filename = (data.get("password_filename") or "Password2FA.txt").strip()
    threads = int(data.get("threads", 1) or 1)

    job.kind = "t2s"
    job.status = "running"
    job.total = 0
    job.done = 0
    job.ok = 0
    job.fail = 0
    job.stop_flag = False
    job.started_at = time.time()
    job.log_lines = []
    job.log(f"tdata→session из {folder} (depth={depth})")

    async def _run():
        from core.reauth import convert_tdata_to_session
        try:
            ok, fail, errors = await convert_tdata_to_session(
                folder_path, move_tdata=move_tdata,
                password_filename=password_filename, depth=depth,
                threads=threads,
                log_fn=job.log,
            )
            job.ok = ok
            job.fail = fail
            job.total = ok + fail
            job.done = ok + fail
            job.log(f"Готово. OK={ok}, FAIL={fail}")
        except Exception as e:
            job.log(f"❌ {e}")
            job.fail += 1
        job.status = "done"
        job.finished_at = time.time()

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    return jsonify({"ok": True})


@app.route("/api/reauth/session_to_tdata", methods=["POST"])
@login_required
def api_reauth_session_to_tdata():
    """Конвертер session → tdata. body: {phones[], pack_count, offline, save_2fa, copy_original}"""
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    job = _get_reauth_job(ctx)
    if job.status == "running":
        return jsonify({"error": "Есть активная задача"}), 400

    data = request.json or {}
    phones = data.get("phones") or []
    pack_count = int(data.get("pack_count", 1) or 1)
    offline = bool(data.get("offline", False))
    save_2fa = bool(data.get("save_2fa", True))
    copy_original = bool(data.get("copy_original", True))
    threads = int(data.get("threads", 1) or 1)

    sessions = []
    for phone in phones:
        acc = ctx.accounts.get(phone)
        if not acc or not acc.session_path or not acc.session_path.exists():
            continue
        meta = {}
        if acc.json_path and acc.json_path.exists():
            try:
                import json as _j
                meta = _j.loads(acc.json_path.read_text(encoding="utf-8"))
            except Exception:
                pass
        sessions.append((acc.session_path, meta))

    if not sessions:
        return jsonify({"error": "Нет валидных сессий"}), 400

    job.kind = "s2t"
    job.status = "running"
    job.total = len(sessions)
    job.done = 0
    job.ok = 0
    job.fail = 0
    job.stop_flag = False
    job.started_at = time.time()
    job.log_lines = []
    job.log(f"▶ Конвертация session → tdata: {len(sessions)} аккаунт(ов)")

    async def _run():
        from core.reauth import convert_session_to_tdata
        from core.client_builder import safe_disconnect
        # Перед конвертацией отключаем все Telethon-клиенты этих аккаунтов, которые
        # сейчас активны в TelePush. Иначе .session файл залочен SQLite от основного
        # клиента, и opentele не может его открыть → висит на connect().
        _disconnected = 0
        for phone in phones:
            _acc = ctx.accounts.get(phone)
            if _acc and getattr(_acc, "client", None):
                try:
                    await safe_disconnect(_acc.client)
                    _acc.client = None
                    _acc.idle = True
                    _disconnected += 1
                except Exception:
                    pass
        if _disconnected:
            job.log(f"Отключено {_disconnected} активных клиентов перед конвертацией (освобождаем .session файлы)")
            await asyncio.sleep(0.5)  # даём SQLite на освобождение блокировок

        try:
            ok, fail, errors = await convert_session_to_tdata(
                sessions, pack_count=pack_count, offline=offline,
                save_2fa=save_2fa, copy_original=copy_original,
                threads=threads,
                log_fn=job.log,
            )
            job.ok = ok
            job.fail = fail
            job.done = job.total
            job.log(f"Готово. OK={ok}, FAIL={fail}")
        except Exception as e:
            job.log(f"❌ {e}")
            job.fail += 1
        job.status = "done"
        job.finished_at = time.time()

    st = _get_state()
    loop = getattr(st, "event_loop", None)
    if loop and not loop.is_closed():
        asyncio.run_coroutine_threadsafe(_run(), loop)
    return jsonify({"ok": True})


# ── Settings persistence ──────────────────────────────────────────────────────

def _settings_file() -> Path:
    """ОСНОВНОЕ хранилище — домашняя папка юзера. Лежит ВНЕ DATA_DIR,
    поэтому переживает любую смену структуры установки, перемещение папки,
    переустановку с нуля и т.д. До v1.4.43 хранилось в DATA_DIR — на старте
    автоматически мигрируется сюда (см. main.py)."""
    return Path.home() / ".telepush" / "user_settings.json"

def _legacy_settings_file() -> Path:
    """Старое место — для миграции и обратной совместимости."""
    from config import DATA_DIR
    return DATA_DIR / "user_settings.json"

def _stable_backup_file() -> Path:
    """Для совместимости со старым кодом — теперь совпадает с основным путём."""
    return _settings_file()

def _clear_readonly(path: Path):
    """Снимает атрибут read-only — иначе write_text может упасть с PermissionError."""
    try:
        import stat
        if path.exists():
            path.chmod(path.stat().st_mode | stat.S_IWRITE | stat.S_IWUSR)
    except Exception:
        pass


def _save_user_settings(settings: dict, force: bool = False) -> tuple:
    """Сохраняет user_settings на диск + зеркальный бэкап.
    Возвращает (ok: bool, error: str|None) — чтобы вызывающий мог сообщить клиенту.
    Атомарная запись через .tmp + replace, со снятием read-only и fallback на прямую запись.

    Защита: если основной файл на диске СУЩЕСТВУЕТ и содержит больше ключей чем мы хотим
    записать — это значит что ctx в памяти не загрузил весь файл, а save затрёт существующий.
    В этом случае СНАЧАЛА смержим существующий файл с новым (существующие значения приоритет
    только если отсутствуют в новом — а то что юзер поменял, остаётся).
    force=True — обойти защиту (для явного restore_from_backup)."""
    import json
    p = _settings_file()
    try:
        p.parent.mkdir(parents=True, exist_ok=True)

        # Защита от перезаписи полным файлом частичными данными (race при падении load)
        if not force and p.exists():
            try:
                existing = json.loads(p.read_text(encoding="utf-8"))
                if isinstance(existing, dict) and len(existing) > len(settings):
                    # В файле больше ключей — похоже мы его не полностью прочитали при старте.
                    # Мержим: берём всё из файла, сверху накатываем settings (юзер-изменения).
                    merged = dict(existing)
                    merged.update(settings)
                    if len(merged) > len(settings):
                        logger.warning("[settings] merge защитил от потери: файл=%d, в памяти=%d, итог=%d",
                                       len(existing), len(settings), len(merged))
                    settings = merged
            except Exception as me:
                logger.debug("[settings] merge check skipped: %s", me)

        raw = json.dumps(settings, ensure_ascii=False, indent=2)
        raw_bytes = raw.encode("utf-8")

        # Снять readonly с существующих файлов (чтоб replace мог их затереть)
        _clear_readonly(p)
        _clear_readonly(p.parent / "user_settings.backup.json")

        # Атомарная запись: tmp → replace. ВАЖНО: write_bytes (не write_text),
        # иначе Windows конвертит \n → \r\n и размер файла не совпадёт с raw.
        tmp = p.with_suffix(".json.tmp")
        try:
            tmp.write_bytes(raw_bytes)
            os.replace(str(tmp), str(p))
        except Exception as atomic_err:
            logger.warning("[settings] atomic write failed (%s), trying direct write", atomic_err)
            try:
                tmp.unlink(missing_ok=True)
            except Exception:
                pass
            p.write_bytes(raw_bytes)

        actual_size = p.stat().st_size
        expected_size = len(raw_bytes)
        if actual_size != expected_size:
            raise IOError(f"файл записан не полностью: {actual_size} вместо {expected_size}")

        # Зеркальный бэкап (после успеха основного)
        try:
            bak = p.parent / "user_settings.backup.json"
            bak_tmp = p.parent / "user_settings.backup.json.tmp"
            bak_tmp.write_bytes(raw_bytes)
            os.replace(str(bak_tmp), str(bak))
        except Exception as be:
            logger.warning("[settings] backup write failed: %s", be)

        # Ротируемые бэкапы с датой в имени — последние 10 штук.
        # Защита от ситуации когда юзер потерял настройки после обновления:
        # всегда можно вернуть из последнего бэкапа.
        try:
            _hist = p.parent / "history"
            _hist.mkdir(parents=True, exist_ok=True)
            stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
            (_hist / f"user_settings.{stamp}.json").write_bytes(raw_bytes)
            # Чистим старые — оставляем только последние 10
            _old = sorted(_hist.glob("user_settings.*.json"))
            for _f in _old[:-10]:
                try: _f.unlink()
                except Exception: pass
        except Exception as he:
            logger.debug("[settings] history backup failed: %s", he)

        # Дублирующий бэкап в легаси-локации (DATA_DIR) — для совместимости
        # с возможными старыми инсталляциями и чтобы на откат версии тоже работало.
        try:
            legacy = _legacy_settings_file()
            legacy.parent.mkdir(parents=True, exist_ok=True)
            _clear_readonly(legacy)
            lg_tmp = legacy.with_suffix(".json.tmp")
            lg_tmp.write_bytes(raw_bytes)
            os.replace(str(lg_tmp), str(legacy))
        except Exception as le:
            logger.debug("[settings] legacy mirror write failed: %s", le)

        # debug — не info: иначе в консоли UI видны десятки одинаковых записей при
        # фоновых сохранениях (схема, monitor, прогрев). Юзеру это шум; для аудита
        # есть отдельный бекап в history/ внутри папки настроек.
        logger.debug("[settings] saved: %d ключей, %d байт → %s", len(settings), expected_size, p)
        return True, None
    except Exception as e:
        logger.error("[settings] save FAILED: %s (path=%s)", e, p)
        return False, str(e)

def _load_user_settings() -> dict:
    """Загружает user_settings. Порядок поиска:
    1. ~/.telepush/user_settings.json (основное хранилище, v1.4.43+)
    2. ~/.telepush/user_settings.backup.json (бэкап в той же папке)
    3. DATA_DIR/user_settings.json (легаси, до v1.4.43) — автомиграция вверх
    4. DATA_DIR/user_settings.backup.json (легаси-бэкап)
    """
    import json
    from config import DATA_DIR

    p = _settings_file()              # ~/.telepush/user_settings.json
    legacy = _legacy_settings_file()  # DATA_DIR/user_settings.json

    # 1) Основной новый файл
    try:
        if p.exists():
            data = json.loads(p.read_text(encoding="utf-8"))
            if data:
                # debug — техническая инфа, русский UX-лог рядом будет
                # явно «Настройки загружены — тайминги ...»
                logger.debug("[settings] loaded from %s (%d keys)", p, len(data))
                return data
            logger.warning("[settings] primary file is empty, trying fallbacks")
    except Exception as e:
        logger.warning("[settings] primary load failed (%s), trying fallbacks", e)

    # Собираем историю бэкапов (user_settings.YYYY-MM-DD_HH-MM-SS.json)
    _history_files = []
    try:
        _hist_dir = p.parent / "history"
        if _hist_dir.exists():
            _history_files = sorted(
                _hist_dir.glob("user_settings.*.json"),
                key=lambda x: x.stat().st_mtime,
                reverse=True,
            )
    except Exception:
        pass

    # 2-N) Порядок фолбэков: бэкап рядом → история (самые свежие) → легаси → старые пути
    fallbacks = [
        p.parent / "user_settings.backup.json",
    ] + list(_history_files) + [
        legacy,
        legacy.parent / "user_settings.backup.json",
        DATA_DIR.parent / "data" / "user_settings.json",
        DATA_DIR.parent.parent / "data" / "user_settings.json",
    ]
    for alt in fallbacks:
        try:
            if alt.exists() and alt != p:
                raw = alt.read_text(encoding="utf-8")
                data = json.loads(raw)
                if data:
                    logger.warning("[settings] MIGRATED from %s → %s (%d keys)", alt, p, len(data))
                    try:
                        p.parent.mkdir(parents=True, exist_ok=True)
                        p.write_bytes(alt.read_bytes())
                    except Exception as we:
                        logger.warning("[settings] migrate write failed: %s", we)
                    return data
        except Exception as e:
            logger.debug("[settings] fallback probe %s: %s", alt, e)

    logger.info("[settings] нет настроек ни в одном фолбэке — будут дефолты")
    return {}


# ── API: Settings ────────────────────────────────────────────────────────────

@app.route("/api/settings/debug")
@login_required
def api_settings_debug():
    """Диагностика: откуда реально читаются настройки. Помогает ловить случаи потери."""
    from config import DATA_DIR
    p = _settings_file()
    bak = p.parent / "user_settings.backup.json"
    info = {
        "data_dir": str(DATA_DIR),
        "settings_path": str(p),
        "settings_exists": p.exists(),
        "backup_path": str(bak),
        "backup_exists": bak.exists(),
        "sys_executable": sys.executable,
        "env_data_dir": os.environ.get("TELEPUSH_DATA_DIR", ""),
        "cwd": os.getcwd(),
    }
    if p.exists():
        try:
            info["settings_size"] = p.stat().st_size
            info["settings_mtime"] = datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
            data = json.loads(p.read_text(encoding="utf-8"))
            info["settings_keys"] = sorted(list(data.keys()))
            info["spam_min_delay"] = data.get("spam_min_delay", "(нет в файле)")
            info["spam_max_delay"] = data.get("spam_max_delay", "(нет в файле)")
        except Exception as e:
            info["settings_error"] = str(e)
    if bak.exists():
        try:
            info["backup_size"] = bak.stat().st_size
            info["backup_mtime"] = datetime.fromtimestamp(bak.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
            bdata = json.loads(bak.read_text(encoding="utf-8"))
            info["backup_keys"] = len(bdata)
            info["backup_spam_min"] = bdata.get("spam_min_delay", "(нет в бэкапе)")
        except Exception as e:
            info["backup_error"] = str(e)

    # Проверка read-only на файлах
    if p.exists():
        try:
            import stat as _stat
            mode = p.stat().st_mode
            info["settings_readonly"] = not bool(mode & _stat.S_IWRITE)
        except Exception:
            pass

    # Обнаружение дубликатов процесса — частая причина "настройки не сохраняются"
    try:
        import psutil
        my_exe = (sys.executable or "").lower()
        running = []
        for proc in psutil.process_iter(["pid", "name", "exe"]):
            try:
                ename = (proc.info.get("name") or "").lower()
                eexe = (proc.info.get("exe") or "").lower()
                if "telepush" in ename or ("telepush" in eexe):
                    running.append({"pid": proc.info["pid"], "exe": proc.info.get("exe", "")})
            except Exception:
                pass
        info["telepush_processes"] = running
        info["telepush_processes_count"] = len(running)
    except Exception as e:
        info["process_check_error"] = str(e)

    return jsonify(info)


@app.route("/api/settings/open_folder", methods=["POST"])
@login_required
def api_settings_open_folder():
    """Открывает папку с user_settings.json в Проводнике (Windows)."""
    from config import DATA_DIR
    p = _settings_file()
    folder = p.parent
    try:
        folder.mkdir(parents=True, exist_ok=True)
        if sys.platform == "win32":
            # Открыть папку и подсветить user_settings.json если он есть
            if p.exists():
                os.startfile(str(folder))  # откроется folder; можно /select, но startfile проще
            else:
                os.startfile(str(folder))
        else:
            import subprocess
            subprocess.Popen(["xdg-open", str(folder)])
        return jsonify({"ok": True, "path": str(folder)})
    except Exception as e:
        logger.warning("open_folder failed: %s", e)
        return jsonify({"ok": False, "error": str(e), "path": str(folder)}), 500


@app.route("/api/settings/restore_from_backup", methods=["POST"])
@login_required
def api_settings_restore():
    """Восстановить user_settings.json из backup'а — на случай если основной файл сброшен."""
    p = _settings_file()
    bak = p.parent / "user_settings.backup.json"
    if not bak.exists():
        return jsonify({"ok": False, "error": "Бэкап не найден"}), 404
    try:
        raw = bak.read_text(encoding="utf-8")
        data = json.loads(raw)
        if not data:
            return jsonify({"ok": False, "error": "Бэкап пустой"}), 400
        p.write_text(raw, encoding="utf-8")
        # Обновляем ctx.user_settings в памяти
        ctx = _get_ctx()
        if ctx:
            ctx.user_settings.update(data)
        logger.info("[settings] manually restored from backup: %d keys", len(data))
        return jsonify({"ok": True, "keys": len(data)})
    except Exception as e:
        logger.warning("[settings] manual restore failed: %s", e)
        return jsonify({"ok": False, "error": str(e)}), 500


@app.route("/api/settings")
@login_required
def api_settings():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({})
    s = ctx.user_settings
    return jsonify({
        "join": {
            "interval": s.get("join_min_interval", 18),
            "jitter": s.get("join_jitter", 3),
            "max_per_session": s.get("join_max_per_session", 10),
            "break_min": s.get("join_session_break_min", 180),
            "break_max": s.get("join_session_break_max", 360),
        },
        "spam": {
            "min_delay": s.get("spam_min_delay", 6),
            "max_delay": s.get("spam_max_delay", 14),
            "burst_size": s.get("spam_burst_size", 7),
            "burst_pause_min": s.get("spam_burst_pause_min", 120),
            "burst_pause_max": s.get("spam_burst_pause_max", 240),
        },
        "dm": {
            "min_delay": s.get("dm_min_delay", 35),
            "max_delay": s.get("dm_max_delay", 90),
            "burst_size": s.get("dm_burst_size", 3),
            "burst_pause_min": s.get("dm_burst_pause_min", 300),
            "burst_pause_max": s.get("dm_burst_pause_max", 600),
        },
        "api_id": int(s.get("api_id", 2040)),
        "api_hash": s.get("api_hash", "b18441a1ff607e10a989891a5462e627"),
        "custom_filters": s.get("custom_filters", ""),
        "share_parsed_with_server": bool(s.get("share_parsed_with_server", True)),
    })


@app.route("/api/settings", methods=["POST"])
@login_required
def api_settings_update():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"error": "no context"}), 500
    data = request.json or {}
    # folder_* ключи удалены из UI (v1.4.80) — эти input'ы никогда не читались
    # в коде, всегда жёстко использовались дефолтные имена (Аккаунты/В работе/ и т.д.).
    # Если от старого UI или внешнего POST придут — игнорируем, чтобы не засорять настройки.
    _ignored_keys = {
        "folder_working", "folder_spam", "folder_permaspam", "folder_frozen", "folder_dead",
        # no_proxy_web оставляем в ignored — опасная опция которую убрали из UI
        "no_proxy_web",
    }
    str_keys = {"api_hash", "json_device", "auto_clean_dead", "custom_filters"}
    changed_keys = []
    for key, val in data.items():
        if key in _ignored_keys:
            continue  # мёртвые поля из старого UI — не сохраняем
        if val is not None:
            if key in str_keys:
                ctx.user_settings[key] = str(val)
            else:
                try:
                    ctx.user_settings[key] = float(val)
                except (ValueError, TypeError):
                    ctx.user_settings[key] = val
            changed_keys.append(key)
    # Persist to disk — если запись упала, клиент узнает ошибку вместо молчаливого OK
    saved_path = _settings_file()
    # Защита: если начальная загрузка настроек упала — save заблокирован чтобы не затереть файл
    if not ctx.temp.get("_settings_loaded_ok", True):
        logger.error("[settings] SAVE BLOCKED: начальная загрузка упала, не перетираем файл дефолтами")
        return jsonify({
            "ok": False,
            "error": "Настройки не были загружены при старте (ошибка чтения файла). Перезапустите TelePush, чтобы избежать потери данных.",
        }), 409
    ok, err = _save_user_settings(ctx.user_settings)
    if not ok:
        return jsonify({"ok": False, "error": f"Не удалось сохранить файл: {err}", "path": str(saved_path)}), 500
    return jsonify({"ok": True, "saved_to": str(saved_path), "keys": len(changed_keys)})


# ── Web server start ─────────────────────────────────────────────────────────

def start_web(port=None):
    """Запускает Flask в daemon-потоке."""
    import threading
    port = port or int(os.environ.get("WEB_PORT", "8080"))
    # Запоминаем порт в state — нужен для api_tg_open_window чтобы открыть
    # tg-web в отдельном pywebview-окне на правильном localhost-URL.
    try:
        st = _get_state()
        if st is not None:
            st.web_port = port
    except Exception:
        pass

    def _run():
        # Пробуем waitress (production-grade WSGI с пред-настроенным thread pool
        # На waitress 3.x под Windows стабильно ловится известный bug:
        # WinError 10038 «not a socket» в waitress.trigger.pull_trigger при
        # cross-thread wake. Видно в production-логах x85 на 1.5.23.
        # Откат на werkzeug + threaded=True. У werkzeug бывал MemoryError на
        # старых 1.5.2 (был ровно один клиент), но это меньшее зло чем
        # каскад waitress-падений на каждом запросе.
        import click
        click.echo = lambda *a, **kw: None
        logger.info("Web UI: http://localhost:%d (werkzeug threaded)", port)
        app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False, threaded=True)

    t = threading.Thread(target=_run, daemon=True)
    t.start()
    return port
