"""
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 random
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, send_file,
)

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/state/.
_secret_file = DATA_DIR / "state" / ".flask_secret"
_secret_file_legacy = DATA_DIR / ".flask_secret"
try:
    if _secret_file_legacy.exists() and not _secret_file.exists():
        _secret_file.parent.mkdir(parents=True, exist_ok=True)
        _secret_file_legacy.rename(_secret_file)
except Exception:
    pass
_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]
    # Технический лог для диагностики, в UI юзеру не нужен — на DEBUG.
    logger.debug("[auth] flask_secret fp=%s existed=%s license.json=%s",
                 _sec_fp, _secret_existed,
                 (DATA_DIR / "state" / "license.json").exists())
except Exception:
    pass
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=WEB_SESSION_HOURS)
app.config["TEMPLATES_AUTO_RELOAD"] = True
# Глобальный лимит тела запроса должен вмещать ручную загрузку апдейт-ZIP
# (бандл 66–162 MB) — иначе /api/update/upload_zip всегда ловит 413 ещё до
# хендлера. Это localhost-панель (login_required, SameSite=Strict), поэтому
# большой потолок безопасен; мелкие загрузки валидируются в своих хендлерах.
app.config["MAX_CONTENT_LENGTH"] = max(WEB_MAX_UPLOAD_MB, 350) * 1024 * 1024
app.config["SESSION_COOKIE_HTTPONLY"] = True
# Strict вместо Lax — защита от DNS rebinding: cross-site POST с куки клиента
# на localhost блокируется.
app.config["SESSION_COOKIE_SAMESITE"] = "Strict"


# Origin/Referer guard на все state-changing POST: защита от DNS rebinding
# и cross-origin форм-сабмитов (POST-эндпоинты без CSRF-токена).
_ALLOWED_ORIGINS_PREFIX = ("http://localhost:", "http://127.0.0.1:")

@app.before_request
def _csrf_origin_guard():
    if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
        return None
    # Исключения: /api/logs/client_event — постится из самого UI (тот же localhost),
    # /auth/license — первый логин (тоже из UI). Origin будет localhost у обоих.
    origin = request.headers.get("Origin", "") or ""
    referer = request.headers.get("Referer", "") or ""
    # Если ни Origin, ни Referer не указаны (curl / нативный клиент) — пропускаем.
    # Браузер ВСЕГДА шлёт хотя бы один на cross-origin. fetch с credentials='same-origin'
    # из самого UI шлёт Origin=http://localhost:PORT.
    if not origin and not referer:
        return None
    # Проверяем что хотя бы один из них начинается с локального префикса.
    src = origin or referer
    if any(src.startswith(p) for p in _ALLOWED_ORIGINS_PREFIX):
        return None
    # Внешний сайт пытается постить на localhost — отбой.
    logger.warning("[csrf] отказ POST %s — origin=%r referer=%r ip=%s",
                   request.path, origin, referer, request.remote_addr)
    return jsonify({"error": "Cross-origin request blocked"}), 403


# ── Маркеры версии для лаунчера ────────────────────────────────────────────
# main.py вшит в exe и не обновляется hot-update через ZIP, а web/app.py —
# обычный модуль в _internal/, обновляется каждым апдейтом. Поэтому stamp
# версии делаем здесь, при импорте модуля.
#
# Лаунчер читает .tp_ver для определения установленной версии. save_ver() у
# клиентов часто молча падает (антивирусы блокируют dot-файлы), и в .tp_ver
# остаётся старая версия → лаунчер каждый раз считает что нужно обновляться.
# Поэтому пишем актуальную версию отсюда — со снятием 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
        # Пишем версию ТОЛЬКО в app/.version + app/_internal/.version (их читает
        # config.py и лаунчер как fallback). Корневые .tp_ver/.version не создаём:
        # они всплывали видимыми рядом с лаунчером и палили софт. Основной
        # маркер — обфусцированный data/state/runtime.cache (ниже, не в корне).
        _targets = [
            _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}")
        # Новый основной маркер — обфусцированный, в data/state/ (не в корне).
        try:
            from utils.state_dir import write_version_marker as _wvm
            _wvm(TELEPUSH_VERSION)
        except Exception:
            pass
        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 ───────────────────────────────────────────────────────────────

from web.shared.context import (  # noqa: E402,F401  (вынесено из app.py)
    login_required, localhost_only, _get_state, _get_ctx, _safe_under_client_root,
)




# ── Идемпотентность стартов ──────────────────────────────────────────────────
# Двойной клик «Старт» мог пройти busy-чек дважды до того как первый выставит
# ctx.*_job (TOCTOU) → 2 джобы на одних акках = PeerFlood/revoke. single_run
# сериализует одновременные старты одного action для owner'а. Снимается по выходу
# из хендлера — к этому моменту джоба уже выставила status синхронно, и обычный
# busy-чек ловит повтор. 409 если уже выполняется.
import threading as _threading
from web.shared.jobs import (  # noqa: E402,F401  (вынесено из app.py)
    _BUSY_STALE_SEC, ACCOUNT_COOLDOWN_DEFAULT_MIN, _job_alive, _get_account_cooldown_sec, _AUTOLOAD_LOCK, _RECENT_ACTIVITY, _RECENT_ACTIVITY_MAX, _SINGLE_RUN_GUARD, _SINGLE_RUN_LOCKS, _get_account_busy, _iter_account_jobs, _cancel_all_jobs, _mark_account_finished, _set_job_auth_error, _get_account_cooldown, single_run, push_recent_activity, _any_task_running, _trigger_autoload_if_needed,
)




from web.shared.runtime import (  # noqa: E402,F401  (вынесено из app.py)
    _threadsafe_event_set, _ensure_loop_or_error, _inline_restart_loop, _loop_diag_log, _fire_and_forget, _run_async,
)


# ── 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/"):
        # tgweb-мост поллит интенсивно (getDifference каждые неск. секунд ×
        # число открытых окон). Общий лимит 120/мин на сессию пробивался при
        # 3+ окнах → 429. Мост — внутренняя инфраструктура за session-auth,
        # каждый вызов сам ограничен флуд-лимитами Telegram.
        if request.path.startswith("/api/tl/"):
            return
        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. /api/license/recheck ОБЯЗАН быть тут: иначе при
    # license_expired гейт ниже отдаёт 403 на POST /api/ — а кнопка «Перепроверить»
    # это и есть POST /api/license/recheck → она блокировалась флагом, который сама
    # же должна снять (catch-22: «истекла» нельзя перепроверить).
    always_ok = ("/api/status", "/api/profile", "/api/settings", "/api/logs",
                 "/api/renew", "/api/update", "/api/ticket", "/logout", "/login", "/static",
                 "/api/accounts", "/api/license/recheck")
    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"):
        # POST на /api/ блокируем как раньше. ПЛЮС любой /api/tl/* (GET и POST):
        # tgweb-мост отдаёт данные аккаунта (whoami=имя/ID, getDialogs=чаты) —
        # GET /api/tl/whoami обходил гейт и утекал identity истёкшей лицензии.
        if request.path.startswith("/api/"):
            if request.method == "POST" or request.path.startswith("/api/tl/"):
                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 ──────────────────────────────────────────────────────────────────


















# Cooldown между задачами на одном акке: после спама сразу инвайт — это
# бот-поведение для Telegram. Дефолт 5 мин. Основная account-safety защита —
# FloodWait + пейсинг между сообщениями; cooldown лишь лёгкая пауза между
# задачами. Настраивается клиентом (account_cooldown_min), 0 = выключить.








from web.shared.accounts_meta import (  # noqa: E402,F401  (вынесено из app.py)
    _GEO_FLAGS, _GEO_PREFIX_KEYS, _detect_geo, _get_account_age_days, _acc_proxy_to_json_vector, _build_account_json, _build_account_meta, _cleanup_duplicate_account_files, _build_client_for_phone,
)


@app.route("/api/recent_activity")
@login_required
def api_recent_activity():
    """Возвращает последние действия для topbar (свежие сверху)."""
    return jsonify({"items": list(reversed(_RECENT_ACTIVITY))})


# /api/ai/chat+settings вынесено в web/modules/ai_web/ (распил 2026-06-09).




def _busy_resp():
    """Готовый jsonify-ответ когда другая задача запущена."""
    return jsonify({
        "error": "Сначала остановите текущую задачу. Одновременно может работать только одна.",
    }), 409


_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








# ── TL-мост для веб-клиента Telegram Web K (tweb) ───────────────────────────
#
# tweb перехватывает invokeApi('messages.getDialogs', ...) и шлёт сюда JSON.
# Здесь реальный вызов идёт через Telethon-клиент выбранного аккаунта, ответ
# конвертируется в форму TL-объекта tweb. Логика конвертации — в web/tl_bridge.py.


@app.route("/api/report_bug", methods=["POST"])
def api_report_bug():
    """tgweb «Report Bug» → пишет отчёт в лог (синкается в remote_logs на
    сервере, чтобы видеть ошибки клиентов)."""
    if not session.get("logged_in"):
        return jsonify(ok=False, error={"type": "UNAUTHORIZED"}), 401
    data = request.get_json(silent=True) or {}
    phone = (request.cookies.get("tgk_phone") or data.get("phone") or "?").strip()
    src = str(data.get("src", "tgweb"))[:16]
    ua = str(data.get("ua", ""))[:200]
    url = str(data.get("url", ""))[:200]
    txt = str(data.get("text", ""))[:500]
    crumbs = str(data.get("crumbs", ""))[:300]
    # [diag]-префикс → _UILogHandler пропускает запись в видимый поток [СИС] (webk-
    # репорты не действие юзера, а диагностика для меня), но RemoteLogHandler шлёт
    # её на сервер при любом debug-флаге (WARNING). Юзер не видит «ошибок», я вижу.
    logger.warning("[diag] [bug_report] %s phone=%s url=%s text=%s crumbs=%s ua=%s", src, phone, url, txt, crumbs, ua)
    return jsonify(ok=True)


# tl-bridge (/api/tl/*, /tg-k/, /api/tgweb/unread) вынесено в web/modules/tgweb/ (распил 2026-06-09).




# ── 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"))
    # Кнопка «Восстановить из бэкапа» убрана: на shared VDS она автологинила
    # под чужим ключом из ~/.telepush/. Кто хочет — вводит ключ вручную.
    return render_template("login.html", restore_available=False)


@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




@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

    # Локальная проверка expires при каждом запросе. Серверный recheck идёт
    # раз в 2 часа — в окне между ними cached expires в session мог быть в
    # прошлом и работа не блокировалась. Проверяем cached дату на каждый запрос.
    _cached_exp = session.get("license_expires", "")
    if _cached_exp:
        # Без guard на license_expired: иначе клиент со stale-cookie оставался
        # заблокирован навсегда даже после продления.
        # Spam-guard: не чаще раза в 60 сек дёргаем сервер.
        try:
            from datetime import datetime as _dt
            _exp_dt = _dt.fromisoformat(_cached_exp)
            _last_stale = session.get("_stale_recheck_ts", 0)
            if _dt.now() > _exp_dt and (time.time() - _last_stale) > 60:
                session["_stale_recheck_ts"] = time.time()
                # Cached expires в прошлом, но session-cookie мог пережить апдейт
                # софта со старой датой (ключ продлён, сервер знает свежий expires,
                # а session ещё хранит старое). Дёргаем сервер один раз перед
                # блокировкой; если valid — обновляем session и снимаем флаг.
                # Не блокируем запрос синхронным lm.check() (морозил UI до 10с).
                # Источник истины — персистная дата из license.json (её пишет
                # фоновая сверка). Свежо там → подтягиваем в session без сети.
                # Иначе блокируем по дате (обратимо), сервер дёргаем в фоне; джобы
                # рубим только когда фон подтвердил что ключ реально невалиден.
                _refreshed = False
                _lm = None
                try:
                    from utils.license import LicenseManager
                    _lm = LicenseManager()
                    _ps = _lm._state or {}
                    _pexp = _ps.get("expires", "") or ""
                    # hwid-гард: верим future-дате из локального license.json только если
                    # файл этой машины (как is_valid). Отсекает перенос/правку чужого файла;
                    # отсутствие hwid (старый формат) не блокируем — фон-сверка добьёт.
                    if (_pexp and _dt.fromisoformat(_pexp) > _dt.now()
                            and _ps.get("hwid", _lm.hwid) == _lm.hwid):
                        session["license_expires"] = _pexp
                        if _ps.get("modules") is not None:
                            session["allowed_modules"] = _ps.get("modules")
                        if _ps.get("features") is not None:
                            session["allowed_features"] = _ps.get("features")
                        if _ps.get("key_type"):
                            session["key_type"] = _ps.get("key_type")
                        session["_lic_check"] = time.time()
                        session.pop("license_expired", None)
                        session.pop("license_expired_reason", None)
                        logger.debug("[lic] stale session expires (%s) → license.json свежий %s",
                                     _cached_exp[:10], _pexp[:10])
                        _refreshed = True
                except Exception as _re:
                    logger.debug("[lic] persisted-date check failed: %s", _re)

                if not _refreshed:
                    session["license_expired"] = True
                    session["license_expired_reason"] = "Срок лицензии истёк"
                    logger.warning("Срок лицензии истёк (по дате: %s) — сверяю на сервере в фоне", _cached_exp[:10])
                    if _lm is not None:
                        def _bg_lic(_k=session.get("license_key"), _m=_lm, _uid=session.get("uid")):
                            try:
                                _r = _m.check(_k)
                            except Exception:
                                return
                            if not _r.get("valid") and _uid:
                                # Сервер ПОДТВЕРДИЛ невалидность → теперь рубим джобы.
                                try:
                                    _st = _get_state()
                                    _c = _st.owners.get(_uid) if _st else None
                                    if _c:
                                        _n = _cancel_all_jobs(_c)
                                        if _n:
                                            logger.warning("License invalid (server-confirmed) uid=%s — %d jobs cancelled", _uid, _n)
                                except Exception:
                                    pass
                        threading.Thread(target=_bg_lic, daemon=True).start()
        except Exception:
            pass

    last = session.get("_lic_check", 0)
    # Проверяем каждые 2 часа независимо от текущего expired: транзиентная
    # ошибка (сервер недоступен, сеть моргнула) не должна навсегда блокировать
    # живой ключ. Если сервер сейчас говорит valid — снимаем флаг.
    if time.time() - last > 7200:  # 2 часа
        # timeout=4 (не 10): этот путь СИНХРОННЫЙ в before_request — он нужен для
        # enforcement отзыва ключа mid-term (bg-поток не может писать session, а
        # cached expires ещё в будущем → stale-ветка не сработает). Но короткий
        # таймаут не даёт панели морозиться надолго при медленном лиц-сервере.
        from utils.license import LicenseManager
        lm = LicenseManager()
        r = lm.check(session.get("license_key"), timeout=4)
        if r.get("valid"):
            # Реально валиден — снимаем ложный expired если он был
            if session.get("license_expired"):
                logger.info("Лицензия в порядке — сервер подтвердил, что ключ живой")
            session.pop("license_expired", None)
            session.pop("license_expired_reason", None)
            # Обновляем кэш expires/key_type/modules в session: иначе после
            # продления ключа дата истечения остаётся старой и Профиль 24+ часов
            # показывает «истёк», хотя backend уже знает свежий expires.
            _new_exp = r.get("expires", "")
            if _new_exp and _new_exp != session.get("license_expires", ""):
                session["license_expires"] = _new_exp
                logger.debug("[lic] Обновили expires в session: %s", _new_exp[:10])
            _new_kt = r.get("key_type", "")
            if _new_kt:
                session["key_type"] = _new_kt
            _new_mods = r.get("modules")
            if _new_mods is not None:
                session["allowed_modules"] = _new_mods
            _new_feats = r.get("features")
            if _new_feats is not None:
                session["allowed_features"] = _new_feats
        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.debug("[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 (отдельное окно) ────────────────────────────────────────────
# Клиент — Telegram Web K (`/tg-k/`, см. tweb_static). Менеджер аккаунтов
# открывает /tg-k/?phone=X (api_tg_open_window). Общие /api/tg/* эндпойнты
# (avatar/media) использует основная панель.


def _set_window_appusermodel_id(window_title: str, app_id: str) -> bool:
    """Тонкая обёртка над utils.win_taskbar.set_window_appusermodel_id."""
    try:
        from utils.win_taskbar import set_window_appusermodel_id as _impl
        return _impl(window_title, app_id)
    except Exception as e:
        logger.debug("[appusermodelid] set failed for %s: %s", window_title, e)
        return False


def _apply_tg_window_taskbar_id(window_title: str, phone: str):
    """Запускает в фоне попытку выставить уникальный AppUserModelID для нового
    TG-окна. Pywebview создаёт окно асинхронно, поэтому ждём пару кадров,
    чтобы HWND появился в системе. Делаем 3 попытки с возрастающей задержкой."""
    import threading, time as _t
    digits = re.sub(r"\D", "", phone or "")
    app_id = f"TelePush.TgClient.{digits}" if digits else "TelePush.TgClient"
    def _try():
        applied_id = False
        applied_icon = False
        for attempt, delay_ms in enumerate((250, 600, 1500), start=1):
            _t.sleep(delay_ms / 1000.0)
            if not applied_id:
                if _set_window_appusermodel_id(window_title, app_id):
                    applied_id = True
                    logger.debug("[tg-web] taskbar ID применён: %s → %s (попытка %d)", window_title, app_id, attempt)
            if not applied_icon:
                if _set_window_icon(window_title, "telegram.ico"):
                    applied_icon = True
                    logger.debug("[tg-web] иконка TG применена для %s (попытка %d)", window_title, attempt)
            if applied_id and applied_icon:
                return
        if not applied_id:
            logger.debug("[tg-web] не смог применить taskbar ID для %s", window_title)
        if not applied_icon:
            logger.debug("[tg-web] не смог применить иконку для %s", window_title)
    threading.Thread(target=_try, daemon=True, name="apply-tg-appid").start()


def _set_window_icon(window_title: str, icon_filename: str) -> bool:
    """Применяет иконку (.ico) к окну с указанным title через WM_SETICON.
    Иконка ищется в web/static/. Это меняет значок в title-bar и в таскбаре.
    """
    if not sys.platform.startswith("win"):
        return False
    try:
        import ctypes
        from ctypes import wintypes
        from pathlib import Path

        # Путь к .ico — учитываем frozen exe (PyInstaller)
        if getattr(sys, "frozen", False):
            base = Path(sys.executable).parent / "_internal" / "web" / "static"
        else:
            base = Path(__file__).parent / "static"
        ico_path = base / icon_filename
        if not ico_path.exists():
            # Fallback: data-dir или текущая
            alt = Path(__file__).parent / "static" / icon_filename
            if alt.exists():
                ico_path = alt
            else:
                logger.debug("[icon] %s not found at %s", icon_filename, ico_path)
                return False

        user32 = ctypes.windll.user32
        hwnd = user32.FindWindowW(None, ctypes.c_wchar_p(window_title))
        if not hwnd:
            return False

        # LoadImageW: тип IMAGE_ICON=1, LR_LOADFROMFILE=0x0010, LR_DEFAULTSIZE=0x0040
        IMAGE_ICON = 1
        LR_LOADFROMFILE = 0x00000010
        LR_DEFAULTSIZE = 0x00000040
        LR_SHARED = 0x00008000

        # Большая иконка (для alt-tab/taskbar) — 32x32
        h_big = user32.LoadImageW(None, ctypes.c_wchar_p(str(ico_path)),
                                   IMAGE_ICON, 32, 32,
                                   LR_LOADFROMFILE)
        # Маленькая иконка (для title-bar) — 16x16
        h_small = user32.LoadImageW(None, ctypes.c_wchar_p(str(ico_path)),
                                     IMAGE_ICON, 16, 16,
                                     LR_LOADFROMFILE)

        WM_SETICON = 0x0080
        ICON_SMALL = 0
        ICON_BIG = 1

        if h_big:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG, h_big)
        if h_small:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, h_small)

        return bool(h_big or h_small)
    except Exception as e:
        logger.debug("[icon] set failed for %s: %s", window_title, e)
        return False


class _TgwebFileApi:
    """JS-мост нативного выбора файлов для окна tgweb. WebView2 в этом хосте
    (Windows Server / процесс от админа) НЕ открывает системный диалог по HTML
    <input type=file> — известное ограничение WebView2 (DOM.setFileInputFiles
    тоже не работает). Поэтому скрепочка в tweb зовёт window.pywebview.api.pick_files
    → нативный WinForms-диалог pywebview (он работает независимо от мёртвого
    WebView2-диалога), читаем байты, отдаём в tweb как base64 → File."""

    def __init__(self):
        self._window = None

    def pick_files(self, kind="media", multiple=True):
        import base64 as _b64
        import os as _os
        import mimetypes as _mt
        try:
            import webview as _wv
            win = self._window or _wv.active_window()
            if win is None:
                return {"ok": False, "error": "no_window"}
            if kind == "document":
                file_types = ("Все файлы (*.*)",)
            else:
                file_types = (
                    "Медиа (*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp;*.mp4;*.mov;*.webm;*.mkv;*.avi)",
                    "Все файлы (*.*)",
                )
            _dlg = getattr(getattr(_wv, "FileDialog", None), "OPEN", None)
            if _dlg is None:
                _dlg = getattr(_wv, "OPEN_DIALOG", 10)  # legacy pywebview
            paths = win.create_file_dialog(_dlg, allow_multiple=bool(multiple), file_types=file_types)
            if not paths:
                return {"ok": True, "files": []}
            out = []
            for p in paths:
                try:
                    # Кап ДО чтения (os.path.getsize) — иначе _f.read() гигабайтного
                    # файла аллоцирует его весь в RAM ещё до проверки → OOM/фриз UI.
                    try:
                        _sz = _os.path.getsize(p)
                    except OSError:
                        _sz = 0
                    if _sz > 200 * 1024 * 1024:  # 200МБ кап — защита bridge от OOM
                        logger.warning("[tgweb] pick_files: %s >200МБ — пропускаю", _os.path.basename(p))
                        continue
                    with open(p, "rb") as _f:
                        _raw = _f.read()
                    _name = _os.path.basename(p)
                    out.append({
                        "name": _name,
                        "type": _mt.guess_type(_name)[0] or "application/octet-stream",
                        "size": len(_raw),
                        "dataB64": _b64.b64encode(_raw).decode("ascii"),
                    })
                except Exception as _fe:
                    logger.warning("[tgweb] pick_files read fail %s: %s", p, str(_fe)[:120])
                    continue
            return {"ok": True, "files": out}
        except Exception as _e:
            logger.warning("[tgweb] pick_files упал: %s", str(_e)[:160])
            return {"ok": False, "error": str(_e)[:200]}


# /api/tg/open_window вынесено в web/modules/tgweb/ (распил 2026-06-09).


# ── Окно логов ───────────────────────────────────────────────────────────────

@app.route("/logs")
@localhost_only
def logs_window_page():
    """Полноэкранная страница с лог-стримом — рендерится в отдельном
    pywebview-окне, открывается через /api/logs/open_window.

    localhost_only вместо login_required: pywebview-окно имеет свой
    cookie-storage отдельно от главного, Flask-session туда не доходит, и окно
    логов иначе требовало бы повторный ввод лицензии. Сервер слушает только
    127.0.0.1 — безопасно."""
    resp = make_response(render_template("logs_window.html"))
    resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
    return resp


def _hwnd_logs_this_process():
    """HWND окна логов ТЕКУЩЕГО процесса (по PID + подстрока «Логи» в титуле).
    None если у этой копии окна нет.

    Заменяет глобальный FindWindow(None, "TelePush — Логи"): при нескольких
    запущенных копиях титулы окон совпадают, и глобальный поиск фокусировал
    консоль чужой копии. EnumWindows+PID авторитетен и не держит stale-закрытые
    окна, как webview.windows."""
    if sys.platform != "win32":
        return None
    try:
        import ctypes as _c
        from ctypes import wintypes as _wt
        user32 = _c.windll.user32
        my_pid = os.getpid()
        result = []

        @_c.WINFUNCTYPE(_c.c_bool, _wt.HWND, _wt.LPARAM)
        def _enum(hwnd, _lparam):
            pid = _wt.DWORD()
            user32.GetWindowThreadProcessId(hwnd, _c.byref(pid))
            if pid.value == my_pid:
                length = user32.GetWindowTextLengthW(hwnd)
                if length:
                    buf = _c.create_unicode_buffer(length + 1)
                    user32.GetWindowTextW(hwnd, buf, length + 1)
                    if "Логи" in buf.value:
                        result.append(hwnd)
                        return False  # стоп — нашли
            return True

        user32.EnumWindows(_enum, 0)
        return result[0] if result else None
    except Exception:
        return None


def _open_url_in_chromium_app(url, width=960, height=620):
    """Открывает URL отдельным окном Edge/Chrome в режиме --app — так же, как
    main.py открывает САМУ панель, когда pywebview/CLR недоступен. Тот же профиль
    data/web_profile → новое окно того же браузера (а не отдельный инстанс).
    Возвращает True если процесс запущен, False если chromium не найден."""
    import os as _os
    import subprocess as _sp
    _pf86 = _os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")
    _pf = _os.environ.get("PROGRAMFILES", r"C:\Program Files")
    _la = _os.environ.get("LOCALAPPDATA", "")
    _cands = []
    for _root in (_pf86, _pf, _la):
        if _root:
            _cands.append(Path(_root) / "Microsoft" / "Edge" / "Application" / "msedge.exe")
    for _root in (_pf, _pf86, _la):
        if _root:
            _cands.append(Path(_root) / "Google" / "Chrome" / "Application" / "chrome.exe")
    _exe = next((str(c) for c in _cands if c.exists()), None)
    if not _exe:
        return False
    _profile = DATA_DIR / "web_profile"
    try:
        _profile.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    _args = [
        _exe,
        f"--app={url}",
        f"--user-data-dir={_profile}",
        "--no-first-run",
        "--no-default-browser-check",
        "--disable-features=Translate",
        f"--window-size={width},{height}",
    ]
    _sp.Popen(_args, creationflags=0x08000000)  # CREATE_NO_WINDOW
    return True


@app.route("/api/logs/open_window", methods=["POST"])
@login_required
def api_logs_open_window():
    """Открыть лог-стрим в отдельном pywebview-окне ИМЕННО ЭТОЙ копии TelePush.

    Singleton строго в рамках текущего процесса (по PID). Глобальный guard по
    общему титулу «TelePush — Логи» (FindWindow) при нескольких копиях
    фокусировал консоль другой копии — поэтому ищем окно по PID."""
    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}/logs"

    # Уже открыта в ЭТОЙ копии? Проверяем окно текущего процесса (PID).
    _hwnd = _hwnd_logs_this_process()
    if _hwnd:
        try:
            import ctypes as _c
            _c.windll.user32.ShowWindow(_hwnd, 9)  # SW_RESTORE
            _c.windll.user32.SetForegroundWindow(_hwnd)
        except Exception:
            pass
        return jsonify({"ok": True, "url": target_url, "opened": True, "already": True})

    opened = False
    try:
        import webview
        # Только если pywebview реально запущен с живыми окнами. В браузерном
        # режиме (pythonnet/CLR недоступен → UI открыт в msedge --app) pywebview
        # НЕ стартовал и webview.windows == [] (пустой, но не None). Прежнее
        # условие `is not None` пропускало этот случай → create_window регистрировал
        # окно-фантом без GUI-loop → opened=True, а окна нет → фронт не делал
        # window.open-фолбэк → «Отдельное окно» молчало. Пустой список → opened=False
        # → фронт открывает /logs новой вкладкой/окном браузера.
        if getattr(webview, "windows", None):
            try:
                webview.create_window(
                    "TelePush — Логи",
                    target_url,
                    width=960,
                    height=620,
                    min_size=(700, 400),
                    resizable=True,
                    background_color="#07070d",
                )
                opened = True
                logger.debug("[logs-window] окно создано (этот процесс)")
            except Exception as e:
                logger.warning("[logs-window] create_window упал: %s", e)
    except ImportError:
        pass

    # Браузерный режим (pywebview не стартовал — CLR недоступен, UI открыт в
    # msedge --app): открываем логи ОТДЕЛЬНЫМ окном Edge --app, ровно как main.py
    # открыл панель. Тот же профиль → новое окно того же браузера, не popup.
    # Не нашли chromium → opened=False → фронт сделает window.open (вкладка/попап).
    if not opened:
        try:
            if _open_url_in_chromium_app(target_url, 960, 620):
                opened = True
                logger.debug("[logs-window] открыто отдельным окном Edge --app (браузерный режим)")
        except Exception as e:
            logger.debug("[logs-window] chromium --app не запустился: %s", e)
    return jsonify({"ok": True, "url": target_url, "opened": opened})


# ── 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/version")
@login_required
def api_version_proxy():
    """Проксит /api/version с сервера telepush.org (route существует только на
    сервере) — модалка «Что нового» в UI иначе ловила 404 на localhost.
    Возвращаем серверный ответ как есть (`version` + `changelog`). Если сервер
    недоступен — fallback на локальную версию из config без changelog."""
    try:
        import requests as _r
        _resp = _r.get(f"{TELEPUSH_SERVER_URL}/api/version", timeout=4)
        if _resp.ok:
            return jsonify(_resp.json())
    except Exception as _ve:
        logger.debug("[api/version proxy] %s", _ve)
    return jsonify({"version": TELEPUSH_VERSION, "changelog": ""})


@app.route("/api/status")
@login_required
def api_status():
    ctx = _get_ctx()
    if not ctx:
        return jsonify({"ok": False})
    # Догон autoload: ловим случай когда акки на диске есть, а в ctx 0
    # (main.py пропустил autoload из-за невалидной license). Триггер
    # срабатывает один раз за процесс.
    if not ctx.accounts:
        _trigger_autoload_if_needed("api/status")
    result = {
        "ok": True,
        "active": ctx.active,
        # Счётчик исключает dead+frozen — совпадает с таблицей менеджера
        # (app.js фильтр `!a.dead && !a.frozen`) и пикерами. raw len(ctx.accounts)
        # включал мёртвых/замороженных → расхождение со списком в шапке.
        "accounts_count": sum(1 for a in ctx.accounts.values()
                              if not getattr(a, "dead", False) and not getattr(a, "frozen", False)),
    }
    # Список акков с открытой tgweb-вебкой (свежий heartbeat <8с). Фронт
    # обновляет бейдж «TG-веб» live при открытии/закрытии окна вместо стойкого
    # снапшота из /api/accounts.
    try:
        import time as _tw_t
        _tw_open = ctx.temp.get("tg_web_open") or {}
        _tw_now = _tw_t.time()
        result["tgweb_open"] = [str(_ph) for _ph, _ts in list(_tw_open.items())
                                if (_tw_now - float(_ts or 0)) < 8]
    except Exception:
        result["tgweb_open"] = []
    # 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", {})
    # Для глобального statusbar внизу — версия + hwid (для саппорта).
    try:
        from config import TELEPUSH_VERSION as _ver
        result["sb_version"] = _ver
    except Exception:
        pass
    try:
        from utils.hwid import generate_hwid as _ghw
        _h = _ghw() or ""
        result["sb_hwid_short"] = _h[:8] if _h else ""
    except Exception:
        pass
    # Флаг «онбординг уже показывался» — хранится на диске чтобы не зависеть
    # от webview-localStorage (который может чиститься в private_mode).
    try:
        _onb_flag = DATA_DIR / "onboarding_done.flag"
        result["onboarding_done"] = _onb_flag.exists()
    except Exception:
        result["onboarding_done"] = False
    # Проверка обновлений: /api/status только ЧИТАЕТ кэш, а обновляет его в ФОНЕ.
    # Раньше синхронный check_update() (requests timeout=10) морозил каждый ~60с-
    # поллинг при медленном/недоступном сервере — панель «подвисала» до 10с.
    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) and not getattr(app, '_update_refreshing', False):
            app._update_refreshing = True
            def _bg_update_check():
                try:
                    from utils.updater import check_update
                    app._update_cache = check_update()
                    app._update_cache_ts = time.time()
                except Exception:
                    pass
                finally:
                    app._update_refreshing = False
            threading.Thread(target=_bg_update_check, daemon=True).start()
        if _upd_cache and _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
        # dict(mj.counts) на Flask-потоке гонит с loop-потоком, добавляющим ключи
        # счётчиков (setdefault) → «dict changed size during iteration». Ловим (L13).
        try:
            _mj_counts = dict(mj.counts) if isinstance(getattr(mj, "counts", None), dict) else {}
        except RuntimeError:
            _mj_counts = {}
        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": _mj_counts,
        })
    # Парсер — тоже активная задача, показываем на главной
    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", ""),
        }
    # Статус инвайта/чекера для блокировки «Старт»: фронт глушит
    # btn-invite-start/btn-ck-start пока running — переживает перезагрузку
    # страницы, не только in-session своп Старт↔Стоп.
    _inv_job = (getattr(ctx, "temp", {}) or {}).get("invite_job")
    if _inv_job is not None and getattr(_inv_job, "status", "") == "running":
        result["invite_job"] = {"status": "running"}
    _chk_job = getattr(ctx, "checker_job", None)
    if _chk_job is not None and getattr(_chk_job, "status", "") == "running":
        result["checker_job"] = {"status": "running"}
    _ch_job = getattr(ctx, "chatter_job", None)
    if _ch_job is not None and getattr(_ch_job, "status", "") == "running":
        result["chatter_job"] = {
            "status": _ch_job.status,
            "phones": len(getattr(_ch_job, "phones", []) or []),
            "processed": getattr(_ch_job, "processed", 0),
            "total": getattr(_ch_job, "total", 0),
            "counts": dict(getattr(_ch_job, "counts", {}) or {}),
        }
    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
        # monitor_jobs — это list (не dict), итерируем напрямую.
        for mj in (getattr(ctx, "monitor_jobs", None) or []):
            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.infra.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
            # close в finally — иначе при исключении в execute утечка
            # SQLite-хэндла на каждый poll дашборда.
            try:
                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)
            finally:
                _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 ───────────────────────────────────────────────────────

# Account-tags роуты (/api/account_tags, /api/account_tags/set) вынесены в
# web/modules/account_manager/routes.py (распил монолита 2026-06-04).


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

@app.route("/api/profile")
@login_required
def api_profile():
    """Данные профиля: лицензия, HWID, версия, аккаунты.

    При каждом открытии вкладки делаем свежий license check (~1с) вместо
    кэшированной даты из session — иначе после продления ключа дата истечения
    оставалась старой.
    """
    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

    # Принудительный refresh license — не ждём фонового _refresh_license_background.
    # Полезно когда админ только что продлил/изменил ключ.
    if key:
        try:
            fresh = lm.check(key)
            if fresh and fresh.get("valid"):
                _new_exp = fresh.get("expires", "")
                if _new_exp and _new_exp != session.get("license_expires", ""):
                    session["license_expires"] = _new_exp
                    session.pop("license_expired", None)
                    session.pop("license_expired_reason", None)
                _new_kt = fresh.get("key_type", "")
                if _new_kt:
                    session["key_type"] = _new_kt
                _new_mods = fresh.get("modules")
                if _new_mods is not None:
                    session["allowed_modules"] = _new_mods
                _new_feats = fresh.get("features")
                if _new_feats is not None:
                    session["allowed_features"] = _new_feats
        except Exception as _le:
            logger.debug("[profile] license refresh skipped: %s", _le)

    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


@app.route("/api/license/recheck", methods=["POST"])
@login_required
def api_license_recheck():
    """Принудительная пере-проверка лицензии у сервера: если клиент закэшировал
    «истёк» локально, а сервер говорит valid. Кнопка «Перепроверить» в
    expired-экране дёргает этот endpoint и обновляет локальный кэш."""
    from utils.license import LicenseManager
    lm = LicenseManager()
    key = session.get("license_key", "") or ""
    if not key:
        return jsonify({"ok": False, "error": "Ключ не найден в сессии"}), 400
    try:
        r = lm.check(key)
        if r.get("valid"):
            # Обновляем session-cookie новыми данными
            session["license_expires"] = r.get("expires", "")
            session["allowed_modules"] = r.get("modules", session.get("allowed_modules", []))
            session["allowed_features"] = r.get("features", session.get("allowed_features", {}))
            session["key_type"] = r.get("key_type", session.get("key_type", "pro"))
            session.pop("license_expired", None)
            session.pop("license_expired_reason", None)
            session["_lic_check"] = time.time()
            return jsonify({"ok": True, "valid": True, "expires": r.get("expires", ""),
                            "msg": "Ключ активен. Перезагрузи страницу — модули разблокируются."})
        return jsonify({"ok": False, "valid": False, "error": r.get("error", "Сервер сказал ключ невалиден")})
    except Exception as e:
        logger.warning("[license-recheck] error: %s", e)
        return jsonify({"ok": False, "error": "Сервер не отвечает: " + str(e)[:100]}), 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:
            # НЕ ретраим verify=False: послали бы ключ+hwid по непроверенному TLS
            # (MITM может форсировать SSLError → перехват). Fail closed.
            logger.warning("Renew SSL verify failed (%s) — не шлю по unverified TLS", ssl_err)
            return jsonify({"ok": False, "error": "Ошибка TLS — проверь время/сеть и повтори"}), 502
    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():
            _threadsafe_event_set(job.pause_event)
    elif action == "stop":
        job.stop_flag = True
        _threadsafe_event_set(job.pause_event)
        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 для саппорта: версия, число аккаунтов, 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))
            # Реальный атрибут — floodwait_until (timestamp), считаем активные.
            diag["accounts_floodwait"] = sum(1 for a in accs if getattr(a, "floodwait_until", 0) > time.time())
            diag["accounts_busy"] = sum(1 for a in accs if getattr(a, "busy", ""))
            # Активные задачи: маппинг типа на фактическое место хранения
            # (join_job→ctx.job, dm→user_spam_job, invite/parser в ctx.temp,
            # monitor_jobs=список).
            jobs = []
            _diag_jobs = {
                "join": getattr(ctx, "job", None),
                "spam": getattr(ctx, "spam_job", None),
                "dm": getattr(ctx, "user_spam_job", None),
                "warmup": getattr(ctx, "warmup_job", None),
                "checker": getattr(ctx, "checker_job", None),
                "chatter": getattr(ctx, "chatter_job", None),
                "boost": getattr(ctx, "boost_job", None),
                "imitation": getattr(ctx, "imitation_job", None),
                "cleanup": getattr(ctx, "cleanup_job", None),
                "neuro_comments": getattr(ctx, "neuro_comments_job", None),
                "reauth": getattr(ctx, "reauth_job", None),
                "invite": ctx.temp.get("invite_job") if hasattr(ctx, "temp") else None,
            }
            for _jtype, j in _diag_jobs.items():
                if j and getattr(j, "status", "") in ("running", "paused"):
                    jobs.append({
                        "type": _jtype,
                        "status": j.status,
                        "processed": getattr(j, "processed", 0),
                        "total": getattr(j, "total", 0),
                    })
            for mj in (getattr(ctx, "monitor_jobs", None) or []):
                if getattr(mj, "status", "") in ("running", "paused"):
                    jobs.append({"type": "monitor", "status": mj.status,
                                 "processed": getattr(mj, "processed", 0), "total": getattr(mj, "total", 0)})
            _pstate = (getattr(ctx, "temp", None) or {}).get("parser", {})
            if _pstate.get("status") in ("running", "paused"):
                jobs.append({"type": "parser", "status": _pstate.get("status"),
                             "processed": _pstate.get("processed", 0), "total": _pstate.get("total", 0)})
            diag["active_jobs"] = jobs
    except Exception as e:
        diag["accounts_error"] = str(e)[:120]
    # Прокси. (Раньше тянул несуществующий utils.proxy_pool → импорт всегда падал
    # → diag молча пустой → строка «Прокси: X/Y живых» в UI не появлялась.)
    try:
        from core.infra.proxy import load_all_alive_proxies
        from config import DATA_DIR as _DDp
        _total = 0
        for _pf in (_DDp / "state" / "proxies.txt", _DDp / "proxies.txt"):
            if _pf.exists():
                _total = sum(1 for _ln in _pf.read_text(encoding="utf-8").splitlines()
                             if _ln.strip() and not _ln.strip().startswith("#"))
                break
        diag["proxies_total"] = _total
        diag["proxies_alive"] = len(load_all_alive_proxies())  # не помеченные мёртвыми
    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")


def _init_ui_log_handler():
    """Регистрируем handler сразу при импорте модуля, не лениво на первом GET
    /api/logs/recent — иначе логи, случившиеся до первого poll, терялись
    (handler ещё не зарегистрирован)."""
    import collections, logging, secrets, re as _re_mod
    if getattr(app, '_log_buffer', None) is not None:
        return
    # Технические [tag]-префиксы ([autoload]/[tgweb]/[webview]/[first-connect]/…)
    # убираем ТОЛЬКО из окна логов клиента — юзеру это визуальный шум («приколы»).
    # В remote_logs (отдельный серверный хэндлер) теги ОСТАЮТСЯ — нужны поддержке
    # для грепа по hwid. Снимаем лишь ОДИН ведущий [слово] в начале сообщения.
    _ui_tag_re = _re_mod.compile(r"^\[[\w.\-]+\]\s*")
    app._log_buffer = collections.deque(maxlen=500)
    # boot-token меняется при каждом старте процесса. UI после рестарта
    # сравнивает токен и сбрасывает локальный _lwLastId с DOM-логами — иначе в
    # окне «Логи работы» остаются записи прошлой сессии, а новые игнорируются
    # (их id меньше сохранённого «since»).
    app._log_boot_token = secrets.token_hex(8)
    _seq = [0]

    class _UILogHandler(logging.Handler):
        def emit(self, record):
            try:
                # Записи с префиксом [diag] и [LIFECYCLE] идут только на сервер
                # (диагностика и телеметрия для админ-панели), в UI не нужны.
                _msg_raw = record.getMessage()
                if _msg_raw.startswith("[diag]") or _msg_raw.startswith("[LIFECYCLE]"):
                    return
                _seq[0] += 1
                # Фиксируем реальное время события (record.created), а не момент
                # опроса — иначе старые записи буфера показывались бы с текущим
                # временем (будто всё произошло одновременно).
                import time as _t
                _ts = getattr(record, "created", None) or _t.time()
                app._log_buffer.append({
                    "id": _seq[0],
                    "ts": _ts,
                    "level": record.levelname,
                    "module": _module_short(record.name),
                    "message": _ui_tag_re.sub("", self.format(record)),
                })
            except Exception:
                pass

    h = _UILogHandler(level=logging.INFO)
    h.setFormatter(logging.Formatter("%(message)s"))
    logging.getLogger("telepush").addHandler(h)


_init_ui_log_handler()


@app.route("/api/logs/client_event", methods=["POST"])
@localhost_only
def api_logs_client_event():
    """Фронт постит сюда любое UI-уведомление (toast) — оно попадает в общий
    логгер и появляется в окне «Логи работы»."""
    data = request.json or {}
    msg = str(data.get("message", ""))[:500]
    level = str(data.get("level", "INFO")).upper()
    module = str(data.get("module", "ui"))[:32]
    if not msg:
        return jsonify({"ok": False, "error": "no message"}), 400

    # Generic-сообщения без подробностей бесполезны для диагностики и засоряют
    # remote_logs. Понижаем их в INFO — в локальный лог попадает, на сервер не
    # шлётся (RemoteLogHandler шлёт WARNING+).
    _generic_noise = {
        "ошибка", "ошибка сети", "не получилось", "не удалось",
        "error", "failed", "request failed",
    }
    if level == "ERROR" and msg.strip().lower().rstrip(".!") in _generic_noise:
        level = "INFO"

    _log = logging.getLogger(f"telepush.{module}")
    if level == "ERROR":
        _log.error("%s", msg)
    elif level == "WARNING":
        _log.warning("%s", msg)
    elif level == "SUCCESS":
        _log.log(25, "%s", msg)
    else:
        _log.info("%s", msg)
    return jsonify({"ok": True})


@app.route("/api/logs/recent")
@localhost_only
def api_logs_recent():
    """Последние логи для консоли в UI. Каждая запись содержит module-тег
    (spam/dm/join/parser/monitor/warmup/autoreply/system) для фильтрации."""
    _log_buffer = getattr(app, '_log_buffer', None)
    if _log_buffer is None:
        _init_ui_log_handler()
        _log_buffer = app._log_buffer

    since = int(request.args.get("since", 0))
    logs = [l for l in _log_buffer if l["id"] > since]
    # Счётчик акков в работе + список активных модулей.
    busy_accs = set()
    active_modules = []  # [(module, count)] для подсказки
    # Real-time stats: сумма отправленных + total по всем активным задачам
    # (job.counts['sent']/job.total).
    rt_sent = 0
    rt_total = 0
    rt_failed = 0
    # Глагол для бейджа логов: дефолт «отправлено», но при активном только
    # чекере → «проверено». Инициализируем до try — return jsonify вне try.
    rt_verb = "отправлено"
    try:
        ctx = _get_ctx()
        if ctx:
            # Per-account busy state — используем существующий helper
            for phone in (ctx.accounts or {}).keys():
                if _get_account_busy(ctx, phone):
                    busy_accs.add(phone)
            # Какие модули реально активны + сбор stats
            _module_counts = {}
            _attrs = (("job", "Подписка"), ("spam_job", "Рассылка"),
                      ("user_spam_job", "ЛС"), ("warmup_job", "Прогрев"))
            for _a, _label in _attrs:
                _j = getattr(ctx, _a, None)
                if _j and getattr(_j, "status", None) == "running":
                    _module_counts[_label] = _module_counts.get(_label, 0) + 1
                    try:
                        _c = getattr(_j, "counts", {}) or {}
                        rt_sent += int(_c.get("sent", 0) or 0)
                        rt_failed += int(_c.get("failed", 0) or 0)
                        rt_total += int(getattr(_j, "total", 0) or 0)
                    except Exception: pass
            for _mj in (ctx.temp.get("multi_jobs") or []):
                if getattr(_mj, "status", None) == "running":
                    _t = getattr(_mj, "type", "spam")
                    _lbl = {"join": "Мульти-подписка", "dm": "Мульти-ЛС"}.get(_t, "Мульти-рассылка")
                    _module_counts[_lbl] = _module_counts.get(_lbl, 0) + 1
                    try:
                        _c = getattr(_mj, "counts", {}) or {}
                        rt_sent += int(_c.get("sent", 0) or 0)
                        rt_failed += int(_c.get("failed", 0) or 0)
                        rt_total += int(getattr(_mj, "total", 0) or 0)
                    except Exception: pass
            # Чекер тоже в реалтайме.
            _chk = (ctx.temp or {}).get("spam_check") or {}
            if _chk.get("status") == "running":
                _module_counts["Чекер"] = _module_counts.get("Чекер", 0) + 1
                try:
                    rt_sent += int(_chk.get("checked", 0) or 0)
                    rt_total += int(_chk.get("total", 0) or 0)
                except Exception: pass
            # Парсер
            _parser = (ctx.temp or {}).get("parser") or {}
            if _parser.get("status") == "running":
                _module_counts["Парсер"] = _module_counts.get("Парсер", 0) + 1
                try:
                    rt_sent += int(_parser.get("progress", 0) or 0)
                    rt_total += int(_parser.get("scanned", 0) or 0) or int(_parser.get("progress", 0) or 0)
                except Exception: pass
            # Bulk-fill (Заполнение профилей)
            _bf = (ctx.temp or {}).get("bulk_fill") or {}
            if _bf.get("status") == "running":
                _module_counts["Заполнение профилей"] = _module_counts.get("Заполнение профилей", 0) + 1
                try:
                    rt_sent += int(_bf.get("done", 0) or 0)
                    rt_total += int(_bf.get("total", 0) or 0)
                except Exception: pass
            active_modules = sorted(_module_counts.items(), key=lambda x: -x[1])
            # Глагол по единственному активному не-sender модулю: если работает
            # только чекер/парсер/заполнение — «отправлено» вводит в заблуждение.
            # При смешанной нагрузке оставляем «отправлено».
            if _module_counts:
                _only = set(_module_counts.keys())
                if _only == {"Чекер"}:
                    rt_verb = "проверено"
                elif _only == {"Парсер"}:
                    rt_verb = "обработано"
                elif _only == {"Заполнение профилей"}:
                    rt_verb = "заполнено"
    except Exception:
        pass
    return jsonify({
        "logs": logs[-100:],
        "active": len(busy_accs) > 0,
        "busy_accounts": len(busy_accs),
        "active_modules": active_modules,
        "rt_sent": rt_sent,
        "rt_failed": rt_failed,
        "rt_total": rt_total,
        "rt_verb": rt_verb,
        "boot": getattr(app, '_log_boot_token', ''),
    })


@app.route("/api/logs/warn_count")
@localhost_only
def api_logs_warn_count():
    """Лёгкий poll для бейджа на кнопке «Логи» в сайдбаре.
    Возвращает количество WARNING+ERROR с id > since и max id в буфере.
    Frontend хранит last_seen в localStorage и обнуляет при открытии окна логов."""
    _log_buffer = getattr(app, '_log_buffer', None)
    if _log_buffer is None:
        _init_ui_log_handler()
        _log_buffer = app._log_buffer
    since = int(request.args.get("since", 0))
    warn = 0
    err = 0
    max_id = 0
    for l in _log_buffer:
        lid = l.get("id") or 0
        if lid > max_id:
            max_id = lid
        if lid <= since:
            continue
        lvl = l.get("level") or ""
        if lvl == "ERROR":
            err += 1
        elif lvl == "WARNING":
            warn += 1
    return jsonify({
        "warn": warn,
        "err": err,
        "max_id": max_id,
        "boot": getattr(app, '_log_boot_token', ''),
    })


@app.route("/api/debug_to_server/status")
@localhost_only
def api_debug_to_server_status():
    """Статус расширенного режима DEBUG-логов в саппорт. По дефолту включён;
    чекбокс в UI позволяет выключить."""
    try:
        from utils.remote_logger import RemoteLogHandler
        enabled = bool(getattr(RemoteLogHandler, "debug_enabled", True))
    except Exception:
        enabled = True
    return jsonify({"enabled": enabled})


@app.route("/api/debug_to_server/toggle", methods=["POST"])
@login_required
def api_debug_to_server_toggle():
    """Включить/выключить присылку DEBUG-логов в саппорт."""
    try:
        enabled = bool((request.json or {}).get("enabled", True))
    except Exception:
        enabled = True
    try:
        from utils.remote_logger import RemoteLogHandler
        RemoteLogHandler.debug_enabled = enabled
        logging.getLogger("telepush.system").info(
            "[debug-to-server] %s", "ВКЛЮЧЕНО" if enabled else "ВЫКЛЮЧЕНО")
    except Exception as _e:
        return jsonify({"error": f"не удалось: {_e}"}), 500
    return jsonify({"ok": True, "enabled": enabled})


@app.route("/api/update/upload_zip", methods=["POST"])
@login_required
def api_update_upload_zip():
    """Drag-and-drop архива обновления. Если auto-update упал или сервер
    недоступен, клиент перетаскивает скачанный ZIP в UI — софт кладёт его как
    manual_update.zip рядом с TelePush.exe. Лаунчер при следующем запуске
    увидит файл и установит."""
    f = request.files.get("file")
    if not f or not f.filename:
        return jsonify({"ok": False, "error": "Файл не выбран"}), 400
    fname = f.filename.lower()
    if not fname.endswith(".zip"):
        return jsonify({"ok": False, "error": "Нужен ZIP-архив"}), 400
    # Базовая директория — где TelePush.exe
    try:
        import sys as _s
        if getattr(_s, "frozen", False):
            base = Path(_s.executable).parent
        else:
            base = Path(__file__).parent.parent
    except Exception:
        return jsonify({"ok": False, "error": "Не нашёл путь для сохранения"}), 500
    dest = base / "manual_update.zip"
    try:
        f.save(str(dest))
        size_mb = dest.stat().st_size / (1024 * 1024)
    except Exception as e:
        return jsonify({"ok": False, "error": f"Не удалось сохранить: {e}"}), 500
    # Базовая проверка что внутри что-то похожее на TelePush bundle
    try:
        import zipfile as _zf
        with _zf.ZipFile(str(dest), "r") as zf:
            names = zf.namelist()
            has_internal = any(n.startswith("_internal/") or n.startswith("app/_internal/") for n in names)
            has_exe = any(n.endswith("TelePush.exe") for n in names)
            if not (has_internal or has_exe):
                dest.unlink(missing_ok=True)
                return jsonify({"ok": False, "error": "Это не похоже на архив TelePush (нет _internal/ или TelePush.exe внутри)"}), 400
    except Exception as e:
        return jsonify({"ok": False, "error": f"ZIP повреждён: {e}"}), 400
    logger.info("[manual_update] saved %s → %s (%.1f MB)", f.filename, dest, size_mb)
    return jsonify({"ok": True, "size_mb": round(size_mb, 1),
                    "msg": "Архив сохранён. Перезапусти TelePush — лаунчер сам установит обнову"})


@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
            # launcher.log + startup_debug.txt живут в КОРНЕ установки (на уровень
            # выше data/logs/), поэтому rglob выше до них не дотягивался — а это
            # ровно те логи, что нужны для разбора проблем запуска/обновления.
            # Кладём их в архив отдельно, чтобы dev получал ВСЕ логи клиента.
            try:
                _root = DATA_DIR.parent
                for _extra in [_root / "launcher.log", _root / "startup_debug.txt"]:
                    try:
                        if _extra.is_file():
                            zf.write(str(_extra), arcname=f"root/{_extra.name}")
                            added += 1
                    except Exception:
                        skipped += 1
            except Exception:
                pass
            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


_UPDATE_STATE = {"status": "idle", "message": "", "error_code": None}


@app.route("/api/update/install", methods=["POST"])
@login_required
def api_update_install():
    """Async install: возвращает 200 сразу, install_update бежит в daemon-потоке,
    UI поллит /api/update/status каждые 3с. Синхронный download/unpack на
    медленном интернете занимал 60+с — waitress закрывал channel по timeout.
    """
    global _UPDATE_STATE
    if _UPDATE_STATE.get("status") == "running":
        return jsonify({"ok": True, "status": "running",
                        "message": "Установка уже идёт — подожди"})

    _UPDATE_STATE = {"status": "running", "message": "Скачиваю обновление...",
                     "error_code": None}

    def _bg_install():
        global _UPDATE_STATE
        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())
            _UPDATE_STATE = {"status": "error", "error_code": "unexpected",
                             "message": f"Неожиданная ошибка: {type(e).__name__}: {str(e)[:150]}"}
            return
        if isinstance(result, dict) and not result.get("ok"):
            _UPDATE_STATE = {"status": "error",
                             "error_code": result.get("error_code", "unknown"),
                             "message": result.get("message", "Неизвестная ошибка обновления")}
            return
        # install_update при успехе делает os._exit(0) — сюда не попадём.

    import threading
    threading.Thread(target=_bg_install, daemon=True, name="update-install").start()
    return jsonify({"ok": True, "status": "started",
                    "message": "Обновление запущено в фоне. Не закрывай TelePush — лаунчер сам перезапустит когда готово."})


@app.route("/api/update/status")
@login_required
def api_update_status():
    """Статус фонового install_update для UI-поллинга."""
    return jsonify(_UPDATE_STATE)


# ── 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()
        ttype = request.form.get("type", "")
        screenshot = request.files.get("screenshot")
    else:
        data = request.json or {}
        module = data.get("module", "")
        subject = data.get("subject", "").strip()
        body = data.get("body", "").strip()
        ttype = data.get("type", "")
        screenshot = None

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

    # Для бага скриншот обязателен и на бэке (не только в JS) — без визуала
    # баг не разобрать. Детект бага: явный type или префикс «[Баг]» в теме.
    if (ttype == "bug" or subject.startswith("[Баг]")) and not screenshot:
        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:
            # НЕ ретраим verify=False: payload содержит license_key+hwid — не шлём по
            # непроверенному TLS (MITM-перехват). Fail closed.
            logger.warning("Ticket SSL verify failed (%s) — не шлю по unverified TLS", ssl_err)
            return jsonify({"ok": False, "error": "Ошибка TLS — проверь время/сеть и повтори"}), 502
        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/spintax/preview", methods=["POST"])
@login_required
def api_spintax_preview():
    """Возвращает N сгенерированных вариантов для текста со спинтаксом.
    Используется кнопкой «Предпросмотр» рядом с полями имя/фамилия/био/текст
    рассылки/ЛС — юзер видит как реально будет выглядеть.
    """
    from app_ui.utils import spintax_expand
    data = request.json or {}
    text = str(data.get("text") or "")
    if not text:
        return jsonify({"variants": [], "has_spintax": False})
    if len(text) > 20000:
        return jsonify({"error": "Текст слишком длинный для предпросмотра"}), 400
    try:
        n = max(1, min(10, int(data.get("count", 5))))
    except Exception:
        n = 5
    # Есть ли вообще { | } в тексте — если нет, отдаём один вариант = сам текст
    has_spintax = "{" in text and "|" in text and "}" in text
    variants = []
    seen = set()
    for _ in range(n * 3):
        v = spintax_expand(text)
        if v not in seen:
            seen.add(v)
            variants.append(v)
            if len(variants) >= n:
                break
    if not variants:
        variants = [text]
    return jsonify({"variants": variants, "has_spintax": has_spintax, "count": len(variants)})


@app.route("/api/paths/info", methods=["GET"])
@login_required
def api_paths_info():
    """Реальные пути где софт хранит данные. Баннер с этими путями в UI убирает
    путаницу когда exe и папка Аккаунты лежат в разных местах."""
    from config import ACCOUNTS_DIR, DATA_DIR
    from utils.state_dir import state_dir, legacy_state_dir
    backup_dir = state_dir()
    legacy = legacy_state_dir()
    # Версия лаунчера из launcher_ver.txt (пишет сам лаунчер при старте).
    # Если файла нет — баннер не показываем (файла нет когда TelePush.exe
    # запускают напрямую, минуя лаунчер).
    _launcher_ver = ""
    try:
        _lv_file = backup_dir / "launcher_ver.txt"
        if _lv_file.exists():
            _launcher_ver = _lv_file.read_text(encoding="utf-8").strip()
    except Exception:
        pass
    # PE VersionInfo из exe не читаем — лаунчер часто наследует версию app от
    # PyInstaller-template вместо своей. Без launcher_ver.txt фронт баннер не
    # показывает (см. showLauncherBannerIfNeeded в app.js).
    return jsonify({
        "accounts_dir": str(ACCOUNTS_DIR),
        "data_dir": str(DATA_DIR),
        "backup_dir": str(backup_dir),
        "accounts_exists": ACCOUNTS_DIR.exists(),
        "backup_exists": backup_dir.exists(),
        "legacy_backup_dir": str(legacy),
        "legacy_backup_exists": legacy.exists(),
        "launcher_version": _launcher_ver,
    })


@app.route("/api/paths/open", methods=["POST"])
@login_required
def api_paths_open():
    """Открывает указанную папку в проводнике Windows. Принимает which=accounts|data|backup."""
    from config import ACCOUNTS_DIR, DATA_DIR
    from utils.state_dir import state_dir
    data = request.json or {}
    which = (data.get("which") or "accounts").strip()
    targets = {
        "accounts": ACCOUNTS_DIR,
        "data": DATA_DIR,
        "backup": state_dir(),
    }
    p = targets.get(which)
    if not p:
        return jsonify({"error": "unknown target"}), 400
    try:
        p.mkdir(parents=True, exist_ok=True)
    except Exception:
        pass
    if not p.exists():
        return jsonify({"error": "Папка не существует и не удалось создать", "path": str(p)}), 500
    # ShellExecuteW возвращает explicit код (>32 = OK, ≤32 = ошибка) — в отличие
    # от os.startfile, который молча игнорил ошибку. Верифицируем код, fallback
    # на subprocess.Popen если ShellExecute недоступен.
    import subprocess as _sp
    path_str = str(p)
    if sys.platform == "win32":
        try:
            import ctypes
            # ShellExecuteW(hwnd=0, lpOperation="open", lpFile=path, lpParameters=NULL, lpDirectory=NULL, nShowCmd=SW_SHOWNORMAL=1)
            rc = ctypes.windll.shell32.ShellExecuteW(0, "open", path_str, None, None, 1)
            if rc and rc > 32:
                logger.info("[paths/open] %s — ShellExecuteW OK (rc=%d)", path_str, rc)
                return jsonify({"ok": True, "path": path_str, "method": "shellexecute"})
            else:
                logger.warning("[paths/open] ShellExecuteW rc=%s — пробую explorer.exe напрямую", rc)
        except Exception as e:
            logger.warning("[paths/open] ShellExecuteW исключение: %s — пробую explorer.exe", e)
    try:
        proc = _sp.Popen(["explorer.exe", path_str], shell=False, creationflags=0x08000000)
        # Explorer часто возвращается мгновенно с rc=1 даже на успех (он
        # просто говорит «уже есть окно»). Поэтому считаем успехом если
        # процесс стартовал без OSError.
        logger.info("[paths/open] %s — explorer.exe PID=%s", path_str, getattr(proc, "pid", "?"))
        return jsonify({"ok": True, "path": path_str, "method": "explorer"})
    except Exception as e:
        logger.warning("[paths/open] explorer.exe не запустился: %s", e)
        return jsonify({
            "error": "Windows не смог открыть Explorer. Проверь что explorer.exe не убит и AV его не блокирует.",
            "path": path_str
        }), 500


@app.route("/api/open_in_browser", methods=["GET"])
@login_required
def api_open_in_browser():
    """Открывает переданный URL в системном браузере (Chrome/Edge): pywebview не
    прокидывает window.open в дефолт-браузер, поэтому используем ShellExecute."""
    url = (request.args.get("url") or "").strip()
    if not url.startswith(("http://localhost", "http://127.0.0.1", "https://localhost", "https://127.0.0.1")):
        return jsonify({"ok": False, "error": "only localhost URLs allowed"}), 400
    if sys.platform != "win32":
        return jsonify({"ok": False, "error": "Windows only"}), 400
    try:
        import ctypes
        rc = ctypes.windll.shell32.ShellExecuteW(0, "open", url, None, None, 1)
        if rc and rc > 32:
            return jsonify({"ok": True})
        return jsonify({"ok": False, "error": f"ShellExecute rc={rc}"}), 500
    except Exception as e:
        return jsonify({"ok": False, "error": str(e)[:200]}), 500


@app.route("/api/system/factory_reset", methods=["POST"])
@login_required
def api_system_factory_reset():
    """Стирает всё: Аккаунты/, data/ (и legacy ~/.telepush/ если осталась).
    После этого софт самозакрывается. С 1.6.0 системные файлы лежат в data/state/,
    отдельная папка ~/.telepush/ — только legacy остатки с 1.5.x.
    """
    from config import ACCOUNTS_DIR, DATA_DIR
    data = request.json or {}
    if not data.get("confirm"):
        return jsonify({"error": "Подтверждение не получено"}), 400
    import shutil as _sh
    deleted = []
    errors = []
    for label, p in [
        ("Аккаунты", ACCOUNTS_DIR),
        ("data", DATA_DIR),
        ("~/.telepush (legacy)", Path.home() / ".telepush"),
    ]:
        if not p.exists():
            continue
        try:
            _sh.rmtree(p, ignore_errors=False)
            deleted.append(label)
        except Exception as e:
            errors.append(f"{label}: {e}")
    # Самозакрытие через 1 сек — чтобы юзер увидел toast
    def _shutdown_after():
        try:
            import time as _t
            _t.sleep(1.0)
            import os as _os
            _os._exit(0)
        except Exception:
            pass
    import threading
    threading.Thread(target=_shutdown_after, daemon=True).start()
    return jsonify({"ok": True, "deleted": deleted, "errors": errors})








# ── Anti-detect / online / DM-target хелперы — вынесено в web/shared/ (распил app.py 2026-06-21).
# Реэкспорт для обратной совместимости: модули делают `from web.app import ...`.
from web.shared.ads import _build_ad_dict, _apply_ad_pre_check, _AD_MODULES, _AD_KEYS  # noqa: E402,F401
from web.shared.online import _start_online_loop, _stop_online_loop  # noqa: E402,F401
from web.shared.dm_targets import (  # noqa: E402,F401
    _own_fleet_dm_keys, _is_own_fleet_target, _filter_dm_targets_own_fleet,
)


# «Расписание работы» удалено: _is_within_working_hours и /api/schedule/*
# больше нет — задачи стартуют по запуску, без часовых ограничений.


# TG-бот уведомления (_notify_telegram) + /api/tg_bot/* + /api/ad_stats вынесено
# в web/modules/ad_config/ (распил монолита 2026-06-10).


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



# ── API: Profile-fill (Заполнение профилей) ── роуты ВЫНЕСЕНЫ в
#    web/modules/profile/routes.py (распил фаза 4, 2026-06-05).
#    Лицензионные /api/profile + /api/profile/reset_hwid остаются выше.


# Чекер аккаунтов (/api/checker/*) вынесен в web/modules/checker/ (распил 2026-06-09).


# cleaner (/api/cleanup/*) вынесено в web/modules/cleaner/ (распил монолита 2026-06-09).


# theme (/api/theme/*) вынесено в web/modules/theme/ (распил монолита 2026-06-09).


# imitation (/api/imitation/*) вынесено в web/modules/imitation/ (распил монолита 2026-06-09).


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











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







from web.shared.results import (  # noqa: E402,F401  (вынесено из app.py)
    _collect_spam_results, _write_export_folder,
)




# history/analytics роуты (/api/history*, /api/analytics*) вынесено в
# web/modules/analytics/ (распил монолита 2026-06-10).


# warmup-роуты вынесено в web/modules/warmup_web/ (распил монолита 2026-06-09).


@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 = {}
        # list(): loop-поток дописывает results по ходу join → без снимка
        # «dictionary changed size during iteration» (SCHED-INFRA-01).
        if j and getattr(j, "results", None):
            results.update(dict(list(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(dict(list(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














# /api/multi/* (мультиаккаунт) вынесено в web/modules/multi/ (распил монолита 2026-06-09).


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

# Гео-флаги по префиксу номера. Module-level чтобы переиспользовать в нескольких
# endpoints (api_accounts, api_warmup_status и др.)




# Роуты /api/accounts (list), /accounts/sources, /accounts/delete_files,
# /accounts/select вынесены в web/modules/account_manager/routes.py
# (распил монолита 2026-06-04). _detect_geo пока остаётся здесь (его
# импортирует модуль) — переедет в helpers.py в финале.






















# Входящий webhook — приём команд start/stop/status от внешнего бота.
# Защита: HMAC-SHA256(body, secret) в заголовке X-TelePush-Signature.
# Секрет хранится в user_settings["webhook_secret"]. Если пуст — endpoint выключен.


@app.route("/api/webhook/in", methods=["POST"])
def api_webhook_in():
    """Входящий webhook. БЕЗ login_required — авторизация через HMAC."""
    import hmac as _hmac
    import hashlib as _hl
    ctx = _get_ctx()
    if not ctx:
        # Без контекста (логин не активен) — отвергаем
        return jsonify({"error": "context not initialized"}), 503
    secret = str(ctx.user_settings.get("webhook_secret", "") or "")
    if not secret:
        return jsonify({"error": "webhook disabled (set webhook_secret in settings)"}), 403
    raw = request.get_data() or b""
    sig = request.headers.get("X-TelePush-Signature", "").strip().lower()
    expected = _hmac.new(secret.encode("utf-8"), raw, _hl.sha256).hexdigest()
    if not _hmac.compare_digest(sig, expected):
        return jsonify({"error": "bad signature"}), 401
    try:
        payload = json.loads(raw.decode("utf-8") or "{}")
    except Exception:
        return jsonify({"error": "bad json"}), 400
    cmd = (payload.get("command") or "").strip().lower()
    if cmd == "status":
        return jsonify({
            "ok": True, "command": "status",
            "accounts": len(ctx.accounts),
            "active": ctx.active,
            "warmup_running": bool(getattr(ctx, "warmup_running", False)),
        })
    if cmd == "stop_warmup":
        try:
            ctx.warmup_stop_flag = True
            logger.info("[webhook] получена команда stop_warmup")
            return jsonify({"ok": True, "command": "stop_warmup"})
        except Exception as e:
            return jsonify({"error": str(e)}), 500
    if cmd == "stop_all":
        # Глобальный стоп: отменяем все активные джобы владельца (нет модуль-уровневого
        # pause_event — стоп идёт по-джобно через _cancel_all_jobs).
        try:
            n = _cancel_all_jobs(ctx)
            logger.warning("[webhook] получена команда stop_all — отменено джоб: %s", n)
            return jsonify({"ok": True, "command": "stop_all", "cancelled": n})
        except Exception as e:
            return jsonify({"error": str(e)}), 500
    return jsonify({"error": f"unknown command: {cmd}", "supported": ["status", "stop_warmup", "stop_all"]}), 400


# QR-логин — альтернатива коду из SMS. ExportLoginToken / AcceptLoginToken.
# Юзер сканирует QR с мобилки в TG → авторизация без SMS.
_qr_sessions: dict = {}  # session_id -> {"client":..., "qr": QRLogin obj, "phone": ...}




















# ── 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 ────────────────────────────────────────────────────────







# Сериализует мутации ctx.monitor_jobs (RMW в _register_monitor_job vs clobber
# `ctx.monitor_jobs = new_pool` в api_monitor_start) — оба на waitress-потоках.
# Без лока last-writer-wins мог выкинуть из пула live-джоб → невидим busy → revoke.
_MONITOR_POOL_LOCK = threading.Lock()


def _register_monitor_job(ctx, job):
    """Регистрирует MonitorJob в пуле ctx.monitor_jobs — единственное место, которое
    видит busy-детекция (_get_account_busy/_any_task_running смотрят ТОЛЬКО пул).

    Заменяет запись ТОГО ЖЕ акка (по acc.phone), не затирая мониторы других акков.
    Ставит status='running' СИНХРОННО (до планирования корутины) — TOCTOU: иначе акк
    подключён ensure_connected'ом пока статус ещё idle → busy=None → окно для 2-й
    MTProto-сессии → revoke. На провале старта _run вернёт status='stopped'.
    ctx.monitor_job — legacy-зеркало для обратной совместимости старых путей."""
    job.status = "running"
    with _MONITOR_POOL_LOCK:
        ctx.monitor_job = job
        _phone = getattr(getattr(job, "acc", None), "phone", None)
        _pool = [j for j in (getattr(ctx, "monitor_jobs", None) or [])
                 if getattr(getattr(j, "acc", None), "phone", None) != _phone]
        _pool.append(job)
        ctx.monitor_jobs = _pool


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.parsing.monitor import MonitorJob, run_monitor_job
    from core.accounts.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 "",
    )
    # P0 (revoke-фикс): джоб ОБЯЗАН попасть в пул ctx.monitor_jobs ДО планирования
    # корутины — обе busy-ветки (_get_account_busy:693 / _any_task_running:850) смотрят
    # ТОЛЬКО пул, legacy-only ctx.monitor_job = невидим = 2-я MTProto-сессия = revoke.
    _register_monitor_job(ctx, job)

    async def _run():
        if not await ensure_connected(_acc, low_risk_module=True):
            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)
    else:
        # Корутина _run НЕ запущена (нет/закрыт event loop) → ensure_connected не
        # вызывался, MTProto-сессия НЕ открывалась. Снимаем фантомный 'running',
        # иначе акк навсегда останется «занят мониторингом» в busy-детекции.
        # Безопасно (не revoke-дыра): сессии нет, просто разблокируем акк.
        job.status = "stopped"
        logger.warning("[monitor] %s: event loop недоступен — рестарт пропущен", reason)










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

def _tg_web_heartbeat(ctx, phone: str):
    """Пингаем что TG-веб для этого акка живой. Каждый /api/tg/* вызов дёргает
    это — _get_account_busy видит акк как «занят TG-веб»."""
    try:
        import time as _t
        _tw = ctx.temp.setdefault("tg_web_open", {})
        _tw[phone] = _t.time()
    except Exception:
        pass


# /api/tg/* (Telegram Web, 72 роута) вынесено в web/modules/tgweb/ (распил монолита 2026-06-09).


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

# вынесено в web/modules/ (распил монолита 2026-06-09).


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

from web.shared.templates_store import (  # noqa: E402,F401  (вынесено из app.py)
    _BUILTIN_TEMPLATES, _CUSTOM_TPL_FILE, _HIDDEN_TPL_FILE, _load_hidden_templates, _save_hidden_templates, _load_custom_templates, _save_custom_templates,
)








# ── Роуты /api/templates* вынесены в web/modules/templates/routes.py ─────────
# (распил монолита, фаза 2, 2026-06-04). Хелперы _BUILTIN_TEMPLATES /
# _load_custom_templates / _load_hidden_templates / _save_* остаются здесь —
# их импортирует модуль templates.




# ── API: Analytics + History ──────────────────────────────────────────────────
# Роуты /api/analytics* + /api/history* и хелпер _aggregate_quota_events вынесены
# в web/modules/analytics/ (распил монолита 2026-06-10).


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

# warmup-роуты вынесено в web/modules/warmup_web/ (распил монолита 2026-06-09).


@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/TP_supports"})
    except Exception:
        return jsonify({"url": "https://t.me/TP_supports"})


# warmup-роуты вынесено в web/modules/warmup_web/ (распил монолита 2026-06-09).


# ── Reauthentication API → вынесено в web/modules/reauth/ (2026-06-05) ──────────
















# ── Settings persistence ──────────────────────────────────────────────────────

from web.shared.settings_store import (  # noqa: E402,F401  (вынесено из app.py)
    _settings_file, _clear_readonly, _legacy_settings_file, _save_user_settings, _load_user_settings,
)

# Cleanup legacy ~/.telepush/user_settings* при импорте: оттуда больше не читаем
# (cross-tenant pollution на shared VDS Windows user'е).
try:
    _legacy_home = Path.home() / ".telepush"
    for _fname in ("user_settings.json", "user_settings.backup.json"):
        try:
            _p = _legacy_home / _fname
            if _p.exists():
                _p.unlink()
        except Exception:
            pass
except Exception:
    pass







# ── API: Settings ────────────────────────────────────────────────────────────

# settings-роуты вынесено в web/modules/settings_web/ (распил монолита 2026-06-09).


# ── Web server start ─────────────────────────────────────────────────────────

def _find_free_port(start: int, attempts: int = 20) -> int:
    """Ищет свободный TCP-порт начиная с start. Если занят — пробует start+1,
    start+2, ... до attempts штук. Возвращает первый свободный.

    SO_REUSEADDR убран: на Windows этот флаг разрешает двум процессам биндить
    один порт одновременно (иначе на shared VDS несколько копий TelePush
    забиндились бы на один порт и браузеры попадали бы в чужой процесс). Bind
    без флагов на Windows уже эксклюзивен по умолчанию."""
    import socket as _sock
    for offset in range(attempts):
        cand = start + offset
        try:
            with _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM) as s:
                # На Windows: SO_EXCLUSIVEADDRUSE = реально эксклюзивный bind.
                # Не критично — bind без REUSEADDR уже эксклюзивен по умолчанию.
                try:
                    if hasattr(_sock, "SO_EXCLUSIVEADDRUSE"):
                        s.setsockopt(_sock.SOL_SOCKET, _sock.SO_EXCLUSIVEADDRUSE, 1)
                except Exception:
                    pass
                s.bind(("127.0.0.1", cand))
                return cand
        except OSError:
            continue
    # Все попытки заняты — возвращаем оригинал, пусть serve() выдаст явную ошибку
    return start


def _reserve_free_port(start: int, attempts: int = 40):
    """Биндит ЭКСКЛЮЗИВНЫЙ (SO_EXCLUSIVEADDRUSE) сокет и ДЕРЖИТ его открытым —
    отдаём в waitress.create_server(sockets=[s]). Возвращает (port, socket).

    Зачем не _find_free_port + serve(port=): waitress по умолчанию ставит на свой
    слушающий сокет SO_REUSEADDR, а на Windows это РАЗРЕШАЕТ двум процессам сесть
    на ОДИН порт одновременно → запросы одной копии TelePush уходят в чужой
    процесс («путается»). Pre-bound эксклюзивный сокет это исключает: вторая копия
    физически не забиндит занятый порт. Резерв держится от проверки до .run(),
    поэтому TOCTOU-гонки (порт схватили между check и bind) тоже нет."""
    import socket as _sock
    for offset in range(attempts):
        cand = start + offset
        s = _sock.socket(_sock.AF_INET, _sock.SOCK_STREAM)
        try:
            if hasattr(_sock, "SO_EXCLUSIVEADDRUSE"):
                try:
                    s.setsockopt(_sock.SOL_SOCKET, _sock.SO_EXCLUSIVEADDRUSE, 1)
                except Exception:
                    pass
            s.bind(("127.0.0.1", cand))
            return cand, s
        except OSError:
            try:
                s.close()
            except Exception:
                pass
            continue
    return start, None


def start_web(port=None):
    """Запускает Flask в daemon-потоке. Возвращает фактически выбранный порт.

    Рандомный стартовый порт в диапазоне 8090-9089 — чтобы копии на shared VDS
    не пересекались. Sticky: при первом старте порт сохраняется в
    state/web_port.txt, при следующих пробуем тот же — URL стабилен между
    апдейтами и старая вкладка после рестарта продолжает работать. Если
    sticky-порт занят — новый рандом и save поверх."""
    import threading, random
    from config import DATA_DIR as _dd
    _port_file = _dd / "state" / "web_port.txt"

    _fixed = os.environ.get("TELEPUSH_FIXED_PORT", "")
    if _fixed:
        try:
            requested = int(_fixed)
        except Exception:
            requested = 8090 + random.randint(0, 999)
    else:
        # Sticky: пробуем тот же что был. Если не было — рандом.
        _saved = None
        try:
            if _port_file.exists():
                _saved = int(_port_file.read_text(encoding="utf-8").strip())
                if not (8090 <= _saved <= 9089):
                    _saved = None
        except Exception:
            _saved = None
        requested = _saved if _saved else (8090 + random.randint(0, 999))
    # Эксклюзивный резерв порта — держим сокет до create_server (см. _reserve_free_port).
    port, _reserved_sock = _reserve_free_port(requested)
    # Техн.инфа про порт → debug.
    if port != requested:
        logger.debug("[web] порт %d занят — переключился на %d", requested, port)
    elif _fixed:
        logger.debug("[web] выбран порт %d (фикс из TELEPUSH_FIXED_PORT)", port)
    else:
        _src = "sticky" if (port == requested and _port_file.exists()) else "рандом 8090-9089"
        logger.debug("[web] выбран порт %d (%s)", port, _src)
    # Запоминаем порт в 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
    # Маркер для лаунчера: куда пинговать в splash-сценарии (если 8090 занят
    # и мы стартанули на 8091 — старый код launcher.py ждал бы вечно).
    try:
        from config import DATA_DIR as _dd
        _state_dir = _dd / "state"
        _state_dir.mkdir(parents=True, exist_ok=True)
        (_state_dir / "web_port.txt").write_text(str(port), encoding="utf-8")
    except Exception as _pe:
        logger.debug("[web] не удалось записать state/web_port.txt: %s", _pe)

    # Монитор-бот: idle polling-поток (сам активируется если юзер включил
    # «отчёты в бот»; иначе спит 5с-тиками без API-вызовов). Переживает рестарт.
    try:
        from core import monitor_bot
        monitor_bot.ensure_started()
    except Exception as _mbe:
        logger.debug("[web] monitor_bot ensure_started: %s", _mbe)

    def _run():
        # werkzeug.threaded накапливал по потоку на каждый запрос → MemoryError
        # при долгой работе с большими списками. waitress с фиксированным
        # thread_pool этого не делает. Шум waitress (WinError 10038) глушится
        # фильтром ниже.
        import click
        click.echo = lambda *a, **kw: None
        logger.info("Веб-интерфейс готов: http://localhost:%d", port)
        try:
            import logging as _wlog
            class _WaitressNoiseFilter(_wlog.Filter):
                def filter(self, rec):
                    m = rec.getMessage()
                    # Глушим Task queue depth — это не ошибка, а инфо.
                    # Реальный фикс — поднять threads (ниже).
                    return not ("10038" in m or "not a socket" in m
                                or "pull_trigger" in m
                                or "Task queue depth" in m)
            _wlog.getLogger("waitress").addFilter(_WaitressNoiseFilter())
            from waitress import create_server
            # threads=16: парсер + параллельные UI-поллы (status, logs,
            # dashboard, broadcast, recent-activity) держат до 10-12 запросов
            # одновременно, с меньшим пулом хвост стоит в очереди.
            # Передаём ЭКСКЛЮЗИВНЫЙ pre-bound сокет (sockets=) — иначе waitress
            # создаёт свой с SO_REUSEADDR и две копии TelePush сядут на один порт.
            if _reserved_sock is not None:
                _srv = create_server(app, sockets=[_reserved_sock], threads=16,
                                     channel_timeout=60, cleanup_interval=30)
            else:
                _srv = create_server(app, host="127.0.0.1", port=port, threads=16,
                                     channel_timeout=60, cleanup_interval=30)
            _srv.run()
        except Exception as _we:
            logger.debug("waitress недоступен (%s) — использую werkzeug (норм для standalone)", _we)
            # werkzeug биндит свой сокет на port — освобождаем резерв, иначе занят нами же.
            try:
                if _reserved_sock is not None:
                    _reserved_sock.close()
            except Exception:
                pass
            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


# ── Модули (Blueprints) ──────────────────────────────────────────────────────
# Распил монолита (2026-06-04). Вынесенные модули регистрируются ЗДЕСЬ, в самом
# низу — после определения всех общих хелперов (login_required/_get_ctx/...),
# чтобы блюпринты могли импортировать их сверху без циркулярного импорта.
# URL не меняются (Blueprint без url_prefix = тот же путь). @app.before_request
# (CSRF/rate-limit) распространяется на роуты блюпринтов автоматически.
from web.modules.account_manager import bp as _account_manager_bp  # noqa: E402
app.register_blueprint(_account_manager_bp)
from web.modules.account_login import bp as _account_login_bp  # noqa: E402
app.register_blueprint(_account_login_bp)
from web.modules.templates import bp as _templates_bp  # noqa: E402
app.register_blueprint(_templates_bp)
from web.modules.proxy import bp as _proxy_bp  # noqa: E402
app.register_blueprint(_proxy_bp)
from web.modules.profile import bp as _profile_fill_bp  # noqa: E402
app.register_blueprint(_profile_fill_bp)
from web.modules.reauth import bp as _reauth_bp  # noqa: E402
app.register_blueprint(_reauth_bp)
from web.modules.spam import bp as _spam_bp  # noqa: E402
app.register_blueprint(_spam_bp)
from web.modules.dm import bp as _dm_bp  # noqa: E402
app.register_blueprint(_dm_bp)
from web.modules.join import bp as _join_bp  # noqa: E402
app.register_blueprint(_join_bp)
from web.modules.invite import bp as _invite_bp  # noqa: E402
app.register_blueprint(_invite_bp)
from web.modules.parser import bp as _parser_bp  # noqa: E402
app.register_blueprint(_parser_bp)
from web.modules.monitor import bp as _monitor_bp  # noqa: E402
app.register_blueprint(_monitor_bp)
from web.modules.checker import bp as _checker_bp  # noqa: E402
app.register_blueprint(_checker_bp)
from web.modules.boost import bp as _boost_bp  # noqa: E402
app.register_blueprint(_boost_bp)
from web.modules.cleaner import bp as _cleaner_bp  # noqa: E402
app.register_blueprint(_cleaner_bp)
from web.modules.theme import bp as _theme_bp  # noqa: E402
app.register_blueprint(_theme_bp)
from web.modules.imitation import bp as _imitation_bp  # noqa: E402
app.register_blueprint(_imitation_bp)
from web.modules.multi import bp as _multi_bp  # noqa: E402
app.register_blueprint(_multi_bp)
from web.modules.warmup_web import bp as _warmup_web_bp  # noqa: E402
app.register_blueprint(_warmup_web_bp)
from web.modules.settings_web import bp as _settings_web_bp  # noqa: E402
app.register_blueprint(_settings_web_bp)
from web.modules.ai_web import bp as _ai_web_bp  # noqa: E402
app.register_blueprint(_ai_web_bp)
from web.modules.neuro_comments import bp as _neuro_comments_bp  # noqa: E402
app.register_blueprint(_neuro_comments_bp)
from web.modules.chatter import bp as _chatter_bp  # noqa: E402
app.register_blueprint(_chatter_bp)
from web.modules.tgweb import bp as _tgweb_bp  # noqa: E402
app.register_blueprint(_tgweb_bp)
from web.modules.ad_config import bp as _ad_config_bp  # noqa: E402
app.register_blueprint(_ad_config_bp)
from web.modules.analytics import bp as _analytics_bp  # noqa: E402
app.register_blueprint(_analytics_bp)
from web.modules.stories import bp as _stories_bp  # noqa: E402
app.register_blueprint(_stories_bp)
