from __future__ import annotations
import ast
import html as _html
import json
import logging
import os
import platform
import random
import re
import string
import shutil
import subprocess
import sys
import threading
import time
from collections import deque, Counter
from datetime import datetime, timezone, timedelta
from typing import TYPE_CHECKING, Any, Optional
from urllib.parse import urlsplit, urlunsplit


def _ensure_deps():
    try:
        import requests  # noqa: F401
    except ImportError:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", "requests"])


_ensure_deps()

import requests
import telebot
try:
    from telebot.handler_backends import ContinueHandling
except Exception:
    ContinueHandling = None
from telebot.types import InlineKeyboardMarkup as K, InlineKeyboardButton as _TgButton, WebAppInfo


_DEGRADE = {"on": False}      # станет True, если Telegram отверг цвета/эмодзи: дальше всё без украшений


class B(_TgButton):
    """Inline-кнопка с цветом (style: primary — синий по умолчанию, success — зелёный, danger — красный).
    Цвет — возможность Telegram; на старых клиентах кнопка просто серая."""

    def __init__(self, text, *args, style=None, **kwargs):
        super().__init__(text, *args, **kwargs)
        self.style = style

    def to_dict(self):
        d = super().to_dict()
        if SETTINGS.get("colored_buttons", True) and not _DEGRADE["on"]:
            d["style"] = self.style or "primary"
        return d


def _plain_kb(kb):
    """Копия клавиатуры без цветов — запасной вариант, если Telegram отклонил разметку."""
    if kb is None:
        return None
    new = K()
    for row in kb.keyboard:
        new.row(*[_TgButton(b.text, url=b.url, callback_data=b.callback_data, web_app=b.web_app) for b in row])
    return new


def _sw(key):
    """Цвет переключателя: включён — зелёный, выключен — обычный."""
    return "success" if SETTINGS.get(key) else None
from tg_bot import CBT
from FunPayAPI.updater.events import NewOrderEvent, NewMessageEvent
from FunPayAPI.common.enums import MessageTypes, SubCategoryTypes
from FunPayAPI.types import LotFields

if TYPE_CHECKING:
    from cardinal import Cardinal

NAME = "TDUgameResell"
VERSION = "2.10.1"
DESCRIPTION = ("Продажа аренды Steam-аккаунтов с TDUgame на FunPay. Сам создаёт лоты, выдаёт аккаунты после оплаты, "
               "скрывает лоты без наличия, ведёт журнал ошибок и обновляется сам. Всё управление: кнопка «Мини-приложение» ниже.")
CREDITS = "TDUgame"
UUID = "7c1f2e6a-4b9d-4c3a-8e51-2f6a9d0c7b4e"
SETTINGS_PAGE = True

logger = logging.getLogger("FPC.tdugame_resell")
LP = "[TDUgameResell]"

API_BASE = "https://tdugame.ru/api/v1"
TIMEOUT = 25
AUTOCREATE_BLOCKED_PRODUCT_IDS = {4, 275}
AUTOCREATE_BLOCKED_NAME_RE = re.compile(r"\b(atomic\s*heart|schedule\s*i)\b", re.I)
LOT_SYNC_BATCH_SIZE = 25


def _api_err_code(value, status_code=None) -> str:
    if value is None:
        return "connection_error" if status_code is None else f"http_{status_code}"
    if isinstance(value, dict):
        raw = value.get("error") or value.get("detail") or value.get("code")
        if isinstance(raw, (dict, list)):
            return _api_err_code(raw, status_code)
        if raw:
            return str(raw)
        msg = value.get("message") or value.get("msg")
        if msg:
            m = re.search(r"\b(no_stock|insufficient_balance|invalid_api_key|rate_limited|not_found|account_disabled)\b",
                          str(msg), re.I)
            if m:
                return m.group(1).lower()
        return f"http_{status_code}" if status_code else str(value)
    if isinstance(value, list):
        return str(value[0]) if value else (f"http_{status_code}" if status_code else "unknown")
    text = str(value).strip()
    if not text:
        return f"http_{status_code}" if status_code else "unknown"
    for parser in (json.loads, ast.literal_eval):
        try:
            parsed = parser(text)
            if parsed is not value:
                return _api_err_code(parsed, status_code)
        except Exception:
            pass
    m = re.search(r"\b(no_stock|insufficient_balance|invalid_api_key|rate_limited|not_found|account_disabled)\b",
                  text, re.I)
    if m:
        return m.group(1).lower()
    return text

PLUGIN_DIR = "storage/plugins/tdugame_resell"
os.makedirs(PLUGIN_DIR, exist_ok=True)




SETTINGS_FILE = os.path.join(PLUGIN_DIR, "settings.json")
MAPPINGS_FILE = os.path.join(PLUGIN_DIR, "mappings.json")
RENTALS_FILE = os.path.join(PLUGIN_DIR, "rentals.json")
HIDDEN_FILE = os.path.join(PLUGIN_DIR, "hidden.json")
HANDLED_FILE = os.path.join(PLUGIN_DIR, "handled_orders.json")
PENDING_QTY_FILE = os.path.join(PLUGIN_DIR, "pending_qty_orders.json")
STATS_FILE = os.path.join(PLUGIN_DIR, "stats.json")
CREATE_LOG_FILE = os.path.join(PLUGIN_DIR, "create_log.json")

ORDER_URL = "https://funpay.com/orders/{order_id}/"

DEFAULT_TEXTS = {
    "text_delivery": (
        "💜 Аренда аккаунта\n"
        "━━━━━━━━━━━━━━\n"
        "Готово, аренда активирована.\n\n"
        "🎮 Игра: {game}\n"
        "👤 Логин: {login}\n"
        "🔑 Пароль: {password}\n\n"
        "⏳ Время аренды: {hours} ч.\n"
        "🕒 До: {expires}\n\n"
        "🔐 Steam Guard: отправьте в чат\n"
        "!кодстим {login}\n\n"
        "Если нужно продлить этот аккаунт после новой оплаты:\n"
        "!продлить {login}\n\n"
        "Приятной игры."
    ),
    "text_extension": (
        "💜 Аренда аккаунта\n"
        "━━━━━━━━━━━━━━\n"
        "Аренда продлена.\n\n"
        "🎮 Игра: {game}\n"
        "👤 Аккаунт: {login}\n"
        "⏳ Добавлено: {hours} ч.\n"
        "🕒 Новое окончание: {expires}\n\n"
        "Steam Guard при необходимости: !кодстим {login}"
    ),
    "text_friend_activated": (
        "💜 Режим «для друга» включён\n"
        "━━━━━━━━━━━━━━\n"
        "Следующая оплата в течение {minutes} мин. выдаст новый отдельный аккаунт, а не продлит текущий."
    ),
    "text_friend_already": (
        "💜 Режим «для друга» уже активен.\n"
        "Следующая оплата выдаст отдельный аккаунт."
    ),
    "text_code": (
        "💜 Steam Guard\n"
        "━━━━━━━━━━━━━━\n"
        "👤 {login}\n"
        "🔐 Код: {code}\n"
        "⏱ Действует примерно {ttl} сек."
    ),
    "text_code_ask_login": (
        "💜 У вас несколько активных аккаунтов.\n"
        "Напишите команду с нужным логином:\n"
        "!кодстим логин\n\n"
        "Доступные логины:\n{logins}"
    ),
    "text_code_not_found": (
        "💜 Активный аккаунт не найден.\n"
        "Если вы только что оплатили заказ, подождите минуту и попробуйте снова: !кодстим логин"
    ),
    "text_ask_which_account": (
        "💜 Для {game} найдено несколько аккаунтов.\n"
        "Какой аккаунт продлить после оплаты?\n\n"
        "Напишите:\n"
        "!продлить логин\n\n"
        "Доступные логины:\n{logins}"
    ),
    "text_extend_applied": (
        "💜 Продление выбрано\n"
        "━━━━━━━━━━━━━━\n"
        "👤 Аккаунт: {login}\n"
        "⏳ Добавлено: {hours} ч.\n"
        "🕒 До: {expires}"
    ),
    "text_extend_no_pending": (
        "💜 Сейчас нет ожидающего выбора аккаунта.\n"
        "Если бот попросил выбрать аккаунт для продления, отправьте:\n"
        "!продлить логин"
    ),
    "text_review_bonus": (
        "💜 Спасибо за отзыв!\n"
        "━━━━━━━━━━━━━━\n"
        "Мы добавили бонус к аренде: {hours} ч.\n"
        "🕒 Новое окончание: {expires}"
    ),
    "text_partial": (
        "💜 Выдача выполнена частично\n"
        "━━━━━━━━━━━━━━\n"
        "Выдано аккаунтов: {delivered} из {ordered}.\n\n"
        "Остальные аккаунты временно недоступны. Продавец уже уведомлён и довыдаст их в ближайшее время."
    ),
    "text_qty_choice": (
        "💜 Аренда аккаунта\n"
        "━━━━━━━━━━━━━━\n"
        "Вы купили {qty} шт. лота «{game}».\n\n"
        "Как удобнее выдать аренду?\n\n"
        "Да — {qty} отдельных аккаунта по {hours_unit} ч.\n"
        "Нет — один аккаунт на {total_hours} ч.\n\n"
        "Ответьте одним словом: Да или Нет."
    ),
    "text_qty_timeout_admin": (
        "Покупатель не ответил, как обработать заказ с qty={qty}. "
        "TDUgame не списывался, выдача остановлена. Проверьте заказ вручную."
    ),
    "text_problem": (
        "💜 Аренда аккаунта\n"
        "━━━━━━━━━━━━━━\n"
        "Автоматическая выдача временно не сработала.\n\n"
        "Продавец уже получил уведомление и скоро поможет с заказом."
    ),
    "text_rental_ended": (
        "💜 Аренда завершена\n"
        "━━━━━━━━━━━━━━\n"
        "Игра: {game}\n\n"
        "Спасибо, что выбрали нас. Если всё прошло хорошо, будем благодарны за отзыв — "
        "это помогает держать сервис быстрым и цены приятными."
    ),
}

DEFAULT_SETTINGS = {
    "enabled": True,
    "api_key": "",
    "currency": "RUB",
    "lot_sync_enabled": True,
    "auto_hide_no_stock": True,
    "auto_hide_zero_balance": True,
    "auto_refund": False,
    "price_guard_enabled": True,
    "price_guard_margin_rub": 0,
    "multi_qty_confirm_enabled": True,
    "multi_qty_confirm_ttl_min": 30,
    "review_bonus_enabled": False,
    "notify_sales": False,
    "friend_minutes": 10,
    "review_bonus_hours": 3,
    "review_bonus_min_stars": 5,
    "balance_threshold": 0,
    "notify_rental_end": True,
    "tz_offset_hours": 3,
    "poll_sec": 120,
    "extend_cooldown": 30,
    "texts": {},
    "ac_usd_rub": 95.0,
    "ac_subcat_cap": 15,
    "ac_min_price": 2,
    "product_markup_percent": {},
    "ac_template": None,
    "auto_new_games": False,
    "custom_emoji": False,
    "colored_buttons": True,
    "ac_excluded_ids": [],
    "ac_known_ids": None,
    "webapp_port": 0,
    "webapp_url": "",
    "webapp_mode": "",
}


def _strip_public_contacts(text: str) -> str:
    return text.strip() if isinstance(text, str) else text






MAP_FILE = os.path.join(PLUGIN_DIR, "funpay_map.json")
MAP_OVERRIDES_FILE = os.path.join(PLUGIN_DIR, "funpay_map_overrides.json")
MISC_GAMES_FILE = os.path.join(PLUGIN_DIR, "misc_funpay_games.json")
NAME_OVERRIDES_FILE = os.path.join(PLUGIN_DIR, "name_overrides.json")
PH_TIME, PH_GAME = "RENTTIME", "RENTGAME"


def _default_ac_template() -> dict:
    """Шаблон автолотов «из коробки»: полное меню создания лотов доступно сразу
    после ввода API-ключа, а мастер («🧙 Мастер настройки») нужен только для тонкой настройки."""
    return {
        "durations": [1, 24, 72, 168],
        "summary_ru": f"🟩 АВТОВЫДАЧА {PH_GAME} STEAM {PH_TIME}",
        "summary_en": f"🟩 AUTO DELIVERY {PH_GAME} STEAM {PH_TIME}",
        "desc_ru": (
            f"🎮 Аренда аккаунта {PH_GAME} на {PH_TIME}.\n"
            "⚡ Логин и пароль выдаются автоматически сразу после оплаты.\n"
            "🔐 Steam Guard: напишите в чат !кодстим логин\n"
            "🔁 Продлить: оплатите этот же лот ещё раз — время добавится к вашему аккаунту.\n"
            "👥 Аккаунт для друга: перед оплатой напишите в чат !друг"
        ),
        "desc_en": (
            f"Rent a {PH_GAME} Steam account for {PH_TIME}.\n"
            "Instant automatic delivery of login and password after payment.\n"
            "Steam Guard code: send !code login in chat.\n"
            "To extend: buy this lot again."
        ),
        "price_mode": "markup",
        "markup_percent": 20,
        "fixed_public_rub": None,
        "fixed_prices": {},
        "usd_rub": 95,
        "min_price": 2,
    }
MIN_RENT_HOURS = 1
AC_DEBUG = False
AC_CREATE_DELAY = 2.5
AC_DELETE_DELAY = 0.4
FP_SUMMARY_LIMIT = 70
FP_SUMMARY_RETRY_LIMIT = 60
FP_LIMITS_HINT = (
    "Лимиты FunPay: краткое описание EN — до ~70 символов; "
    "на подкатегорию обычно ~8–10 лотов (зависит от аккаунта). "
    "Дублирующие лоты в одном разделе запрещены."
)
DURATION_PRESETS = {
    "short": [3, 10, 24],
    "standard": [1, 24, 72, 168],
    "full": [3, 24, 72, 168, 360, 720],
}

SETTINGS: dict = {}
MAPPINGS: list = []
RENTALS: dict = {}
HIDDEN: dict = {}
HANDLED: list = []
PENDING_QTY: dict = {}
STATS: dict = {}

cardinal_instance: "Cardinal | None" = None
tg = None
bot: "telebot.TeleBot | None" = None

_lock = threading.Lock()
_poll_stop = threading.Event()
_poll_thread: "threading.Thread | None" = None

_friend: dict = {}           # buyer_key -> activated_at (ts)
_pending_ext: dict = {}      # buyer_key -> {hours, order_id, product_id, allowed_logins, ts}
_extend_intent: dict = {}    # buyer_key -> {login, ts}; explicit next-order extension
_ext_last: dict = {}         # buyer_key -> ts (cooldown)
_processed_msgs: set = set()
_tg_processed_msg_ids: set = set()
_processed_review: set = set()
_admin_alert_dedup: dict = {}
_balance_alerted = {"low": False}
_tg_state_shadow: dict = {}
_fp_cmd_dedup_lock = threading.Lock()
_FP_CMD_DEDUP_TTL = 15
_FP_CMD_DEDUP_FILE = os.path.join(os.getenv("TMPDIR") or "/tmp", "tdugame_resell_cmd_dedup.json")

_map_cache: dict = {"data": None, "ts": 0}
_misc_cache: dict = {}
_ac_wizard: dict = {}
_ac_plan: dict = {}          # user_id -> {"items": [...]}
_ac_running: dict = {"on": False}
_ac_lock = threading.Lock()


_ac_cancel = threading.Event()   # «⛔ Остановить» в сообщении с прогрессом


# Лимит FunPay: не больше 500 новых лотов за 24 часа на аккаунт. Считаем скользящее окно по
# времени каждого успешного создания и храним на диске, чтобы перезапуск бота не обнулял счётчик.
CREATE_LIMIT_24H = 500
CREATE_WINDOW_SEC = 24 * 3600
_create_log: list = []
_create_log_lock = threading.Lock()


def _prune_create_log() -> None:
    cutoff = time.time() - CREATE_WINDOW_SEC
    _create_log[:] = [t for t in _create_log if t > cutoff]


def _load_create_log() -> None:
    raw = _load_json(CREATE_LOG_FILE, [])
    with _create_log_lock:
        _create_log[:] = [float(t) for t in raw if isinstance(t, (int, float))] if isinstance(raw, list) else []
        _prune_create_log()


def _record_creation() -> None:
    with _create_log_lock:
        _create_log.append(time.time())
        _prune_create_log()
        _save_json(CREATE_LOG_FILE, _create_log)


def _creations_24h() -> int:
    with _create_log_lock:
        _prune_create_log()
        return len(_create_log)


def _creation_quota_left() -> int:
    return max(0, CREATE_LIMIT_24H - _creations_24h())


def _quota_reset_in() -> float:
    """Через сколько секунд освободится место под следующий лот (0, если уже есть)."""
    with _create_log_lock:
        _prune_create_log()
        if len(_create_log) < CREATE_LIMIT_24H:
            return 0.0
        oldest_needed = sorted(_create_log)[len(_create_log) - CREATE_LIMIT_24H]
        return max(0.0, oldest_needed + CREATE_WINDOW_SEC - time.time())


def _quota_next_slot_in() -> float:
    """Через сколько секунд начнут освобождаться места (самое старое создание выйдет из окна 24 ч)."""
    with _create_log_lock:
        _prune_create_log()
        if not _create_log:
            return 0.0
        return max(0.0, min(_create_log) + CREATE_WINDOW_SEC - time.time())


def _ac_begin(title: str) -> bool:
    with _ac_lock:
        if _ac_running.get("on"):
            return False
        _ac_cancel.clear()
        _ac_running.clear()
        _ac_running.update({"on": True, "title": title, "done": 0, "total": 0,
                            "created": 0, "skipped": 0, "failed": 0, "started": None})
        return True


_autoraise_pause_lock = threading.Lock()


def _pause_cardinal_autoraise(reason: str = ""):
    """auto_steam.py (гифты/рокстар/майнкрафт/кошелёк) тоже приостанавливает
    autoRaise на время СВОИХ синков, независимо от этого плагина, и оба плагина
    могут работать одновременно - без общего счётчика второй параллельный синк
    запоминал бы уже "0" (выставленный первым) как "исходное" значение и при
    завершении сам восстанавливал бы "0", навсегда выключая автоподнятие (см.
    разбор гонки по логам - так и произошло 17.09). Счётчик вложенности храним
    прямо на живом объекте cardinal_instance - он общий для всех плагинов,
    поэтому auto_steam.py и этот плагин координируются через один счётчик."""
    try:
        cfg = getattr(cardinal_instance, "MAIN_CFG", None)
        if not cfg or "FunPay" not in cfg:
            return None
        with _autoraise_pause_lock:
            depth = getattr(cardinal_instance, "_autoraise_pause_depth", 0)
            if depth == 0:
                cardinal_instance._autoraise_pause_original = cfg["FunPay"].get("autoRaise", fallback="0")
            cardinal_instance._autoraise_pause_depth = depth + 1
            cfg["FunPay"]["autoRaise"] = "0"
            logger.info(f"{LP} Cardinal autoRaise paused for TDUgameResell task ({reason}), "
                        f"depth={cardinal_instance._autoraise_pause_depth}")
        return True
    except Exception as e:
        logger.debug(f"{LP} could not pause Cardinal autoRaise: {e}")
        return None


def _restore_cardinal_autoraise(previous):
    if previous is None:
        return
    try:
        cfg = getattr(cardinal_instance, "MAIN_CFG", None)
        if not cfg or "FunPay" not in cfg:
            return
        with _autoraise_pause_lock:
            depth = max(0, getattr(cardinal_instance, "_autoraise_pause_depth", 1) - 1)
            cardinal_instance._autoraise_pause_depth = depth
            if depth > 0:
                logger.info(f"{LP} Cardinal autoRaise остаётся приостановленным "
                            f"(ещё {depth} синк(ов) не завершены)")
                return
            original = getattr(cardinal_instance, "_autoraise_pause_original", None)
            if original is not None:
                cfg["FunPay"]["autoRaise"] = str(original)
            cardinal_instance._autoraise_pause_original = None
            if str(original).strip().lower() in ("1", "true", "yes", "on"):
                logger.info(f"{LP} Cardinal autoRaise restored after TDUgameResell task")
    except Exception as e:
        logger.debug(f"{LP} could not restore Cardinal autoRaise: {e}")
_commission_cache: dict = {}  # subcat_id -> float
_products_cache: dict = {"data": None, "ts": 0, "err": False}   # кэш TDUgame /rental/products
_pub_lots_cache: dict = {}    # subcat_id -> {"ts": ts, "lots": [(price, desc, server), ...]}
_price_cache: dict = {}       # (product_id, hours) -> {"ts": ts, "rub": float}
_node_form_cache: dict = {}   # subcat_id -> {"ts": ts, "form": {...}}
AC_CACHE_TTL = 900           # 15 минут


def _fp_cmd_norm(text: str) -> str:
    return re.sub(r"\s+", " ", (text or "").strip().lower())[:160]


def _fp_cmd_seen(chat_id, text: str) -> bool:
    now = time.time()
    key = f"{chat_id}:{_fp_cmd_norm(text)}"

    try:
        import fcntl
        os.makedirs(os.path.dirname(_FP_CMD_DEDUP_FILE), exist_ok=True)
        try:
            fd = os.open(_FP_CMD_DEDUP_FILE, os.O_RDWR)
        except FileNotFoundError:
            fd = os.open(_FP_CMD_DEDUP_FILE, os.O_RDWR | os.O_CREAT, 0o666)
        try:
            os.chmod(_FP_CMD_DEDUP_FILE, 0o666)
        except Exception:
            pass
        with os.fdopen(fd, "r+", encoding="utf-8") as fh:
            fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
            fh.seek(0)
            try:
                data = json.load(fh) or {}
            except Exception:
                data = {}
            data = {str(k): float(v) for k, v in data.items() if now - float(v) <= _FP_CMD_DEDUP_TTL}
            if key in data:
                return True
            data[key] = now
            fh.seek(0)
            fh.truncate()
            json.dump(data, fh, ensure_ascii=False)
            return False
    except Exception:
        dkey = f"{key}:{int(now) // _FP_CMD_DEDUP_TTL}"
        with _fp_cmd_dedup_lock:
            if dkey in _processed_msgs:
                return True
            _processed_msgs.add(dkey)
            if len(_processed_msgs) > 500:
                _processed_msgs.clear()
        return False


def _load_json(path, default):
    if os.path.exists(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return default


def _save_json(path, data):
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
    except Exception as e:
        logger.error(f"{LP} save {path} failed: {e}")


def _autolot_block_reason(*items) -> str:
    parts = []
    for item in items:
        if not item:
            continue
        if isinstance(item, dict):
            pid = item.get("product_id") or item.get("id")
            if str(pid).isdigit() and int(pid) in AUTOCREATE_BLOCKED_PRODUCT_IDS:
                return f"product_id={pid}"
            for key in ("product_name", "game_name", "title_keyword", "name"):
                val = item.get(key)
                if val:
                    parts.append(str(val))
        else:
            parts.append(str(item))
    text = " ".join(parts)
    match = AUTOCREATE_BLOCKED_NAME_RE.search(text)
    return match.group(1) if match else ""


def _excluded_ids() -> set:
    out = set()
    for x in SETTINGS.get("ac_excluded_ids") or []:
        try:
            out.add(int(x))
        except (TypeError, ValueError):
            continue
    return out


def _is_excluded(pid) -> bool:
    try:
        return int(pid) in _excluded_ids()
    except (TypeError, ValueError):
        return False


def load_all():
    global SETTINGS, MAPPINGS, RENTALS, HIDDEN, HANDLED, PENDING_QTY, STATS
    raw_settings = _load_json(SETTINGS_FILE, {})
    raw_settings = raw_settings if isinstance(raw_settings, dict) else {}
    saved_texts = raw_settings.get("texts")
    SETTINGS = {**DEFAULT_SETTINGS, **raw_settings}
    SETTINGS["texts"] = {**DEFAULT_TEXTS, **(saved_texts if isinstance(saved_texts, dict) else {})}
    tpl = SETTINGS.get("ac_template")
    if not (isinstance(tpl, dict) and tpl.get("summary_ru") and tpl.get("durations")):
        SETTINGS["ac_template"] = _default_ac_template()
        _save_json(SETTINGS_FILE, SETTINGS)
    mp = _load_json(MAPPINGS_FILE, [])
    MAPPINGS = mp if isinstance(mp, list) else []
    changed = False
    before_count = len(MAPPINGS)
    MAPPINGS = [m for m in MAPPINGS if not (isinstance(m, dict) and _autolot_block_reason(m))]
    if len(MAPPINGS) != before_count:
        changed = True
        logger.info(f"{LP} Убрано запрещённых привязок автолотов: {before_count - len(MAPPINGS)}")
    for m in MAPPINGS:
        if isinstance(m, dict) and not m.get("rent_code") and m.get("product_id") is not None and m.get("hours") is not None:
            m["rent_code"] = _make_rent_code(m.get("product_id"), m.get("hours"))
            changed = True
    RENTALS = _load_json(RENTALS_FILE, {})
    HIDDEN = _load_json(HIDDEN_FILE, {})
    h = _load_json(HANDLED_FILE, [])
    HANDLED = h if isinstance(h, list) else []
    _load_create_log()
    pq = _load_json(PENDING_QTY_FILE, {})
    PENDING_QTY = pq if isinstance(pq, dict) else {}
    STATS = _normalize_stats(_load_json(STATS_FILE, {}))
    known = {str(m.get("lot_id")) for m in MAPPINGS if m.get("lot_id")}
    orphans = [lid for lid in HIDDEN if lid not in known]
    if orphans:
        for lid in orphans:
            HIDDEN.pop(lid, None)
        save_hidden()
        logger.info(f"{LP} Очищено осиротевших скрытых лотов: {len(orphans)}")
    if changed:
        save_mappings()
    logger.info(f"{LP} Loaded: mappings={len(MAPPINGS)}, rentals_buyers={len(RENTALS)}")


def save_settings():
    _save_json(SETTINGS_FILE, SETTINGS)


def save_mappings():
    _save_json(MAPPINGS_FILE, MAPPINGS)


def save_rentals():
    _save_json(RENTALS_FILE, RENTALS)


def save_hidden():
    _save_json(HIDDEN_FILE, HIDDEN)


def save_handled():
    _save_json(HANDLED_FILE, HANDLED[-500:])


# ============================================================================
#  ЖУРНАЛ СОБЫТИЙ: подробные причины, почему лот не создался / товар не выдался / лот скрыт
# ============================================================================
EVENTS_FILE = os.path.join(PLUGIN_DIR, "events.json")
EVENTS_MAX = 600
_EVENTS: list = []
_EVENTS_LOCK = threading.Lock()
_EVENTS_STATE = {"loaded": False, "saved_at": 0.0, "dirty": False}

# (шаблон в тексте причины, что делать)
_ADVICE = [
    (r"лимит создания|429|too many|кулдаун|rate.?limit",
     "FunPay временно ограничил создание/изменение лотов. Подождите 10–30 минут: плагин продолжит сам, повторять вручную не нужно."),
    (r"no_stock|нет свободных аккаунтов",
     "На TDUgame закончились свободные аккаунты этой игры. Лот скрывается автоматически и вернётся, когда аккаунты появятся. Если включён авто-возврат, покупателю вернут деньги."),
    (r"insufficient_balance|недостаточно средств",
     "Пополните баланс TDUgame: пока он на нуле, выдача и продление не работают."),
    (r"invalid_duration|вне допустимого|слишком корот|duration_too_short|меньше минимума",
     "Срок этого лота меньше минимального срока аренды игры на TDUgame. Лот скрыт автоматически. Удалите его или создайте лот с более длинным сроком."),
    (r"invalid_api_key|api.?ключ|api key",
     "Проверьте API-ключ в «Настройки → API ключ» и убедитесь, что он не отозван."),
    (r"connection_error|нет связи|timeout|timed out|http_5\d\d|catalog_unavailable|временно недоступен",
     "TDUgame или интернет-соединение временно недоступны. Если ошибка повторяется, проверьте связь сервера с tdugame.ru; выдача повторится при следующем заказе."),
    (r"не привязан|привязк",
     "Привяжите лот к игре во вкладке «Привязки» или создайте лот через «Создание лотов»."),
    (r"защита от минусов|минусов[ао]й цен",
     "Цена лота ниже себестоимости на TDUgame. Пересчитайте цены: «Создание лотов → Пересчитать цены»."),
    (r"credentials|пароль",
     "Аренда создана, но TDUgame не вернул пароль. Откройте аренду на tdugame.ru или напишите в поддержку TDUgame, покупателю выдайте данные вручную."),
    (r"не найден в списке|задержк",
     "FunPay показал лот с задержкой. Обычно он появляется через несколько минут: нажмите «Привязки → Обновить» или повторите создание."),
    (r"лимит раздела|лотов на игру",
     "В разделе игры на FunPay уже максимум лотов. Удалите лишние лоты этой игры или уменьшите количество сроков."),
    (r"заполните|поле",
     "FunPay отклонил форму лота: не заполнено обязательное поле. Проверьте шаблон лота в «Создание лотов → Шаблон»."),
]


def _advice_for(*texts) -> str:
    blob = " ".join(str(t or "") for t in texts).lower()
    for pat, adv in _ADVICE:
        if re.search(pat, blob, re.I):
            return adv
    return ""


def _events_load():
    if _EVENTS_STATE["loaded"]:
        return
    data = _load_json(EVENTS_FILE, [])
    _EVENTS[:] = data if isinstance(data, list) else []
    _EVENTS_STATE["loaded"] = True


def _events_flush(force: bool = False):
    now = time.time()
    if not force and now - _EVENTS_STATE["saved_at"] < 3.0:
        _EVENTS_STATE["dirty"] = True
        return
    _EVENTS_STATE["saved_at"] = now
    _EVENTS_STATE["dirty"] = False
    try:
        os.makedirs(os.path.dirname(EVENTS_FILE), exist_ok=True)
        _save_json(EVENTS_FILE, _EVENTS)
    except Exception:
        pass


def _evt(kind: str, level: str, title: str, detail: str = "", advice: "str | None" = None, **ctx):
    """kind: lot_create / delivery / extend / stock / balance / system; level: error / warn / info / ok."""
    try:
        with _EVENTS_LOCK:
            _events_load()
            if advice is None:
                advice = _advice_for(title, detail, ctx.get("raw"))
            rec = {"ts": int(time.time()), "kind": kind, "level": level, "title": str(title)[:200],
                   "detail": str(detail or "")[:600], "advice": advice or ""}
            for k, v in ctx.items():
                if v not in (None, ""):
                    rec[k] = v if isinstance(v, (int, float, bool)) else str(v)[:300]
            _EVENTS.append(rec)
            if len(_EVENTS) > EVENTS_MAX:
                del _EVENTS[: len(_EVENTS) - EVENTS_MAX]
            _events_flush(force=level in ("error", "warn"))
    except Exception as e:
        logger.debug(f"{LP} events: {e}")


def _events_list(limit: int = 200, level: str = "", kind: str = "") -> list:
    with _EVENTS_LOCK:
        _events_load()
        if _EVENTS_STATE["dirty"]:
            _events_flush(force=True)
        out = [e for e in reversed(_EVENTS)
               if (not level or e.get("level") == level) and (not kind or e.get("kind") == kind)]
    return out[:limit]


def _events_counts() -> dict:
    with _EVENTS_LOCK:
        _events_load()
        day = time.time() - 86400
        errs = sum(1 for e in _EVENTS if e.get("level") == "error" and e.get("ts", 0) >= day)
        warns = sum(1 for e in _EVENTS if e.get("level") == "warn" and e.get("ts", 0) >= day)
        return {"errors24h": errs, "warns24h": warns, "total": len(_EVENTS)}



def save_pending_qty():
    _save_json(PENDING_QTY_FILE, PENDING_QTY)


def save_stats():
    _save_json(STATS_FILE, STATS)


def _empty_stats() -> dict:
    return {
        "orders_seen": {},
        "orders_total": 0,
        "rentals_total": 0,
        "extensions_total": 0,
        "review_bonuses_total": 0,
        "hours_total": 0.0,
        "funpay_earned_rub": 0.0,
        "spent_rub_total": 0.0,
        "by_product": {},
    }


def _normalize_stats(raw) -> dict:
    st = _empty_stats()
    if isinstance(raw, dict):
        for key in st:
            if key in raw:
                st[key] = raw[key]
    if not isinstance(st.get("orders_seen"), dict):
        st["orders_seen"] = {}
    if not isinstance(st.get("by_product"), dict):
        st["by_product"] = {}
    return st


def _stats_product(product_id, name: str) -> dict:
    key = str(product_id or "unknown")
    item = STATS.setdefault("by_product", {}).setdefault(key, {
        "name": name or "аккаунт",
        "orders": 0,
        "rentals": 0,
        "extensions": 0,
        "hours": 0.0,
        "earned_rub": 0.0,
        "spent_rub": 0.0,
    })
    if name:
        item["name"] = name
    return item


def _stats_add_order(order, product_id=None, product_name: str = "") -> None:
    oid = str(getattr(order, "id", "") or "")
    paid = _order_paid_rub(order)
    if not oid or paid is None:
        return
    seen = STATS.setdefault("orders_seen", {})
    if oid in seen:
        return
    seen[oid] = paid
    if len(seen) > 2000:
        for old in list(seen)[:len(seen) - 2000]:
            seen.pop(old, None)
    STATS["orders_total"] = int(STATS.get("orders_total", 0) or 0) + 1
    STATS["funpay_earned_rub"] = float(STATS.get("funpay_earned_rub", 0) or 0) + paid
    prod = _stats_product(product_id, product_name)
    prod["orders"] = int(prod.get("orders", 0) or 0) + 1
    prod["earned_rub"] = float(prod.get("earned_rub", 0) or 0) + paid
    save_stats()


def _stats_add_spent(product_id, product_name: str, hours: float, cost_rub: "float | None",
                      kind: str = "rental") -> None:
    hours = float(hours or 0)
    cost = float(cost_rub or 0)
    if kind == "extension":
        STATS["extensions_total"] = int(STATS.get("extensions_total", 0) or 0) + 1
    elif kind == "review_bonus":
        STATS["review_bonuses_total"] = int(STATS.get("review_bonuses_total", 0) or 0) + 1
    else:
        STATS["rentals_total"] = int(STATS.get("rentals_total", 0) or 0) + 1
    STATS["hours_total"] = float(STATS.get("hours_total", 0) or 0) + hours
    STATS["spent_rub_total"] = float(STATS.get("spent_rub_total", 0) or 0) + cost
    prod = _stats_product(product_id, product_name)
    if kind == "extension":
        prod["extensions"] = int(prod.get("extensions", 0) or 0) + 1
    elif kind == "rental":
        prod["rentals"] = int(prod.get("rentals", 0) or 0) + 1
    prod["hours"] = float(prod.get("hours", 0) or 0) + hours
    prod["spent_rub"] = float(prod.get("spent_rub", 0) or 0) + cost
    save_stats()










































class _SafeDict(dict):
    def __missing__(self, key):
        return "{" + key + "}"


def get_text(key: str) -> str:
    return (SETTINGS.get("texts") or {}).get(key) or DEFAULT_TEXTS.get(key, "")


def fmt(key: str, **kwargs) -> str:
    try:
        return get_text(key).format_map(_SafeDict(**kwargs))
    except Exception:
        return get_text(key)


def _tg_state_key(chat_id, user_id) -> str:
    return f"{chat_id}:{user_id}"


def _continue_tg_handling():
    try:
        return ContinueHandling() if ContinueHandling else None
    except Exception:
        return None


def _remember_tg_state(chat_id, user_id, state: str) -> None:
    _tg_state_shadow[_tg_state_key(chat_id, user_id)] = {"state": state, "ts": _now()}
    _arm_tg_next_step(chat_id)


def _forget_tg_state(chat_id, user_id) -> None:
    _tg_state_shadow.pop(_tg_state_key(chat_id, user_id), None)


def _arm_tg_next_step(chat_id) -> None:
    try:
        handler = globals().get("tg_msg_dispatch")
        if bot and handler and hasattr(bot, "register_next_step_handler_by_chat_id"):
            bot.register_next_step_handler_by_chat_id(chat_id, handler)
    except Exception as e:
        logger.debug(f"{LP} [TG] next step arm failed: {e}")


def _set_tg_state(chat_id, message_id, user_id, state: str) -> None:
    try:
        if tg:
            tg.set_state(chat_id, message_id, user_id, state)
    except Exception as e:
        logger.debug(f"{LP} [TG] set_state failed: {e}")
    _remember_tg_state(chat_id, user_id, state)


def _clear_tg_state(chat_id, user_id, delete: bool = True) -> None:
    try:
        if tg:
            tg.clear_state(chat_id, user_id, delete)
    except Exception as e:
        logger.debug(f"{LP} [TG] clear_state failed: {e}")
    _forget_tg_state(chat_id, user_id)


def _get_tg_state(chat_id, user_id):
    sd = None
    try:
        sd = tg.get_state(chat_id, user_id) if tg else None
    except Exception:
        sd = None
    if sd:
        return sd
    local = _tg_state_shadow.get(_tg_state_key(chat_id, user_id))
    if not local:
        return None
    if _now() - float(local.get("ts") or 0) > 1800:
        _forget_tg_state(chat_id, user_id)
        return None
    return {"state": local.get("state", "")}


def _parse_iso(value) -> float:
    if not value:
        return 0.0
    try:
        s = str(value).replace("Z", "+00:00")
        return datetime.fromisoformat(s).timestamp()
    except Exception:
        return 0.0


def _tz_label(offset: int) -> str:
    if offset == 3:
        return "МСК"
    if offset == 0:
        return "UTC"
    return f"UTC{'+' if offset >= 0 else ''}{offset}"


def _fmt_expires(value) -> str:
    ts = _parse_iso(value)
    if not ts:
        return str(value or "—")
    try:
        offset = int(SETTINGS.get("tz_offset_hours", 3) or 0)
    except Exception:
        offset = 3
    try:
        tz = timezone(timedelta(hours=offset))
        return datetime.fromtimestamp(ts, tz=tz).strftime("%d.%m.%Y %H:%M ") + _tz_label(offset)
    except Exception:
        return str(value)


# ----------------------------------------------------------------------------
#  Клиент TDUgame API (https://tdugame.ru/api-docs.html)
# ----------------------------------------------------------------------------
# Остальной код плагина работает со словарями в привычном snake_case-формате
# (price_per_hour_rub, available_accounts, steam_login, new_expires_at, ...),
# поэтому клиент сам приводит ответы TDUgame к этому виду — так всё, что
# выше по коду, не знает про устройство конкретного API.

_THROTTLE_LOCK = threading.Lock()
_THROTTLE_CALLS: "deque" = deque()
_THROTTLE_LIMIT = 58             # запросов в минуту на весь плагин
_PRICE_TABLE_LOCK = threading.Lock()
_PRICE_TABLE: dict = {"ts": 0.0, "hours": [], "prices": {}, "fail_ts": 0.0}
_PRICE_TABLE_TTL = 900
_PRICE_TABLE_BASE_HOURS = [1, 3, 6, 12, 24, 48, 72, 96, 168, 240, 336, 504, 720]
_QUOTE_CACHE: dict = {}          # (product_id, hours) -> (ts, total_rub, discount)
_CRED_CACHE: dict = {}           # rental_uid -> (ts, {"steam_login", "steam_password"})
_IDEM_SEQ: dict = {}
_RUN_ID = "%06x" % random.getrandbits(24)   # свой у каждого запуска плагина


def _api_throttle():
    """Общий для всего плагина лимит запросов — лимит TDUgame общий для всех продавцов,
    поэтому берём не больше ~1 запроса в секунду."""
    with _THROTTLE_LOCK:
        now = time.time()
        while _THROTTLE_CALLS and now - _THROTTLE_CALLS[0] > 60:
            _THROTTLE_CALLS.popleft()
        wait = 0.0
        if len(_THROTTLE_CALLS) >= _THROTTLE_LIMIT:
            wait = 60 - (now - _THROTTLE_CALLS[0]) + 0.5
        if wait <= 0:
            _THROTTLE_CALLS.append(now)
            return
    logger.warning(f"{LP} [API] self-throttle, wait {wait:.1f}s")
    time.sleep(wait)
    with _THROTTLE_LOCK:
        _THROTTLE_CALLS.append(time.time())


def _next_idem(order) -> str:
    """Ключ идемпотентности для /rent и /extend: если запрос повторится из-за
    сетевой ошибки, TDUgame вернёт тот же результат, а не спишет деньги второй раз."""
    oid = str(getattr(order, "id", "") or "noid")
    n = _IDEM_SEQ.get(oid, 0) + 1
    _IDEM_SEQ[oid] = n
    if len(_IDEM_SEQ) > 2000:
        for k in list(_IDEM_SEQ)[:1000]:
            _IDEM_SEQ.pop(k, None)
    # RUN_ID: после перезапуска счётчик начинается заново, а TDUgame кэширует и ошибки тоже —
    # без него повторная попытка по тому же заказу вернула бы старую ошибку вместо нового запроса
    return f"fp-{oid}-{_RUN_ID}-{n}"


def _norm_product(p: dict) -> dict:
    return {
        "id": p.get("id"),
        "name": p.get("name"),
        "header_image": p.get("headerImage"),
        "price_per_hour_rub": p.get("pricePerHourRub"),
        "price_per_hour_usd": p.get("pricePerHourUsd"),
        "available_accounts": p.get("availableAccounts"),
        "min_hours": p.get("minHours"),
        "max_hours": p.get("maxHours"),
    }


class TDUgameAPI:
    def __init__(self, api_key: str):
        self.key = api_key
        self.s = requests.Session()
        self.s.headers.update({
            "X-API-Key": api_key,
            "Accept": "application/json",
            "Content-Type": "application/json",
        })

    def _req(self, method, path, idem: "str | None" = None, **kwargs):
        kwargs.setdefault("timeout", TIMEOUT)
        headers = dict(kwargs.pop("headers", None) or {})
        if idem:
            headers["Idempotency-Key"] = str(idem)[:120]
        url = f"{API_BASE}{path}"
        # повторять после сетевой ошибки можно только то, что безопасно повторить
        net_attempts = 3 if (method == "GET" or idem) else 1
        net_try = 0
        for _ in range(6):
            _api_throttle()
            try:
                r = self.s.request(method, url, headers=headers, **kwargs)
            except requests.RequestException as e:
                net_try += 1
                logger.warning(f"{LP} [API] {method} {path} net error (try {net_try}): {e}")
                if net_try >= net_attempts:
                    return None
                time.sleep(2)
                continue
            if r.status_code == 429:
                try:
                    wait = min(60.0, float(r.headers.get("Retry-After", "5") or 5))
                except Exception:
                    wait = 5.0
                logger.warning(f"{LP} [API] 429 on {path}, wait {wait:.0f}s")
                time.sleep(wait)
                continue
            return r
        return None

    @staticmethod
    def _err_code(r) -> str:
        if r is None:
            return "connection_error"
        try:
            return _api_err_code(r.json(), r.status_code)
        except Exception:
            pass
        return f"http_{r.status_code}"

    def _json(self, r):
        if r is not None and r.status_code == 200:
            try:
                return r.json()
            except Exception:
                return None
        return None

    def balance(self):
        j = self._json(self._req("GET", "/balance"))
        if not isinstance(j, dict):
            return None
        try:
            return {"balance_rub": float(j.get("balanceRub", 0) or 0), "balance_usd": 0.0}
        except Exception:
            return None

    def products(self, search: "str | None" = None, currency: str = "RUB"):
        path = "/products"
        if search:
            path += f"?search={requests.utils.quote(search)}"
        j = self._json(self._req("GET", path))
        if not isinstance(j, list):
            return None
        return [_norm_product(p) for p in j if isinstance(p, dict)]

    def calculate_price(self, product_id: int, hours: int, fresh: bool = False):
        """Себестоимость аренды в рублях (то, что спишет TDUgame).
        fresh=False — из общей таблицы цен (один запрос на весь каталог, а не по
        запросу на каждую пару игра×срок); fresh=True — живой расчёт (для защиты
        от минусовой цены в момент заказа)."""
        pid, h = int(product_id), int(hours)
        if not fresh:
            v = _table_price(self, pid, h)
            if v is not None:
                return {"total_rub": v, "discount_percent": None}
            c = _QUOTE_CACHE.get((pid, h))
            if c and time.time() - c[0] < 900:
                return {"total_rub": c[1], "discount_percent": c[2]}
        r = self._req("POST", f"/products/{pid}/quote", json={"hours": h, "quantity": 1})
        j = self._json(r)
        if isinstance(j, dict) and j.get("priceRub") is not None:
            try:
                total = float(j["priceRub"])
            except Exception:
                return None
            _QUOTE_CACHE[(pid, h)] = (time.time(), total, j.get("discountPercent"))
            return {"total_rub": total, "discount_percent": j.get("discountPercent")}
        return None

    def price_table(self, hours: "list[int]"):
        hrs = ",".join(str(int(h)) for h in hours)
        return self._json(self._req("GET", f"/price-table?hours={hrs}"))

    def rental(self, uid: str):
        j = self._json(self._req("GET", f"/rentals/{uid}"))
        if not isinstance(j, dict):
            return None
        return {"rental_uid": j.get("rentalUid"), "expires_at": j.get("expiresAt"), "status": j.get("status")}

    def rent(self, product_id: int, hours: int, currency: str = "RUB", idem: "str | None" = None):
        body = {"productId": int(product_id), "hours": int(hours), "quantity": 1}
        r = self._req("POST", "/rent", idem=idem, json=body)
        if idem and r is not None and r.status_code == 502 and self._err_code(r) == "catalog_unavailable":
            time.sleep(3)
            r = self._req("POST", "/rent", idem=f"{idem}-retry", json=body)
        if r is not None and r.status_code == 200:
            try:
                j = r.json()
            except Exception:
                return None, "bad_response"
            uids = j.get("rentalUids") or []
            if not uids:
                return None, "no_stock"
            uid = uids[0]
            creds = self.credentials(uid) or {}
            info = self.rental(uid) or {}
            return {
                "rental_uid": uid,
                "steam_login": creds.get("steam_login", ""),
                "expires_at": info.get("expires_at"),
                "price_rub": j.get("priceRub"),
            }, None
        return None, self._err_code(r)

    def credentials(self, uid: str):
        c = _CRED_CACHE.get(uid)
        if c and time.time() - c[0] < 120:
            return c[1]
        j = self._json(self._req("GET", f"/rentals/{uid}/credentials"))
        if not isinstance(j, dict):
            return None
        out = {"steam_login": j.get("login", ""), "steam_password": j.get("password", "")}
        _CRED_CACHE[uid] = (time.time(), out)
        if len(_CRED_CACHE) > 500:
            for k in list(_CRED_CACHE)[:250]:
                _CRED_CACHE.pop(k, None)
        return out

    def code(self, uid: str):
        j = self._json(self._req("GET", f"/rentals/{uid}/code"))
        if not isinstance(j, dict) or not j.get("code"):
            return None
        return {"success": True, "code": j.get("code"), "expires_in": j.get("expiresIn", 30)}

    def extend(self, uid: str, hours: int, currency: str = "RUB", idem: "str | None" = None):
        r = self._req("POST", f"/rentals/{uid}/extend", idem=idem, json={"hours": int(hours)})
        if idem and r is not None and r.status_code == 502 and self._err_code(r) == "catalog_unavailable":
            time.sleep(3)
            r = self._req("POST", f"/rentals/{uid}/extend", idem=f"{idem}-retry", json={"hours": int(hours)})
        if r is not None and r.status_code == 200:
            try:
                j = r.json()
            except Exception:
                return None, "bad_response"
            return {"new_expires_at": j.get("expiresAt"), "price_rub": j.get("priceRub")}, None
        return None, self._err_code(r)

    def active(self):
        j = self._json(self._req("GET", "/rentals"))
        if not isinstance(j, list):
            return None
        return [{"rental_uid": x.get("rentalUid"), "product_id": x.get("productId"),
                 "expires_at": x.get("expiresAt"), "status": x.get("status")} for x in j if isinstance(x, dict)]

    def funpay_map(self):
        j = self._json(self._req("GET", "/funpay-map"))
        return j.get("games") if isinstance(j, dict) and isinstance(j.get("games"), list) else None


def _refresh_price_table(api: "TDUgameAPI", extra_hours: "list[int] | None" = None) -> None:
    now = time.time()
    with _PRICE_TABLE_LOCK:
        wanted = list(dict.fromkeys(
            _PRICE_TABLE_BASE_HOURS + [int(h) for h in _PRICE_TABLE["hours"]] + [int(h) for h in (extra_hours or [])]
        ))
        _PRICE_TABLE["fail_ts"] = now          # не долбим сервер повторно, пока идёт этот запрос
    wanted = wanted[:24]
    data = api.price_table(wanted)
    if not isinstance(data, dict) or not isinstance(data.get("prices"), dict):
        return
    ready = bool(data.get("ready", True))
    with _PRICE_TABLE_LOCK:
        _PRICE_TABLE["prices"] = data["prices"]
        _PRICE_TABLE["hours"] = [int(h) for h in data.get("hours", wanted)]
        # если сервер ещё греет таблицу (ready=False) — спросим снова через минуту
        _PRICE_TABLE["ts"] = now if ready else now - _PRICE_TABLE_TTL + 60
        _PRICE_TABLE["fail_ts"] = 0.0


def _table_price(api: "TDUgameAPI", product_id: int, hours: int) -> "float | None":
    now = time.time()
    with _PRICE_TABLE_LOCK:
        stale = now - _PRICE_TABLE["ts"] > _PRICE_TABLE_TTL
        missing_hour = hours not in _PRICE_TABLE["hours"]
        recently_failed = now - _PRICE_TABLE["fail_ts"] < 30
    if (stale or missing_hour) and not recently_failed:
        _refresh_price_table(api, [hours])
    row = _PRICE_TABLE["prices"].get(str(int(product_id)))
    if isinstance(row, dict) and row.get(str(int(hours))) is not None:
        try:
            return float(row[str(int(hours))])
        except Exception:
            return None
    return None


def _sanitize_key(raw: str) -> str:
    s = (raw or "").strip()
    if len(s) >= 2 and s[0] in "\"'`" and s[-1] == s[0]:
        s = s[1:-1].strip()
    return "".join(ch for ch in s if ch.isprintable() and not ch.isspace())


def _get_api() -> "TDUgameAPI | None":
    key = _sanitize_key(SETTINGS.get("api_key", ""))
    if not key:
        return None
    return TDUgameAPI(key)


def _dbg(msg: str):
    if SETTINGS.get("ac_debug"):
        logger.info(f"{LP} [AC-DEBUG] {msg}")


def _products(api: "TDUgameAPI | None", force: bool = False):
    if api is None:
        return None
    now = time.time()
    if (not force and _products_cache["data"] is not None
            and now - _products_cache["ts"] < AC_CACHE_TTL):
        return _products_cache["data"]
    data = api.products()
    if data is None:
        _products_cache["err"] = True
        _dbg("products(): ошибка API (None) — отдаю прошлый кэш если есть")
        return _products_cache["data"] if _products_cache["data"] is not None else None
    _products_cache.update({"data": data, "ts": now, "err": False})
    _dbg(f"products(): загружено {len(data)} товаров, кэш на {AC_CACHE_TTL//60} мин")
    return data


def _bar(done: int, total: int, width: int = 14) -> str:
    """Шкала с плавным заполнением: целые деления █, последнее — частичное (▏▎▍▌▋▊▉),
    чтобы продвижение было видно даже при 1–2%."""
    total = max(1, total)
    frac = min(1.0, max(0.0, done / total))
    exact = frac * width
    full = int(exact)
    part = int((exact - full) * 8)
    if done > 0 and full == 0 and part == 0:
        part = 1                               # хоть что-то уже сделано — показываем тонкую полоску
    partial = " ▏▎▍▌▋▊▉"[part] if full < width and part else ""
    rest = width - full - (1 if partial else 0)
    return f"{'█' * full}{partial}{'░' * rest} {int(frac * 100)}% ({done}/{total})"


def _send(chat_id, text: str):
    if cardinal_instance and chat_id and text:
        try:
            cardinal_instance.send_message(chat_id, text, watermark=False)
        except Exception as e:
            logger.error(f"{LP} send to {chat_id} failed: {e}")


def notify_buyer_problem(chat_id):
    _send(chat_id, get_text("text_problem"))


def _admins():
    if cardinal_instance and cardinal_instance.telegram:
        return list(cardinal_instance.telegram.authorized_users)
    return []


def _alert_kind(reason: str) -> str:
    low = reason.lower()
    if "продл" in low:
        return "extend"
    if "баланс" in low:
        return "balance"
    if "выдать" in low or "выдач" in low or "аренда создана" in low or "аккаунт выдан" in low:
        return "delivery"
    return "system"


def notify_admins(reason: str, order=None, raw: str = "", extra: str = "", dedup_key: "str | None" = None,
                  log_event: bool = True):
    if log_event:
        try:
            _evt(_alert_kind(reason), "error", reason, detail=extra or raw,
                 order=getattr(order, "id", None) if order is not None else None,
                 buyer=getattr(order, "buyer_username", None) if order is not None else None,
                 lot=(getattr(order, "description", "") or "")[:120] if order is not None else None,
                 raw=raw[:300] if raw else None)
        except Exception:
            pass
    if dedup_key:
        last = _admin_alert_dedup.get(dedup_key, 0)
        if time.time() - last < 60:
            return
        _admin_alert_dedup[dedup_key] = time.time()

    lines = [f"<b>{LP}</b>", f"⚠️ {_html.escape(reason)}"]
    kb = None
    if order is not None:
        oid = getattr(order, "id", "?")
        buyer = getattr(order, "buyer_username", "?")
        lines.append(f"Покупатель: <b>{_html.escape(str(buyer))}</b>")
        lines.append(f"Заказ: <code>{_html.escape(str(oid))}</code>")
        desc = getattr(order, "description", "") or ""
        if desc:
            lines.append(f"Лот: {_html.escape(desc[:80])}")
        kb = K()
        kb.add(B("🔗 Открыть заказ", url=ORDER_URL.format(order_id=oid)))
    if extra:
        lines.append(_html.escape(extra))
    if raw:
        lines.append(f"<code>{_html.escape(raw[:200])}</code>")
    text = "\n".join(lines)
    if not bot:
        return
    for admin_id in _admins():
        try:
            bot.send_message(admin_id, text, parse_mode="HTML", reply_markup=kb)
        except Exception:
            pass


def notify_admins_info(text: str):
    if not bot:
        return
    for admin_id in _admins():
        try:
            bot.send_message(admin_id, f"{LP} {text}", parse_mode="HTML")
        except Exception:
            pass


_HUMAN_ERR = {
    "no_stock": "Нет свободных аккаунтов на TDUgame",
    "insufficient_balance": "Недостаточно средств на балансе TDUgame",
    "invalid_api_key": "Неверный API ключ TDUgame",
    "account_disabled": "Аккаунт TDUgame отключён",
    "rate_limited": "Превышен лимит запросов TDUgame",
    "not_found": "Товар/аренда не найдены на TDUgame",
    "connection_error": "Нет связи с TDUgame",
    "invalid_duration": "Срок аренды вне допустимого для этой игры диапазона",
    "invalid_request": "TDUgame отклонил запрос (неверные параметры)",
    "catalog_unavailable": "TDUgame временно недоступен (сбой на стороне каталога)",
    "not_active": "Аренда уже не активна",
    "operation_inflight": "Предыдущая операция по аренде ещё выполняется",
}


def _human_err(code) -> str:
    norm = _api_err_code(code)
    return _HUMAN_ERR.get(norm, f"Ошибка TDUgame: {code}")


def _is_no_stock(err) -> bool:
    return _api_err_code(err) == "no_stock"


def _hour_chunks(hours: int) -> list[int]:
    try:
        left = int(hours or 0)
    except Exception:
        left = MIN_RENT_HOURS
    left = max(MIN_RENT_HOURS, left)
    chunks = []
    while left > 0:
        chunk = min(720, left)
        chunks.append(chunk)
        left -= chunk
    return chunks


def _duration_in_desc(hours: int, desc_l: str) -> bool:
    if not desc_l or not hours:
        return False
    for lang in ("ru", "en"):
        t = _format_time_text(int(hours), lang).strip().lower()
        if t and t in desc_l:
            return True
    return False


def _duration_candidates(text: str) -> set[int]:
    low = (text or "").lower()
    out: set[int] = set()
    for n, unit in re.findall(
        r"(?<!\d)(\d{1,4})\s*(час(?:а|ов)?|ч\b|h\b|hour[s]?|день|дня|дней|д\b|сут(?:ки|ок)?|day[s]?|недел[яиюьеь]*|week[s]?|месяц(?:а|ев)?|мес\b|month[s]?)",
        low,
        re.I,
    ):
        try:
            val = int(n)
        except Exception:
            continue
        if val <= 0:
            continue
        u = unit.lower()
        if u.startswith(("час", "ч", "h", "hour")):
            out.add(val)
        elif u.startswith(("д", "сут", "day")):
            out.add(val * 24)
        elif u.startswith(("нед", "week")):
            out.add(val * 168)
        elif u.startswith(("мес", "month")):
            out.add(val * 720)
    if re.search(r"\b(сутки|суток)\b", low):
        out.add(24)
    if re.search(r"\b(неделя|неделю|week)\b", low):
        out.add(168)
    if re.search(r"\b(месяц|month)\b", low):
        out.add(720)
    return out


def _order_title_duration_mismatch(order, hours_unit: int) -> "set[int] | None":
    title = getattr(order, "description", "") or ""
    cands = _duration_candidates(title)
    if cands and int(hours_unit) not in cands:
        return cands
    return None


_RX_RENT_CODE = re.compile(r"\brent\s*:\s*([a-z0-9][a-z0-9_-]{1,48})\b", re.I)


def _make_rent_code(product_id, hours, placement: str = "") -> str:
    try:
        tail = f"-{placement}" if placement else ""
        pid = int(product_id)
        # у «своих игр» TDUgame id отрицательный; минус в коде не пройдёт поиск по
        # заказу (первый символ должен быть буквой/цифрой), поэтому пишем n20, а не -20
        pid_s = f"n{-pid}" if pid < 0 else str(pid)
        return f"rent:{pid_s}-{int(hours)}{tail}"
    except Exception:
        raw = re.sub(r"[^a-z0-9_-]+", "-", f"{product_id}-{hours}-{placement}".lower()).strip("-")
        return f"rent:{raw or 'unknown'}"


def _short_err(err, limit: int = 220) -> str:
    s = re.sub(r"\s+", " ", str(err or "")).strip()
    return s[:limit] + ("..." if len(s) > limit else "")


def _extract_rent_codes(text: str) -> list[str]:
    seen = []
    for m in _RX_RENT_CODE.finditer(text or ""):
        code = "rent:" + m.group(1).lower()
        if code not in seen:
            seen.append(code)
    return seen


def _mapping_rent_code(mapping: dict) -> str:
    code = (mapping.get("rent_code") or "").strip().lower()
    if code.startswith("rent:"):
        return code
    placement = mapping.get("placement") or "main"
    return _make_rent_code(mapping.get("product_id"), mapping.get("hours"),
                           "" if placement == "main" else placement).lower()


def _mapping_rent_codes(mapping: dict) -> list[str]:
    codes = []
    placement = mapping.get("placement") or "main"
    raw_codes = [
        mapping.get("rent_code"),
        _make_rent_code(mapping.get("product_id"), mapping.get("hours"), "" if placement == "main" else placement),
        f"rent:{mapping.get('lot_id')}" if mapping.get("lot_id") else "",
    ]
    if placement == "main":
        raw_codes.append(_make_rent_code(mapping.get("product_id"), mapping.get("hours")))
    for code in raw_codes:
        code = (code or "").strip().lower()
        if code.startswith("rent:") and code not in codes:
            codes.append(code)
    return codes


def _append_rent_code_full(text: str, code: str) -> str:
    code = (code or "").strip()
    if not code or code.lower() in (text or "").lower():
        return text or ""
    base = (text or "").rstrip()
    return f"{base}\n\n{code}" if base else code


def _strip_rent_code_from_title(text: str) -> str:
    s = str(text or "")
    s = re.sub(r"\s*[\[\(\{]?\s*rent\s*:\s*[a-z0-9][a-z0-9_-]{1,48}\s*[\]\)\}]?", " ", s, flags=re.I)
    return re.sub(r"\s+", " ", s).strip()


def _limit_lot_title(text: str, limit: int = FP_SUMMARY_LIMIT) -> str:
    s = _strip_rent_code_from_title(text)
    s = re.sub(r"\s+", " ", s).strip()
    if limit and len(s) > limit:
        s = s[:max(1, limit - 3)].rstrip(" .,;:!?|/-_") + "..."
    return s


def _limit_lot_title_keep_tail(text: str, tail: str, limit: int = FP_SUMMARY_LIMIT) -> str:
    s = _strip_rent_code_from_title(text)
    s = re.sub(r"\s+", " ", s).strip()
    tail = re.sub(r"\s+", " ", str(tail or "")).strip()
    if not limit or len(s) <= limit:
        return s
    if not tail:
        return _limit_lot_title(s, limit)
    idx = s.lower().rfind(tail.lower())
    if idx < 0:
        return _limit_lot_title(s, limit)
    head = s[:idx].rstrip(" .,;:!?|/-_")
    tail_part = s[idx:].strip()
    keep = limit - len(tail_part) - 4
    if keep <= 0:
        return _limit_lot_title(tail_part, limit)
    return f"{head[:keep].rstrip(' .,;:!?|/-_')}... {tail_part}".strip()


def _last_duration_text(text: str) -> str:
    matches = list(re.finditer(
        r"(?<!\d)\d{1,4}\s*(?:час(?:а|ов)?|ч\b|h\b|hour[s]?|день|дня|дней|д\b|сут(?:ки|ок)?|day[s]?|недел[яиюьеь]*|week[s]?|месяц(?:а|ев)?|мес\b|month[s]?)",
        str(text or ""),
        re.I,
    ))
    return matches[-1].group(0).strip() if matches else ""


def _sanitize_summary_fields(fields: dict) -> bool:
    changed = False
    if "fields[summary][ru]" in fields:
        old = fields.get("fields[summary][ru]") or ""
        new = _limit_lot_title_keep_tail(old, _last_duration_text(old))
        if new != old:
            fields["fields[summary][ru]"] = new
            changed = True
    if "fields[summary][en]" in fields:
        old = fields.get("fields[summary][en]") or ""
        new = _limit_lot_title_keep_tail(_ascii(_strip_rent_code_from_title(old)), _last_duration_text(old))
        if old and not _en_title_ok(new):
            new = "RENTAL ACCOUNT INSTANT AUTO DELIVERY"
        if new != old:
            fields["fields[summary][en]"] = new
            changed = True
    return changed


def _match_mapping(order, lot_id) -> "dict | None":
    order_text = "\n".join(str(x or "") for x in (
        getattr(order, "description", ""),
        getattr(order, "html", ""),
    ))
    desc_l = order_text.lower()
    subcat = getattr(getattr(order, "subcategory", None), "id", None)

    codes = _extract_rent_codes(order_text)
    if codes:
        code_cands = [m for m in MAPPINGS if any(c in codes for c in _mapping_rent_codes(m))]
        if len(code_cands) == 1:
            _dbg(f"_match_mapping: матч по rent-коду {_mapping_rent_code(code_cands[0])}")
            return code_cands[0]
        if len(code_cands) > 1:
            logger.warning(f"{LP} order {getattr(order, 'id', '?')}: неоднозначный rent-код {codes}, пропуск")
            return None

    if lot_id:
        for m in MAPPINGS:
            if m.get("lot_id") and str(m["lot_id"]) == str(lot_id):
                return m
        logger.info(f"{LP} order {getattr(order, 'id', '?')}: lot_id={lot_id} не привязан к TDUgame, пропуск")
        return None

    # Без lot_id нельзя безопасно полагаться на одну подкатегорию: в ней могут
    # продаваться обычные аккаунты, валюты и чужие лоты. Разрешаем только
    # fallback по явному ключевому слову и длительности из названия лота.
    cands = []
    for m in MAPPINGS:
        kw = (m.get("title_keyword") or "").strip().lower()
        if not kw:
            continue
        sc = m.get("subcategory_id")
        if sc and subcat and str(sc) != str(subcat):
            continue
        if kw in desc_l:
            cands.append(m)

    dur_cands = [m for m in cands if _duration_in_desc(int(m.get("hours") or 0), desc_l)]

    if desc_l and dur_cands:
        if len(dur_cands) == 1:
            _dbg(f"_match_mapping: fallback по ключу и длительности (lot_id не найден в событии) -> "
                 f"hours={dur_cands[0].get('hours')} {dur_cands[0].get('product_name')}")
            return dur_cands[0]

    if len(cands) == 1:
        logger.warning(f"{LP} order {getattr(order, 'id', '?')}: lot_id не найден, "
                       f"ключевое слово совпало, но длительность не распознана — пропуск во избежание неверной выдачи")
        return None
    if len(cands) > 1:
        logger.warning(f"{LP} order {getattr(order, 'id', '?')}: неоднозначная привязка "
                       f"(игра найдена, длительность из названия не распознана), пропуск во избежание неверной выдачи")
        return None
    return None


def _buyer_key(chat_id) -> str:
    return str(chat_id)


def _now() -> float:
    return time.time()


def _active_rentals(buyer_key: str) -> list:
    out = []
    for r in RENTALS.get(buyer_key, []):
        exp = r.get("expires_ts", 0)
        if not exp or exp > _now() - 60:
            out.append(r)
    return out


def _add_rental(buyer_key, rec: dict):
    RENTALS.setdefault(buyer_key, []).append(rec)
    save_rentals()


# --- Работа рядом со старым плагином QuantumKOSellRent -------------------------------------------
# Если рядом стоит старый плагин, его аренды выданы через другого поставщика, и продлевать,
# присылать коды и принимать по ним оплату может только он. Файл его аренд читаем только на чтение;
# если файла нет (обычная установка), всё ниже ничего не меняет.
LEGACY_RENTALS_FILE = "storage/plugins/quantum_kosell_rent/rentals.json"
_legacy_cache = {"mtime": None, "data": {}}


def _legacy_rentals(buyer_key: str) -> list:
    try:
        mt = os.path.getmtime(LEGACY_RENTALS_FILE)
    except OSError:
        return []
    if _legacy_cache["mtime"] != mt:
        try:
            with open(LEGACY_RENTALS_FILE, encoding="utf-8") as f:
                data = json.load(f)
        except Exception:
            data = {}
        _legacy_cache["mtime"] = mt
        _legacy_cache["data"] = data if isinstance(data, dict) else {}
    out = []
    for r in _legacy_cache["data"].get(buyer_key) or []:
        exp = r.get("expires_ts", 0)
        if not exp or exp > _now() - 60:
            out.append(r)
    return out


def _legacy_takes_command(buyer_key: str, arg) -> bool:
    """True: команду покупателя обработает старый плагин (аренда там), мы молчим."""
    legacy = _legacy_rentals(buyer_key)
    if not legacy:
        return False
    mine = _active_rentals(buyer_key)
    if arg:
        a = arg.strip().lower()
        if any((r.get("login") or "").lower() == a for r in mine):
            return False
        return any((r.get("login") or "").lower() == a for r in legacy)
    return not mine


def _cleanup_rentals():
    changed = False
    for bk in list(RENTALS.keys()):
        kept = [r for r in RENTALS[bk] if (r.get("expires_ts", 0) or 0) > _now() - 3600]
        if len(kept) != len(RENTALS[bk]):
            RENTALS[bk] = kept
            changed = True
        if not kept:
            del RENTALS[bk]
            changed = True
    if changed:
        save_rentals()


def _friend_active(buyer_key: str) -> bool:
    ts = _friend.get(buyer_key)
    if not ts:
        return False
    if _now() - ts > SETTINGS.get("friend_minutes", 10) * 60:
        _friend.pop(buyer_key, None)
        return False
    return True


def _friend_set(buyer_key: str):
    _friend[buyer_key] = _now()


def _friend_clear(buyer_key: str):
    _friend.pop(buyer_key, None)


def _extend_intent_set(buyer_key: str, login: str):
    if login:
        _extend_intent[buyer_key] = {"login": login, "ts": _now()}


def _extend_intent_clear(buyer_key: str):
    _extend_intent.pop(buyer_key, None)


def _extend_intent_get(buyer_key: str) -> "dict | None":
    intent = _extend_intent.get(buyer_key)
    if not intent:
        return None
    ttl = max(5, int(SETTINGS.get("friend_minutes", 10) or 10)) * 60
    if _now() - float(intent.get("ts") or 0) > ttl:
        _extend_intent_clear(buyer_key)
        return None
    return intent


def _find_rental_by_login(buyer_key: str, login: str, product_id=None) -> "dict | None":
    login_l = (login or "").strip().lower()
    if not login_l:
        return None
    for r in _active_rentals(buyer_key):
        if product_id is not None and str(r.get("product_id")) != str(product_id):
            continue
        if (r.get("login") or "").strip().lower() == login_l:
            return r
    return None


def _maybe_refund(order, force: bool = False):
    if not (force or SETTINGS.get("auto_refund")):
        return False
    oid = getattr(order, "id", None)
    if not oid or not cardinal_instance:
        return False
    try:
        cardinal_instance.account.refund(oid)
        notify_admins_info(f"💸 Выполнен авто-возврат по заказу {oid}")
        return True
    except Exception as e:
        notify_admins(f"Не удалось сделать авто-возврат: {e}", order=order, dedup_key=f"refund:{oid}")
        return False


def _is_int_id(text) -> bool:
    """id товара: у «своих игр» TDUgame он отрицательный (-20), isdigit() такой не пропускает."""
    return bool(re.fullmatch(r"-?\d+", str(text or "").strip()))


def _account_currency_name() -> str:
    """Валюта аккаунта FunPay: RUB / USD / EUR / UNKNOWN."""
    try:
        cur = getattr(cardinal_instance.account, "currency", None)
        return str(getattr(cur, "name", cur) or "UNKNOWN").upper()
    except Exception:
        return "UNKNOWN"


def _currency_blocked_text() -> str:
    """Не пусто, если аккаунт FunPay не в рублях: цены лотов считаются в рублях и
    ввелись бы в чужой валюте (40 ₽ превратились бы в $40)."""
    cur = _account_currency_name()
    if cur in ("USD", "EUR"):
        return (f"Валюта аккаунта FunPay — {cur}. Плагин считает цены в рублях и пока работает "
                f"только с рублёвыми аккаунтами, чтобы не выставить лоты по неверной цене.")
    return ""


def _order_paid_rub(order) -> "float | None":
    cur = getattr(order, "currency", None)
    if str(getattr(cur, "name", cur) or "").upper() in ("USD", "EUR"):
        return None   # заказ оплачен не в рублях — с рублёвой себестоимостью не сравнить
    for attr in ("price", "total", "sum", "amount_rub"):
        raw = getattr(order, attr, None)
        if raw in (None, ""):
            continue
        try:
            paid = float(str(raw).replace(" ", "").replace(",", "."))
            return paid if paid > 0 else None
        except Exception:
            continue
    return None


def _required_cost_rub(api: "TDUgameAPI", product_id, hours: int) -> "float | None":
    try:
        data = api.calculate_price(int(product_id), int(hours), fresh=True)
        if isinstance(data, dict) and data.get("total_rub") is not None:
            cost = float(data["total_rub"])
            return cost if cost > 0 else None
    except Exception:
        pass

    prod = None
    try:
        pid = int(product_id)
        prod = next((p for p in (_products(api) or []) if int(p.get("id") or 0) == pid), None)
    except Exception:
        prod = None
    if not prod:
        return None
    try:
        cost = float(_cost_rub(api, prod, int(hours)))
        return cost if cost > 0 else None
    except Exception:
        return None


def _price_guard_ok(api: "TDUgameAPI", mapping: dict, order, hours: int, chat_id, count: int = 1) -> bool:
    if not SETTINGS.get("price_guard_enabled", True):
        return True

    paid = _order_paid_rub(order)
    if paid is None:
        logger.warning(f"{LP} order {getattr(order, 'id', '?')}: не удалось определить цену заказа, price_guard пропущен")
        return True

    unit_cost = _required_cost_rub(api, mapping.get("product_id"), hours)
    if unit_cost is None:
        logger.warning(f"{LP} order {getattr(order, 'id', '?')}: не удалось определить себестоимость TDUgame, price_guard пропущен")
        return True

    try:
        margin = max(0.0, float(SETTINGS.get("price_guard_margin_rub", 0) or 0))
    except Exception:
        margin = 0.0
    count = max(1, int(count or 1))
    cost = round(unit_cost * count, 2)
    required = round(cost + margin, 2)
    if paid + 1e-9 >= required:
        return True

    notify_buyer_problem(chat_id)
    notify_admins(
        f"Защита от минусовой цены: заказ оплачен на {_money(paid)}₽, "
        f"себестоимость TDUgame {_money(cost)}₽"
        + (f" + запас {_money(margin)}₽" if margin > 0 else "")
        + f". Выдача остановлена.",
        order=order,
        extra=f"product_id={mapping.get('product_id')} hours={hours} count={count} required={_money(required)}₽",
        dedup_key=f"price_guard:{getattr(order, 'id', '?')}",
    )
    _maybe_refund(order)
    return False


def _do_new_rental(api, mapping, chat_id, buyer_name, order, hours: int, notify_fail: bool = True):
    product_id = mapping["product_id"]
    currency = mapping.get("currency") or SETTINGS.get("currency", "RUB")
    game = mapping.get("product_name") or "аккаунт"

    chunks = _hour_chunks(hours)
    requested_hours = sum(chunks)
    idem = _next_idem(order)
    data, err = api.rent(product_id, chunks[0], currency, idem=f"{idem}-r")
    if err or not data:
        if _api_err_code(err) == "insufficient_balance":
            _balance_alerted["low"] = True
        code = _api_err_code(err or "unknown")
        if notify_fail:
            # в журнал пишем каждый случай (оповещение в Telegram ниже может схлопнуться как повтор)
            _evt("delivery", "error", f"Не выдан аккаунт: {game}, {hours} ч",
                 detail=f"{_human_err(err or 'unknown')}. Код ответа TDUgame: {code}.",
                 order=getattr(order, "id", None), buyer=getattr(order, "buyer_username", None),
                 lot=(getattr(order, "description", "") or "")[:120], raw=str(err)[:300], product_id=product_id)
            notify_buyer_problem(chat_id)
            notify_admins(f"Не удалось выдать аккаунт: {_human_err(err or 'unknown')}",
                          order=order, raw=str(err), dedup_key=f"rent:{err}", log_event=False)
            _maybe_refund(order, force=_is_no_stock(err))
        # лот, по которому выдать нельзя, сразу скрываем (в фоне, чтобы не задерживать ответ покупателю)
        if code in ("no_stock", "invalid_duration") and mapping.get("lot_id"):
            threading.Thread(target=_hide_lot,
                             args=(mapping.get("lot_id"), "stock" if code == "no_stock" else "min_hours"),
                             kwargs={"force": True}, daemon=True).start()
        return False, err

    uid = data.get("rental_uid")
    login = data.get("steam_login", "")
    expires_raw = data.get("expires_at")
    delivered_hours = chunks[0]
    spent_rub = float(data.get("price_rub") or 0)
    for ci, chunk in enumerate(chunks[1:], 1):
        ext_data, ext_err = api.extend(uid, chunk, currency, idem=f"{idem}-e{ci}")
        if ext_err or not ext_data:
            if _api_err_code(ext_err) == "insufficient_balance":
                _balance_alerted["low"] = True
            notify_admins(f"Аккаунт выдан, но не удалось докинуть всё время: "
                          f"{delivered_hours}/{requested_hours} ч ({_human_err(ext_err or 'unknown')})",
                          order=order, extra=f"uid={uid} login={login}", raw=str(ext_err),
                          dedup_key=f"rent_partial:{uid}:{ext_err}")
            break
        delivered_hours += chunk
        spent_rub += float(ext_data.get("price_rub") or 0)
        expires_raw = ext_data.get("new_expires_at") or expires_raw

    password = ""
    creds = api.credentials(uid)
    if creds:
        password = creds.get("steam_password", "")
    if not password:
        notify_admins("Аренда создана, но не удалось получить пароль (credentials)",
                      order=order, extra=f"uid={uid} login={login}", dedup_key=f"cred:{uid}")

    rec = {
        "uid": uid,
        "product_id": product_id,
        "product_name": game,
        "login": login,
        "hours": delivered_hours,
        "expires_raw": expires_raw,
        "expires_ts": _parse_iso(expires_raw),
        "lot_id": mapping.get("lot_id"),
        "order_id": getattr(order, "id", None),
        "created_at": _now(),
        "review_bonus_given": False,
    }
    _add_rental(_buyer_key(chat_id), rec)
    cost_rub = spent_rub if spent_rub > 0 else _required_cost_rub(api, product_id, delivered_hours)
    _stats_add_order(order, product_id, game)
    _stats_add_spent(product_id, game, delivered_hours, cost_rub, "rental")

    _send(chat_id, fmt("text_delivery", login=login, password=password, game=game,
                       hours=delivered_hours, expires=_fmt_expires(expires_raw)))
    _evt("delivery", "warn" if delivered_hours < requested_hours or not password else "ok",
         f"Выдан аккаунт: {game}, {delivered_hours} ч",
         detail=("Выдана только часть срока: " + f"{delivered_hours} из {requested_hours} ч. " if delivered_hours < requested_hours else "")
                + ("Пароль не получен от TDUgame. " if not password else ""),
         advice="" if delivered_hours >= requested_hours and password else None,
         order=getattr(order, "id", None), buyer=getattr(order, "buyer_username", None), product_id=product_id)
    if SETTINGS.get("notify_sales"):
        paid_rub = _order_paid_rub(order)
        profit_rub = None if paid_rub is None or cost_rub is None else paid_rub - cost_rub
        notify_admins_info(
            "✅ Заказ создан!\n\n"
            f"🆔 ID аренды: {_html.escape(str(uid or '—'))}\n"
            f"👤 Покупатель: {_html.escape(str(getattr(order, 'buyer_username', '?') or '?'))}\n"
            f"💰 Цена заказа: {_money_dash(paid_rub)}₽\n"
            f"💸 Потрачено на API: {_money_dash(cost_rub)}₽\n"
            f"💎 Чистая прибыль: {_money_dash(profit_rub)}₽"
        )
    return True, None


def _do_extend(api, mapping, chat_id, rental, order, hours: int):
    currency = mapping.get("currency") or SETTINGS.get("currency", "RUB")
    game = mapping.get("product_name") or rental.get("product_name") or "аккаунт"
    chunks = _hour_chunks(hours)
    requested_hours = sum(chunks)
    applied_hours = 0
    spent_rub = 0.0
    idem = _next_idem(order)
    new_exp = rental.get("expires_raw")
    for ci, chunk in enumerate(chunks):
        data, err = api.extend(rental["uid"], chunk, currency, idem=f"{idem}-e{ci}")
        if err or not data:
            notify_buyer_problem(chat_id)
            if _api_err_code(err) == "insufficient_balance":
                _balance_alerted["low"] = True
            if applied_hours:
                notify_admins(f"Продление применено частично: {applied_hours}/{requested_hours} ч "
                              f"({_human_err(err or 'unknown')})",
                              order=order, extra=f"uid={rental['uid']}", raw=str(err), dedup_key=f"ext_partial:{err}")
                break
            notify_admins(f"Не удалось продлить аренду: {_human_err(err or 'unknown')}",
                          order=order, extra=f"uid={rental['uid']}", raw=str(err), dedup_key=f"ext:{err}")
            _maybe_refund(order, force=_is_no_stock(err))
            return False
        applied_hours += chunk
        spent_rub += float(data.get("price_rub") or 0)
        new_exp = data.get("new_expires_at") or new_exp
    rental["expires_raw"] = new_exp
    rental["expires_ts"] = _parse_iso(new_exp)
    save_rentals()
    cost_rub = spent_rub if spent_rub > 0 else _required_cost_rub(api, mapping.get("product_id"), applied_hours)
    _stats_add_order(order, mapping.get("product_id"), game)
    _stats_add_spent(mapping.get("product_id"), game, applied_hours, cost_rub, "extension")
    _send(chat_id, fmt("text_extension", login=rental.get("login", ""), game=game,
                       hours=applied_hours, expires=_fmt_expires(new_exp)))
    if SETTINGS.get("notify_sales"):
        notify_admins_info(f"✅ Продлён {rental.get('login')} (+{hours} ч), заказ {getattr(order, 'id', '?')}")
    return True


def _order_snapshot(order) -> dict:
    return {
        "id": str(getattr(order, "id", "") or ""),
        "buyer_username": getattr(order, "buyer_username", "") or "",
        "chat_id": getattr(order, "chat_id", None),
        "description": getattr(order, "description", "") or "",
        "price": getattr(order, "price", None),
        "amount": getattr(order, "amount", None),
        "html": getattr(order, "html", "") or "",
        "currency": getattr(getattr(order, "currency", None), "name", None),
    }


def _order_from_snapshot(data: dict):
    data = data or {}
    return type("O", (), {
        "id": data.get("id"),
        "buyer_username": data.get("buyer_username", ""),
        "chat_id": data.get("chat_id"),
        "description": data.get("description", ""),
        "price": data.get("price"),
        "amount": data.get("amount"),
        "html": data.get("html", ""),
        # валюту восстанавливаем объектом с .name — так её читает _order_paid_rub
        "currency": type("C", (), {"name": data.get("currency")})() if data.get("currency") else None,
    })()


def _qty_ttl_sec() -> int:
    try:
        mins = int(SETTINGS.get("multi_qty_confirm_ttl_min", 30) or 30)
    except Exception:
        mins = 30
    return max(1, mins) * 60


def _pending_qty_for_buyer(buyer_key: str) -> "dict | None":
    _expire_pending_qty_orders()
    cands = [p for p in PENDING_QTY.values() if str(p.get("buyer_key")) == str(buyer_key)]
    if not cands:
        return None
    return sorted(cands, key=lambda p: float(p.get("ts") or 0), reverse=True)[0]


def _pending_qty_remove(order_id) -> None:
    oid = str(order_id or "")
    if oid and oid in PENDING_QTY:
        PENDING_QTY.pop(oid, None)
        save_pending_qty()


def _pending_qty_put(order, mapping: dict, lot_id, chat_id, buyer_name: str,
                     buyer_key: str, qty: int, hours_unit: int, friend_mode: bool) -> None:
    oid = str(getattr(order, "id", "") or "")
    if not oid:
        return
    PENDING_QTY[oid] = {
        "order_id": oid,
        "ts": _now(),
        "buyer_key": str(buyer_key),
        "chat_id": chat_id,
        "buyer_name": buyer_name or "",
        "lot_id": lot_id,
        "mapping": dict(mapping or {}),
        "qty": int(qty),
        "hours_unit": int(hours_unit),
        "friend_mode": bool(friend_mode),
        "order": _order_snapshot(order),
    }
    save_pending_qty()
    _send(chat_id, fmt("text_qty_choice",
                       qty=qty,
                       game=mapping.get("product_name") or mapping.get("title_keyword") or "аккаунт",
                       hours_unit=hours_unit,
                       total_hours=hours_unit * qty))


_QTY_YES = {"да", "д", "yes", "y", "+", "ага", "нужны", "нужно", "отдельные", "несколько"}
_QTY_NO = {"нет", "не", "no", "n", "-", "один", "одно", "1"}


def _qty_answer(text: str) -> "str | None":
    clean = re.sub(r"[^\w+\- ]+", " ", (text or "").lower(), flags=re.I)
    clean = re.sub(r"\s+", " ", clean).strip()
    if not clean:
        return None
    first = clean.split()[0]
    if clean in _QTY_YES or first in _QTY_YES:
        return "separate"
    if clean in _QTY_NO or first in _QTY_NO:
        return "combined"
    return None


def _execute_separate_order(api, mapping: dict, chat_id, buyer_name, order,
                            hours_unit: int, count: int, buyer_key: str, force_friend: bool = False) -> bool:
    count = max(1, int(count or 1))
    hours_unit = max(MIN_RENT_HOURS, int(hours_unit or MIN_RENT_HOURS))
    if not _price_guard_ok(api, mapping, order, hours_unit, chat_id, count=count):
        return True
    if force_friend:
        _friend_clear(buyer_key)

    delivered = 0
    last_err = None
    for _ in range(count):
        ok, err = _do_new_rental(api, mapping, chat_id, buyer_name, order, hours_unit, notify_fail=False)
        if ok:
            delivered += 1
            continue
        last_err = err
        break

    if delivered == count:
        return True
    if delivered:
        _send(chat_id, fmt("text_partial", delivered=delivered, ordered=count))
        notify_admins(f"Мульти-qty: выдано только {delivered}/{count} отдельных аккаунтов "
                      f"по {hours_unit} ч ({_human_err(last_err or 'unknown')})",
                      order=order, raw=str(last_err), dedup_key=f"qty_partial:{getattr(order, 'id', '?')}")
        return True

    notify_buyer_problem(chat_id)
    notify_admins(f"Мульти-qty: не удалось выдать ни одного аккаунта из {count} "
                  f"по {hours_unit} ч ({_human_err(last_err or 'unknown')})",
                  order=order, raw=str(last_err), dedup_key=f"qty_fail:{getattr(order, 'id', '?')}")
    _maybe_refund(order, force=_is_no_stock(last_err))
    return True


def _execute_combined_order(api, mapping: dict, chat_id, buyer_name, order, total_hours: int,
                            buyer_key: str, force_friend: bool = False,
                            explicit_login: "str | None" = None) -> bool:
    total_hours = max(MIN_RENT_HOURS, int(total_hours or MIN_RENT_HOURS))

    if explicit_login:
        target = _find_rental_by_login(buyer_key, explicit_login, mapping.get("product_id"))
        if not target:
            _send(chat_id, get_text("text_extend_no_pending"))
            return False
        if not _price_guard_ok(api, mapping, order, total_hours, chat_id):
            return True
        _friend_clear(buyer_key)
        _extend_intent_clear(buyer_key)
        _do_extend(api, mapping, chat_id, target, order, total_hours)
        return True

    if not _price_guard_ok(api, mapping, order, total_hours, chat_id):
        return True

    existing = [r for r in _active_rentals(buyer_key) if str(r.get("product_id")) == str(mapping["product_id"])]

    intent = _extend_intent_get(buyer_key)
    if intent:
        target = _find_rental_by_login(buyer_key, intent.get("login", ""), mapping["product_id"])
        if target:
            _friend_clear(buyer_key)
            _extend_intent_clear(buyer_key)
            _do_extend(api, mapping, chat_id, target, order, total_hours)
            return True

    if force_friend or _friend_active(buyer_key):
        _friend_clear(buyer_key)
        ok, err = _do_new_rental(api, mapping, chat_id, buyer_name, order, total_hours, notify_fail=False)
        if not ok:
            notify_buyer_problem(chat_id)
            notify_admins(f"Режим друга: не удалось выдать новый аккаунт на {total_hours} ч "
                          f"({_human_err(err or 'unknown')})",
                          order=order, raw=str(err), dedup_key=f"friend:{err}")
            _maybe_refund(order, force=_is_no_stock(err))
        return True

    if len(existing) == 0:
        _do_new_rental(api, mapping, chat_id, buyer_name, order, total_hours)
    elif len(existing) == 1:
        _do_extend(api, mapping, chat_id, existing[0], order, total_hours)
    else:
        logins = [r.get("login", "") for r in existing]
        _pending_ext[buyer_key] = {
            "hours": total_hours,
            "order_id": order.id,
            "product_id": mapping["product_id"],
            "allowed_logins": logins,
            "ts": _now(),
        }
        _send(chat_id, fmt("text_ask_which_account",
                           game=mapping.get("product_name") or "аккаунт",
                           logins="\n".join(f"• {l}" for l in logins)))
    return True


def _apply_pending_qty(pending: dict, decision: str, explicit_login: "str | None" = None) -> bool:
    api = _get_api()
    order = _order_from_snapshot(pending.get("order") or {})
    mapping = pending.get("mapping") or {}
    chat_id = pending.get("chat_id") or getattr(order, "chat_id", None)
    buyer_name = pending.get("buyer_name") or getattr(order, "buyer_username", "")
    buyer_key = str(pending.get("buyer_key") or _buyer_key(chat_id))
    qty = max(1, int(pending.get("qty") or 1))
    hours_unit = max(MIN_RENT_HOURS, int(pending.get("hours_unit") or MIN_RENT_HOURS))

    if not api:
        notify_buyer_problem(chat_id)
        notify_admins("Заказ ожидал выбора qty, но API ключ TDUgame не задан — выдача невозможна",
                      order=order, dedup_key=f"qty_no_api:{getattr(order, 'id', '?')}")
        _pending_qty_remove(pending.get("order_id"))
        return True

    if decision == "combined" and explicit_login:
        target = _find_rental_by_login(buyer_key, explicit_login, mapping.get("product_id"))
        if not target:
            _send(chat_id, get_text("text_extend_no_pending"))
            return False

    _pending_qty_remove(pending.get("order_id"))
    if decision == "separate":
        return _execute_separate_order(api, mapping, chat_id, buyer_name, order,
                                       hours_unit, qty, buyer_key,
                                       force_friend=bool(pending.get("friend_mode")))
    return _execute_combined_order(api, mapping, chat_id, buyer_name, order,
                                   hours_unit * qty, buyer_key,
                                   force_friend=bool(pending.get("friend_mode")),
                                   explicit_login=explicit_login)


def _expire_pending_qty_orders(silent: bool = False) -> None:
    if not PENDING_QTY:
        return
    now = _now()
    ttl = _qty_ttl_sec()
    expired = []
    for oid, pending in list(PENDING_QTY.items()):
        try:
            ts = float(pending.get("ts") or 0)
        except Exception:
            ts = 0
        if now - ts > ttl:
            expired.append((oid, pending))
    if not expired:
        return
    for oid, pending in expired:
        PENDING_QTY.pop(oid, None)
    save_pending_qty()
    if silent:
        return
    for oid, pending in expired:
        order = _order_from_snapshot(pending.get("order") or {"id": oid})
        notify_admins(fmt("text_qty_timeout_admin",
                          qty=pending.get("qty", "?"),
                          game=(pending.get("mapping") or {}).get("product_name") or "аккаунт"),
                      order=order,
                      extra=f"hours_unit={pending.get('hours_unit')} order_id={oid}",
                      dedup_key=f"qty_timeout:{oid}")


def _process_order(cardinal: "Cardinal", order, lot_id):
    api = _get_api()
    if not api:
        notify_admins("Заказ получен, но API ключ TDUgame не задан — выдача невозможна", order=order,
                      dedup_key="no_api")
        notify_buyer_problem(order.chat_id)
        _maybe_refund(order)
        return

    mapping = _match_mapping(order, lot_id)
    if not mapping:
        logger.info(f"{LP} order {order.id}: нет привязки (lot_id={lot_id})")
        if "авто-выдача на" in str(getattr(order, "description", "") or "").lower():
            notify_admins("Оплачен заказ на аренду, но лот не привязан к TDUgame - выдача не произошла, "
                          "нужно выдать вручную", order=order, dedup_key=f"no_mapping:{order.id}")
        return

    buyer_name = order.buyer_username or ""
    chat_obj = cardinal.account.get_chat_by_name(buyer_name, make_request=True)
    chat_id = chat_obj.id if chat_obj else order.chat_id

    try:
        qty = max(1, int(order.amount or 1))
    except Exception:
        qty = 1
    try:
        hours_unit = max(MIN_RENT_HOURS, int(mapping.get("hours") or 1))
    except Exception:
        hours_unit = MIN_RENT_HOURS
    buyer_key = _buyer_key(chat_id)

    if any(str(r.get("product_id")) == str(mapping.get("product_id")) for r in _legacy_rentals(buyer_key)):
        logger.info(f"{LP} order {order.id}: у покупателя аренда этой игры в старом плагине, заказ обработает он")
        return

    logger.info(f"{LP} order {order.id} matched product={mapping.get('product_id')} qty={qty} hours_unit={hours_unit}")

    total_hours = hours_unit * qty
    mismatch = _order_title_duration_mismatch(order, hours_unit)
    if mismatch:
        got = ", ".join(f"{h}ч" for h in sorted(mismatch))
        notify_buyer_problem(chat_id)
        notify_admins(f"Срок лота не совпал с привязкой: в названии похоже {got}, "
                      f"а привязка даёт {hours_unit}ч. Выдача остановлена.",
                      order=order, dedup_key=f"duration_mismatch:{order.id}")
        _maybe_refund(order)
        return

    intent = _extend_intent_get(buyer_key)
    explicit_target = None
    if intent:
        explicit_target = _find_rental_by_login(buyer_key, intent.get("login", ""), mapping["product_id"])

    if (qty > 1 and SETTINGS.get("multi_qty_confirm_enabled", True)
            and explicit_target is None):
        friend_mode = bool(_friend_active(buyer_key))
        _pending_qty_put(order, mapping, lot_id, chat_id, buyer_name, buyer_key,
                         qty, hours_unit, friend_mode)
        return

    _execute_combined_order(api, mapping, chat_id, buyer_name, order, total_hours, buyer_key)


def _lot_id_from_order_page(cardinal: "Cardinal", order) -> "str | None":
    """В событии нового заказа ни lot_id, ни ссылки на лот нет, а название в
    описании заказа FunPay обрезает ("Premium De...") - запасной поиск по
    ключевому слову для длинных названий не срабатывает. Полная страница
    заказа обычно содержит прямую ссылку на лот - берём ID оттуда.

    НО для части категорий (замечено на "Аккаунты Minecraft Dungeons 2",
    заказ S7W1CQQU - воспроизведено и на свежем, ещё не закрытом заказе, то
    есть это не следствие статуса) FunPay такую ссылку в принципе не рисует,
    страница ведёт только на страницу категории (/lots/<subcat_id>/). Прямой
    ссылки может не быть, а вот "Подробное описание" лота на странице заказа
    приведено ПОЛНОСТЬЮ (не обрезано, в отличие от текста в событии) и всегда
    содержит наш служебный rent:<product_id>-<hours> - им и определяем лот
    однозначно, без ссылки."""
    try:
        resp = cardinal.account.method("get", f"orders/{order.id}/", {}, {}, raise_not_200=True)
        page = resp.content.decode()
    except Exception as e:
        logger.warning(f"{LP} order {getattr(order, 'id', '?')}: не удалось получить lot_id со страницы заказа: {e}")
        return None
    m = re.search(r'/(?:lots|chips)/offer\?id=(\d+)', page)
    if m:
        return m.group(1)
    codes = _extract_rent_codes(page)
    if codes:
        for mp in MAPPINGS:
            if mp.get("lot_id") and any(c in codes for c in _mapping_rent_codes(mp)):
                return str(mp["lot_id"])
    return None


def on_new_order(cardinal: "Cardinal", event: NewOrderEvent):
    if not SETTINGS.get("enabled"):
        return
    order = event.order
    try:
        with _lock:
            if order.id in HANDLED:
                return
            HANDLED.append(order.id)
            save_handled()
        lot_id = getattr(event, "lot_id", None)
        if not lot_id:
            m = re.search(r'/(?:lots|chips)/offer\?id=(\d+)', getattr(order, "html", "") or "")
            lot_id = m.group(1) if m else None
        if not lot_id:
            lot_id = _lot_id_from_order_page(cardinal, order)
        _process_order(cardinal, order, lot_id)
    except Exception as e:
        logger.error(f"{LP} on_new_order error: {e}", exc_info=True)
        try:
            notify_buyer_problem(order.chat_id)
            notify_admins("Внутренняя ошибка при обработке заказа", order=order, raw=str(e))
        except Exception:
            pass


def _cmd_friend(chat_id, buyer_key):
    if _friend_active(buyer_key):
        _send(chat_id, get_text("text_friend_already"))
        return
    _extend_intent_clear(buyer_key)
    _friend_set(buyer_key)
    _send(chat_id, fmt("text_friend_activated", minutes=SETTINGS.get("friend_minutes", 10)))


def _cmd_code(chat_id, buyer_key, arg):
    api = _get_api()
    if not api:
        _send(chat_id, get_text("text_code_not_found"))
        return
    rentals = _active_rentals(buyer_key)
    if not rentals:
        _send(chat_id, get_text("text_code_not_found"))
        return
    target = None
    if arg:
        for r in rentals:
            if (r.get("login") or "").lower() == arg.lower():
                target = r
                break
        if not target:
            _send(chat_id, get_text("text_code_not_found"))
            return
    else:
        if len(rentals) == 1:
            target = rentals[0]
        else:
            logins = "\n".join(f"• {r.get('login', '')}" for r in rentals)
            _send(chat_id, fmt("text_code_ask_login", logins=logins))
            return
    data = api.code(target["uid"])
    if not data or not data.get("code"):
        _send(chat_id, get_text("text_code_not_found"))
        notify_admins("Не удалось получить Steam Guard код", extra=f"uid={target['uid']} login={target.get('login')}",
                      dedup_key=f"code:{target['uid']}")
        return
    ttl = int(data.get("expires_in", 30) or 30)
    if ttl < 5:
        _send(chat_id, "⏳ Секунду, готовлю свежий код…")
        time.sleep(ttl + 1)
        fresh = api.code(target["uid"])
        if fresh and fresh.get("code"):
            data = fresh
            ttl = int(data.get("expires_in", 30) or 30)
    _send(chat_id, fmt("text_code", login=target.get("login", ""),
                       code=data.get("code"), ttl=ttl))


def _cmd_extend(chat_id, buyer_key, arg, buyer_name):
    last = _ext_last.get(buyer_key, 0)
    if _now() - last < SETTINGS.get("extend_cooldown", 30):
        return
    _friend_clear(buyer_key)
    pending = _pending_ext.get(buyer_key)
    if not arg:
        _send(chat_id, get_text("text_extend_no_pending"))
        return
    if not pending:
        target = _find_rental_by_login(buyer_key, arg)
        if not target:
            _send(chat_id, get_text("text_extend_no_pending"))
            return
        _extend_intent_set(buyer_key, target.get("login") or arg)
        _send(chat_id, f"✅ Ок, следующий оплаченный лот этой игры продлю для {target.get('login') or arg}.")
        return
    if arg.lower() not in [l.lower() for l in pending.get("allowed_logins", [])]:
        _send(chat_id, get_text("text_extend_no_pending"))
        return
    rentals = _active_rentals(buyer_key)
    target = None
    for r in rentals:
        if (r.get("login") or "").lower() == arg.lower():
            target = r
            break
    if not target:
        _send(chat_id, get_text("text_extend_no_pending"))
        return
    api = _get_api()
    if not api:
        notify_buyer_problem(chat_id)
        return
    _ext_last[buyer_key] = _now()
    mapping = next((m for m in MAPPINGS if str(m.get("product_id")) == str(pending["product_id"])), {})
    fake_order = type("O", (), {"id": pending.get("order_id"), "buyer_username": buyer_name,
                                "chat_id": chat_id, "description": ""})()
    if _do_extend(api, mapping or {"product_id": pending["product_id"]}, chat_id, target, fake_order, pending["hours"]):
        _pending_ext.pop(buyer_key, None)
        _extend_intent_clear(buyer_key)


def _handle_buyer_text(chat_id, text: str, buyer_name=None):
    if not SETTINGS.get("enabled"):
        return
    text = (text or "").strip()
    if not text:
        return
    low = text.lower()
    parts = text.split(maxsplit=1)
    cmd = parts[0].lower()
    buyer_key = _buyer_key(chat_id)

    if _fp_cmd_seen(chat_id, text):
        logger.debug(f"{LP} command duplicate skipped: chat={chat_id} text={low[:80]!r}")
        return

    arg = parts[1].strip() if len(parts) > 1 else None

    pending_qty = _pending_qty_for_buyer(buyer_key)
    if pending_qty:
        answer = _qty_answer(low)
        if answer:
            _apply_pending_qty(pending_qty, answer)
            return
        if cmd in ("!продлить", "!prod", "!extend") and arg:
            _apply_pending_qty(pending_qty, "combined", explicit_login=arg)
            return
        if cmd not in ("!friend", "!друг", "!кодстим", "!code", "!продлить", "!prod", "!extend"):
            return

    if cmd not in ("!friend", "!друг", "!кодстим", "!code", "!продлить", "!prod", "!extend"):
        return

    if _legacy_takes_command(buyer_key, arg):
        logger.debug(f"{LP} команда {cmd}: аренда в старом плагине, пропускаю (chat={chat_id})")
        return

    if cmd in ("!friend", "!друг"):
        _cmd_friend(chat_id, buyer_key)
    elif cmd in ("!кодстим", "!code"):
        _cmd_code(chat_id, buyer_key, arg)
    elif cmd in ("!продлить", "!prod", "!extend"):
        _cmd_extend(chat_id, buyer_key, arg, buyer_name)


_RX_REVIEW_GIVEN = re.compile(
    r'(Покупатель|The buyer).+(оставил отзыв|написал отзыв|изменил отзыв|has given feedback|has changed feedback)',
    re.IGNORECASE)
_RX_STARS = re.compile(r'(\d)\s*(?:звезд|зірк|star|★)', re.IGNORECASE)


def _handle_review(msg):
    if not SETTINGS.get("review_bonus_enabled"):
        return
    text = str(getattr(msg, "text", "") or "")
    if not _RX_REVIEW_GIVEN.search(text) and "★" not in text:
        return
    dedup = f"{msg.chat_id}:{getattr(msg, 'id', '')}"
    if dedup in _processed_review:
        return
    _processed_review.add(dedup)
    if len(_processed_review) > 500:
        _processed_review.clear()

    stars = None
    m = _RX_STARS.search(text)
    if m:
        try:
            stars = int(m.group(1))
        except Exception:
            stars = None
    if stars is None:
        stars = text.count("★") or None
    min_stars = SETTINGS.get("review_bonus_min_stars", 5)
    if stars is not None and stars < min_stars:
        return

    chat_id = msg.chat_id
    buyer_key = _buyer_key(chat_id)
    rentals = _active_rentals(buyer_key)
    if not rentals:
        return
    target = sorted(rentals, key=lambda r: r.get("expires_ts", 0), reverse=True)[0]
    if target.get("review_bonus_given"):
        return
    api = _get_api()
    if not api:
        return
    bonus_hours = SETTINGS.get("review_bonus_hours", 2)
    currency = SETTINGS.get("currency", "RUB")
    data, err = api.extend(target["uid"], bonus_hours, currency, idem=f"review-{target['uid']}")
    if err or not data:
        notify_admins(f"Бонус за отзыв не начислен: {_human_err(err or 'unknown')}",
                      extra=f"uid={target['uid']}", dedup_key=f"revbonus:{err}")
        return
    new_exp = data.get("new_expires_at")
    target["expires_raw"] = new_exp
    target["expires_ts"] = _parse_iso(new_exp)
    target["review_bonus_given"] = True
    save_rentals()
    cost_rub = float(data.get("price_rub") or 0) or _required_cost_rub(api, target.get("product_id"), bonus_hours)
    _stats_add_spent(target.get("product_id"), target.get("product_name") or "аккаунт",
                      bonus_hours, cost_rub, "review_bonus")
    _send(chat_id, fmt("text_review_bonus", hours=bonus_hours, expires=_fmt_expires(new_exp)))


def _is_own_message(cardinal: "Cardinal", msg) -> bool:
    if getattr(msg, "by_bot", False):
        return True
    own_id = getattr(cardinal.account, "id", None)
    if own_id is not None and getattr(msg, "author_id", None) == own_id:
        return True
    own_name = getattr(cardinal.account, "username", None)
    if own_name and getattr(msg, "author", None) == own_name:
        return True
    return False


def on_new_message(cardinal: "Cardinal", event: NewMessageEvent):
    msg = event.message
    if not msg:
        return
    try:
        if getattr(msg, "type", None) != MessageTypes.NON_SYSTEM:
            _handle_review(msg)
            return
        if _is_own_message(cardinal, msg) or msg.author_id == 0 or getattr(msg, "i_am_buyer", False):
            return
        if not msg.text:
            return
        chat_name = getattr(msg, "chat_name", None) or getattr(msg, "author", None)
        _handle_buyer_text(msg.chat_id, msg.text, buyer_name=chat_name)
    except Exception as e:
        logger.error(f"{LP} on_new_message error: {e}", exc_info=True)


def on_last_chat_message_changed(cardinal: "Cardinal", event):
    chat = getattr(event, "chat", None)
    if not chat:
        return
    try:
        if getattr(chat, "last_by_bot", False) or not getattr(chat, "unread", False):
            return
        last = getattr(chat, "last_message_text", None)
        if not last:
            return
        _handle_buyer_text(chat.id, last, buyer_name=getattr(chat, "name", None))
    except Exception as e:
        logger.error(f"{LP} on_last_chat_message_changed error: {e}", exc_info=True)


_LOT_RETRY_WAITS = [6.0, 15.0, 20.0]


def _set_lot_active(lot_id, active: bool) -> bool:
    if not cardinal_instance:
        return False
    last_err = None
    attempts = len(_LOT_RETRY_WAITS) + 1
    for i in range(attempts):
        try:
            lf = cardinal_instance.account.get_lot_fields(int(lot_id))
            # LotFields.active is not a reliable indicator here: FunPayAPI
            # forcibly reports it as False when the parsed database amount is
            # zero, even if the "active" checkbox is still enabled on FunPay.
            # Always submit deactivation, otherwise we can log a successful
            # hide without sending any request at all.
            raw_fields = getattr(lf, "fields", {}) or {}
            raw_active = raw_fields.get("active") == "on"
            if active and lf.active and raw_active:
                return True
            lf.active = active
            cardinal_instance.account.save_lot(lf)
            time.sleep(0.5)

            # Do not persist HIDDEN on a merely successful HTTP request. Read
            # the edit form again and confirm the actual checkbox state.
            check = cardinal_instance.account.get_lot_fields(int(lot_id))
            check_fields = getattr(check, "fields", {}) or {}
            saved_active = check_fields.get("active") == "on"
            # Some older FunPayAPI builds do not expose the raw fields dict.
            # In that case the refreshed public property is the best signal.
            if (check_fields and saved_active == active) or (
                not check_fields and bool(check.active) == active
            ):
                return True
            last_err = RuntimeError(
                f"FunPay did not apply active={active}; actual active={saved_active}"
            )
            raise last_err
        except Exception as e:
            last_err = e
            err_text = str(e).lower()
            if any(marker in err_text for marker in
                   ("предложение не найдено", "страница не найдена", "offer not found")):
                lot_key = str(lot_id)
                mappings_before = len(MAPPINGS)
                MAPPINGS[:] = [m for m in MAPPINGS if str(m.get("lot_id")) != lot_key]
                hidden_removed = HIDDEN.pop(lot_key, None) is not None
                if len(MAPPINGS) != mappings_before:
                    save_mappings()
                if hidden_removed:
                    save_hidden()
                logger.warning(f"{LP} lot {lot_id} removed from mappings: offer not found on FunPay")
                return False
            if i < attempts - 1:
                wait = _LOT_RETRY_WAITS[i]
                logger.warning(f"{LP} set_lot_active {lot_id}={active} попытка {i+1}/{attempts} не удалась: {e}. "
                               f"Жду {wait:.0f}с и повторяю.")
                time.sleep(wait)
    logger.error(f"{LP} set_lot_active {lot_id}={active} failed после {attempts} попыток: {last_err}")
    return False


_HIDE_FAIL: dict = {}        # lot_id -> (время последней неудачи, число подряд неудач)
_HIDE_COOLDOWN_S = 600


def _lot_title_of(lot_id) -> str:
    for m in MAPPINGS:
        if str(m.get("lot_id")) == str(lot_id):
            return f"{m.get('product_name') or m.get('title_keyword') or 'лот'} {m.get('hours') or '?'} ч"
    return "лот"


_REASON_RU = {
    "stock": "нет свободных аккаунтов на TDUgame",
    "min_hours": "срок лота меньше минимума игры на TDUgame",
    "balance": "на балансе TDUgame нет денег",
}


def _hide_lot(lot_id, reason: str, force: bool = False) -> bool:
    """Скрывает лот. force=True — отправить скрытие, даже если плагин считает лот уже скрытым
    (лот мог снова стать активным, а запись в hidden.json осталась)."""
    key = str(lot_id)
    if key in HIDDEN and not force:
        return True
    if _set_lot_active(lot_id, False):
        was = key in HIDDEN
        HIDDEN[key] = reason
        save_hidden()
        _HIDE_FAIL.pop(key, None)
        logger.info(f"{LP} lot {lot_id} hidden ({reason})")
        _evt("stock", "warn" if force and was else "info",
             f"Лот скрыт: {_lot_title_of(lot_id)}",
             detail=("Лот снова оказался активным, хотя плагин считал его скрытым, скрыл повторно. " if force and was else "")
                    + f"Причина: {_REASON_RU.get(reason, reason)}.",
             advice="", lot_id=key, reason=reason)
        return True
    fails = _HIDE_FAIL.get(key, (0, 0))[1] + 1
    _HIDE_FAIL[key] = (time.time(), fails)
    _evt("stock", "error", f"Не удалось скрыть лот: {_lot_title_of(lot_id)}",
         detail=f"Причина скрытия: {_REASON_RU.get(reason, reason)}. FunPay не принял изменение (попыток подряд: {fails}). "
                f"Пока лот активен, покупатель может его оплатить.",
         advice="Чаще всего это лимит запросов FunPay (429): плагин повторит через 10 минут. Если ошибка повторяется, скройте лот на FunPay вручную.",
         lot_id=key, reason=reason)
    return False


def _restore_lot(lot_id):
    if str(lot_id) not in HIDDEN:
        return
    if _set_lot_active(lot_id, True):
        HIDDEN.pop(str(lot_id), None)
        save_hidden()
        logger.info(f"{LP} lot {lot_id} restored")
        _evt("stock", "ok", f"Лот снова в продаже: {_lot_title_of(lot_id)}",
             detail="На TDUgame появились свободные аккаунты.", advice="", lot_id=str(lot_id))
    else:
        _evt("stock", "error", f"Не удалось вернуть лот в продажу: {_lot_title_of(lot_id)}",
             detail="Аккаунты на TDUgame есть, но FunPay не принял включение лота.",
             advice="Плагин повторит позже. Если не получается, включите лот на FunPay вручную.", lot_id=str(lot_id))


def _active_lot_ids() -> "set | None":
    """ID наших лотов, которые сейчас активны на FunPay (публичный список в профиле показывает только активные).
    None — не удалось получить (тогда опираемся только на hidden.json)."""
    if not cardinal_instance:
        return None
    try:
        acc = cardinal_instance.account
        prof = acc.get_user(acc.id)
        return {str(getattr(l, "id", "")) for l in (prof.get_lots() or [])}
    except Exception as e:
        logger.debug(f"{LP} active lots fetch failed: {_short_err(e)}")
        return None


def _poll_cycle():
    api = _get_api()
    if not api:
        return
    if not SETTINGS.get("lot_sync_enabled", True):
        return
    if _ac_running.get("on"):
        logger.debug(f"{LP} lot sync skipped: plugin task is running")
        return
    mapped_lots = [m for m in MAPPINGS if m.get("lot_id") and not _autolot_block_reason(m)]

    if SETTINGS.get("auto_hide_zero_balance"):
        bal = api.balance()
        if bal is not None:
            cur = SETTINGS.get("currency", "RUB")
            amount = bal.get("balance_rub" if cur == "RUB" else "balance_usd", 0) or 0
            threshold = SETTINGS.get("balance_threshold", 0)
            if amount <= threshold:
                if not _balance_alerted["low"]:
                    notify_admins(f"Баланс TDUgame на нуле/ниже порога ({amount} {cur}). "
                                  f"Скрываю лоты - пополните баланс.", dedup_key="balance_low")
                    _balance_alerted["low"] = True
                changed = 0
                for m in mapped_lots:
                    if _ac_running.get("on"):
                        logger.info(f"{LP} lot sync interrupted: plugin task started")
                        return
                    if changed >= LOT_SYNC_BATCH_SIZE:
                        break
                    if str(m["lot_id"]) in HIDDEN:
                        continue
                    _hide_lot(m["lot_id"], "balance")
                    changed += 1
                return
            else:
                if _balance_alerted["low"]:
                    notify_admins_info(f"✅ Баланс TDUgame пополнен ({amount} {cur}). Восстанавливаю лоты.")
                _balance_alerted["low"] = False
                changed = 0
                for lid, reason in list(HIDDEN.items()):
                    if reason == "balance":
                        if _ac_running.get("on"):
                            logger.info(f"{LP} lot sync interrupted: plugin task started")
                            return
                        if changed >= LOT_SYNC_BATCH_SIZE:
                            break
                        _restore_lot(lid)
                        changed += 1

    if SETTINGS.get("auto_hide_no_stock"):
        _stock_sync(api, mapped_lots)


_SYNC_BUDGET_S = 55          # сколько секунд за один проход тратим на скрытие/возврат лотов
_sync_state = {"pending": 0, "last": 0.0}


def _stock_sync(api, mapped_lots):
    """Скрывает лоты игр, которых нет в наличии (или срок лота меньше минимума игры), и возвращает их, когда всё снова в порядке.

    Что исправлено относительно прежней версии:
    • сверяемся с реальным состоянием на FunPay (активные лоты профиля), а не только с hidden.json —
      раньше лот, который снова стал активным, но числился скрытым, больше никогда не скрывался;
    • лоты, которые не получилось скрыть, уходят на паузу 10 минут и не «съедают» лимит прохода —
      раньше несколько упрямых лотов в начале списка блокировали все остальные;
    • лимит прохода по времени, а не «25 лотов»; недоделанное добирается в следующем проходе (через ~30 с)."""
    products = api.products()
    if products is None:
        _evt("stock", "warn", "Не удалось получить список игр с TDUgame",
             detail="Проверка наличия пропущена, лоты не изменялись.", advice=_advice_for("connection_error"))
        return
    avail, minh = {}, {}
    for p in products:
        try:
            pid = int(p.get("id"))
        except Exception:
            continue
        avail[pid] = int(p.get("available_accounts", 0) or 0)
        try:
            minh[pid] = int(p.get("min_hours") or 1)
        except Exception:
            minh[pid] = 1

    active = _active_lot_ids()          # None — профиль недоступен
    to_hide, to_restore = [], []
    for m in mapped_lots:
        try:
            pid = int(m.get("product_id"))
        except Exception:
            continue
        if pid not in avail:
            continue
        lid = str(m["lot_id"])
        try:
            hours = int(m.get("hours") or 0)
        except Exception:
            hours = 0
        too_short = hours > 0 and hours < minh.get(pid, 1)
        unavailable = avail[pid] <= 0 or too_short
        reason = "min_hours" if too_short else "stock"
        fail_ts = _HIDE_FAIL.get(lid, (0, 0))[0]
        cooling = fail_ts and time.time() - fail_ts < _HIDE_COOLDOWN_S
        if unavailable:
            if cooling:
                continue
            if lid in HIDDEN:
                # плагин считает лот скрытым; если он при этом активен на FunPay — скрываем заново
                if active is not None and lid in active:
                    to_hide.append((lid, reason, True))
            else:
                to_hide.append((lid, reason, False))
        elif HIDDEN.get(lid) in ("stock", "min_hours"):
            to_restore.append(lid)

    deadline = time.time() + _SYNC_BUDGET_S
    done = fails_in_row = 0
    for lid, reason, force in to_hide:
        if _ac_running.get("on"):
            logger.info(f"{LP} lot sync interrupted: plugin task started")
            return
        if time.time() > deadline or fails_in_row >= 3:
            break
        ok = _hide_lot(lid, reason, force=force)
        fails_in_row = 0 if ok else fails_in_row + 1
        done += 1
    if fails_in_row < 3:
        for lid in to_restore:
            if _ac_running.get("on"):
                return
            if time.time() > deadline:
                break
            _restore_lot(lid)
            done += 1
    left = max(0, len(to_hide) + len(to_restore) - done)
    _sync_state["pending"] = left
    _sync_state["last"] = time.time()
    if left:
        _evt("stock", "info", f"Проверка наличия: осталось обработать {left} лот(ов)",
             detail="Доделаю в следующем проходе, это нормально при большом числе лотов или лимитах FunPay.", advice="")


def _notify_expirations():
    if not SETTINGS.get("notify_rental_end", True):
        return
    now = _now()
    changed = False
    for bk, lst in list(RENTALS.items()):
        for r in lst:
            exp = r.get("expires_ts") or 0
            if exp and now >= exp and not r.get("end_notified"):
                try:
                    _send(int(bk), fmt("text_rental_ended",
                                       login=r.get("login", ""),
                                       game=r.get("product_name") or "аккаунт"))
                except Exception:
                    pass
                r["end_notified"] = True
                changed = True
    if changed:
        save_rentals()






















def _poller_loop():
    while not _poll_stop.is_set():
        wait_s = max(60, SETTINGS.get("poll_sec", 120))
        if _sync_state.get("pending"):
            wait_s = 30          # есть недоделанные скрытия — возвращаемся быстрее
        _poll_stop.wait(wait_s)
        if _poll_stop.is_set():
            break
        try:
            if SETTINGS.get("enabled"):
                _notify_expirations()
                _expire_pending_qty_orders()
            _cleanup_rentals()
            if SETTINGS.get("enabled"):
                try:
                    _auto_new_games_check()
                except Exception as e:
                    logger.error(f"{LP} auto-new-games error: {e}", exc_info=True)
            if SETTINGS.get("enabled") and (SETTINGS.get("auto_hide_no_stock") or SETTINGS.get("auto_hide_zero_balance")):
                _poll_cycle()
        except Exception as e:
            logger.error(f"{LP} poll error: {e}", exc_info=True)


def _start_poller():
    global _poll_thread
    if _poll_thread and _poll_thread.is_alive():
        return
    _poll_stop.clear()
    _poll_thread = threading.Thread(target=_poller_loop, daemon=True)
    _poll_thread.start()


def _stop_poller(*args, **kwargs):
    _poll_stop.set()


P = "tdr"


def _back_btn(cb):
    return B("◀️ Назад", callback_data=cb)


# ============================================================================
#  АВТОСОЗДАНИЕ ЛОТОВ v1
# ============================================================================

def _ac_tpl() -> dict:
    t = SETTINGS.get("ac_template")
    return t if isinstance(t, dict) else {}


def _ac_has_template() -> bool:
    t = _ac_tpl()
    return bool(t.get("summary_ru") and t.get("durations"))


def _norm_game(name: str) -> str:
    s = (name or "").lower()
    s = re.sub(r"\(.*?\)", " ", s)
    s = re.sub(r"\[.*?\]", " ", s)
    s = re.sub(r"[+:&/\\,.\-_!?|™®]", " ", s)
    s = re.sub(r"[^a-z0-9а-яё' ]", " ", s, flags=re.I)
    return re.sub(r"\s+", " ", s).strip()


def _format_time_text(hours: int, lang: str = "ru") -> str:
    if lang == "en":
        if hours >= 720 and hours % 720 == 0:
            m = hours // 720
            return f"{m} month{'s' if m != 1 else ''}"
        if hours >= 24 and hours % 24 == 0:
            d = hours // 24
            return f"{d} day{'s' if d != 1 else ''}"
        return f"{hours}h"
    if hours >= 720:
        m = max(1, round(hours / 720))
        if m == 1:
            return "1 месяц"
        if 2 <= m % 10 <= 4 and m % 100 not in (12, 13, 14):
            return f"{m} месяца"
        return f"{m} месяцев"
    if hours >= 24 and hours % 24 == 0:
        d = hours // 24
        if d == 1:
            return "1 день"
        if 2 <= d % 10 <= 4 and d % 100 not in (12, 13, 14):
            return f"{d} дня"
        return f"{d} дней"
    if hours % 10 == 1 and hours % 100 != 11:
        return f"{hours} час"
    if hours % 10 in (2, 3, 4) and hours % 100 not in (12, 13, 14):
        return f"{hours} часа"
    return f"{hours} часов"


def _apply_placeholders(text: str, game_name: str, hours: int, lang: str = "ru") -> str:
    if not text:
        return text
    t = _format_time_text(hours, lang)
    g = game_name or ""
    out = text
    for ph in (PH_TIME, "renttime"):
        out = re.sub(re.escape(ph), t, out, flags=re.I)
    for ph in (PH_GAME, "rentgame"):
        out = re.sub(re.escape(ph), g, out, flags=re.I)
    return out


def _apply_title_placeholders(text: str, game_name: str, hours: int, lang: str = "ru",
                              limit: int = FP_SUMMARY_LIMIT) -> str:
    if not text:
        return ""
    time_text = _format_time_text(hours, lang)
    out = text
    for ph in (PH_TIME, "renttime"):
        out = re.sub(re.escape(ph), time_text, out, flags=re.I)
    game_rx = re.compile(rf"{re.escape(PH_GAME)}|rentgame", re.I)
    game = _strip_rent_code_from_title(game_name or "")
    if game_rx.search(out):
        lo, hi, best = 0, len(game), ""
        while lo <= hi:
            mid = (lo + hi) // 2
            part = game if mid >= len(game) else game[:max(1, mid - 3)].rstrip(" .,;:!?|/-_") + "..."
            cand = re.sub(r"\s+", " ", game_rx.sub(part, out)).strip()
            cand = _strip_rent_code_from_title(cand)
            if len(cand) <= limit:
                best = cand
                lo = mid + 1
            else:
                hi = mid - 1
        if best:
            return best
    return _limit_lot_title_keep_tail(_apply_placeholders(text, game_name, hours, lang), time_text, limit)


def _normalize_short_en(text: str) -> str:
    text = _ascii(_strip_rent_code_from_title(text))
    return _limit_lot_title(text, FP_SUMMARY_LIMIT)


def _cost_rub(api: "TDUgameAPI | None", prod: dict, hours: int) -> float:
    pid = int(prod.get("id") or 0)
    key = (pid, int(hours))
    now = time.time()
    c = _price_cache.get(key)
    if c and now - c["ts"] < AC_CACHE_TTL:
        return c["rub"]
    rub = None
    if api is not None and pid:
        data = api.calculate_price(pid, hours)
        if data and data.get("total_rub") is not None:
            try:
                rub = float(data["total_rub"])
                _dbg(f"calc-price pid={pid} {hours}ч → {rub}₽ "
                     f"(скидка {data.get('discount_percent')}%)")
            except Exception:
                rub = None
    if rub is None:
        pph_rub = float(prod.get("price_per_hour_rub") or prod.get("price_per_hour") or 0)
        rub = pph_rub * hours
        _dbg(f"calc-price fallback pid={pid} {hours}ч → {rub}₽ (price_per_hour_rub×часы)")
    _price_cache[key] = {"ts": now, "rub": rub}
    return rub


def _get_commission(subcat_id: int) -> float:
    sid = int(subcat_id)
    if sid in _commission_cache:
        return _commission_cache[sid]
    k = 1.0
    try:
        acc = cardinal_instance.account
        headers = {"accept": "*/*", "x-requested-with": "XMLHttpRequest"}
        data = {"nodeId": sid, "price": 1000}
        r = acc.method("post", "lots/calc", headers, data)
        j = r.json()
        methods = j.get("methods") or []
        cur = SETTINGS.get("currency", "RUB")
        matching = [m for m in methods if m.get("unit") == cur]
        if matching:
            k = float(min(matching, key=lambda x: float(x.get("price", 1000))).get("price", 1000)) / 1000.0
    except Exception as e:
        logger.debug(f"{LP} [AC] commission sub={sid}: {e}")
    _commission_cache[sid] = k
    return k


def _money(x) -> str:
    try:
        v = round(float(x) + 1e-9, 2)
    except Exception:
        return str(x)
    if v == int(v):
        return f"{int(v)}"
    return f"{v:.2f}".rstrip("0").rstrip(".")


def _money_dash(x) -> str:
    if x is None:
        return "—"
    return _money(x)


def _pct(x) -> str:
    try:
        v = round(float(x), 2)
    except Exception:
        return str(x)
    if v == int(v):
        return f"{int(v)}"
    return f"{v:.2f}".rstrip("0").rstrip(".")


def _min_price_floor(tpl: "dict | None" = None) -> float:
    try:
        v = float(SETTINGS.get("ac_min_price", 2) or 0)
    except Exception:
        v = 2.0
    return v if v >= 1.0 else 1.0


def _public_price_rub(api: "TDUgameAPI | None", prod: dict, hours: int, tpl: "dict | None" = None) -> float:
    t = tpl or _ac_tpl()
    floor = _min_price_floor(t)
    if t.get("price_mode") == "fixed":
        fp = t.get("fixed_prices") or {}
        val = fp.get(str(hours), fp.get(hours))
        if val is None:
            val = t.get("fixed_public_rub")
        return round(float(max(floor, float(val or floor))), 2)
    product_markup = (SETTINGS.get("product_markup_percent") or {}).get(str(prod.get("id")))
    markup = float(product_markup if product_markup is not None else t.get("markup_percent", 50) or 0)
    base = _cost_rub(api, prod, hours)
    return round(float(max(floor, base * (1 + markup / 100.0))), 2)


def _lot_input_price(public_rub: float, subcat_id: int) -> float:
    return float(max(_min_price_floor(), public_rub))


def _merge_map_overrides(data: dict) -> dict:
    overrides = _load_json(MAP_OVERRIDES_FILE, {})
    extra = overrides.get("games") if isinstance(overrides, dict) else None
    if not extra:
        return data
    games = list(data.get("games") or [])
    by_pid = {}
    for idx, game in enumerate(games):
        try:
            by_pid[int(game.get("product_id"))] = idx
        except (TypeError, ValueError):
            continue
    for game in extra:
        try:
            pid = int(game.get("product_id"))
        except (TypeError, ValueError):
            continue
        idx = by_pid.get(pid)
        if idx is None:
            by_pid[pid] = len(games)
            games.append(game)
        else:
            games[idx] = game
    data["games"] = games
    return data


def _remember_map_override(product_id: int, product_name: str, route: dict) -> None:
    overrides = _load_json(MAP_OVERRIDES_FILE, {})
    games = overrides.get("games") if isinstance(overrides, dict) else None
    if not isinstance(games, list):
        games = []
    entry = {
        "product_id": int(product_id),
        "product_name": product_name,
        "funpay": {
            "category_id": int(route["category_id"]),
            "subcategory_id": int(route["subcategory_id"]),
            "subcategory_type": "common",
            "fields": dict(route.get("fields") or {}),
        },
    }
    games = [g for g in games if str(g.get("product_id")) != str(product_id)]
    games.append(entry)
    _save_json(MAP_OVERRIDES_FILE, {"games": games})


def _repair_mojibake_value(value):
    if isinstance(value, str) and any(ch in value for ch in ("Ð", "Ñ", "Â", "â")):
        try:
            fixed = value.encode("latin1").decode("utf-8")
            if fixed and "\ufffd" not in fixed:
                return fixed
        except Exception:
            pass
    if isinstance(value, dict):
        return {k: _repair_mojibake_value(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_repair_mojibake_value(v) for v in value]
    return value


def _convert_map_games(games: list) -> dict:
    """Ответ TDUgame /funpay-map (camelCase) -> внутренний формат карты."""
    out = []
    for g in games:
        fp = g.get("funpay") or {}
        try:
            out.append({
                "product_id": int(g.get("productId")),
                "product_name": g.get("name") or "",
                "name": g.get("name") or "",
                "aliases": list(g.get("aliases") or []),
                "funpay": {
                    "category_id": int(fp.get("categoryId")),
                    "subcategory_id": int(fp.get("subcategoryId")),
                    "subcategory_type": fp.get("subcategoryType") or "common",
                    "fields": dict(fp.get("fields") or {}),
                },
            })
        except (TypeError, ValueError):
            continue
    return {"games": out}


def _fetch_map(force: bool = False) -> "dict | None":
    now = time.time()
    if not force and _map_cache["data"] and now - _map_cache["ts"] < 600:
        return _map_cache["data"]
    data = None
    api = _get_api()
    if api is not None:
        try:
            games = api.funpay_map()
            if games:
                data = _convert_map_games(games)
                _save_json(MAP_FILE, data)
        except Exception as e:
            _dbg(f"map: {e}")
    if data:
        logger.info(f"{LP} [AC] карта загружена ({len(data.get('games', []))} игр)")
        _map_cache["warned"] = False
    elif not _map_cache.get("warned"):
        logger.info(f"{LP} [AC] карта категорий недоступна — локальный кэш {MAP_FILE}")
        _map_cache["warned"] = True
    if data is None:
        data = _load_json(MAP_FILE, None)
    if data:
        data = _repair_mojibake_value(data)
        data = _merge_map_overrides(data)
        _save_json(MAP_FILE, data)
        _map_cache["data"] = data
        _map_cache["ts"] = now
    return data


def _diag_add(diagnostics: "list | None", reason: str, **fields) -> None:
    if diagnostics is not None:
        item = {"reason": reason}
        item.update(fields)
        diagnostics.append(item)


def _map_name_candidates(g: dict) -> list[str]:
    names = []
    for key in ("product_name", "name", "product_name"):
        val = (g.get(key) or "").strip()
        if val:
            names.append(val)
    for val in (g.get("aliases") or []):
        val = str(val or "").strip()
        if val:
            names.append(val)
    seen, out = set(), []
    for name in names:
        low = name.lower()
        if low not in seen:
            seen.add(low)
            out.append(name)
    return out


def _soft_name_match(map_name: str, product_name: str) -> bool:
    mt = _norm_roman(map_name).split()
    pt = _norm_roman(product_name).split()
    if not mt or not pt:
        return False
    if mt == pt:
        return True
    short, long = (mt, pt) if len(mt) <= len(pt) else (pt, mt)
    if len(short) >= 2 and long[:len(short)] == short:
        return True
    if len(short) == 1 and len(short[0]) >= 6 and long and long[0] == short[0]:
        return True
    return False


def _discover_funpay_game_route(game_name: str) -> dict | None:
    """Find an account subcategory when the remote map does not know a game."""
    account = getattr(cardinal_instance, "account", None)
    categories = getattr(account, "_Account__categories", None)
    subcategories = getattr(account, "_Account__subcategories", None)
    if not categories or not subcategories:
        return None
    for category in categories:
        if not _soft_name_match(game_name, getattr(category, "name", "")):
            continue
        for subcategory in subcategories:
            if getattr(getattr(subcategory, "category", None), "id", None) != getattr(category, "id", None):
                continue
            if str(getattr(subcategory, "name", "")).strip().lower() != "аккаунты":
                continue
            if getattr(getattr(subcategory, "type", None), "name", "") != "COMMON":
                continue
            return {
                "category_id": int(category.id),
                "subcategory_id": int(subcategory.id),
                "fields": {"fields[type]": "Аренда"},
            }
    return None


def _map_games_available(api: "TDUgameAPI", diagnostics: "list | None" = None) -> list:
    mp = _fetch_map()
    if not mp:
        _diag_add(diagnostics, "карта недоступна")
        return []
    prods = _products(api) or []
    by_id = {int(p["id"]): p for p in prods if re.fullmatch(r"-?\d+", str(p.get("id", "")))}
    by_name = {}
    by_norm = {}
    for p in prods:
        nm = str(p.get("name", "")).strip()
        if not nm:
            continue
        by_name[nm.lower()] = p
        nk = _norm_roman(nm)
        if nk:
            by_norm.setdefault(nk, p)
    out = []
    for g in mp.get("games", []):
        try:
            map_pid = int(g.get("product_id", -1))
        except Exception:
            map_pid = -1
        p = by_id.get(map_pid)
        names = _map_name_candidates(g)
        if not p:
            for nm in names:
                p = by_name.get(str(nm or "").strip().lower())
                if p:
                    break
        if not p:
            for nm in names:
                nk = _norm_roman(str(nm or ""))
                p = by_norm.get(nk) if nk else None
                if p:
                    break
        if not p:
            for nm in names:
                p = next((prod for prod in prods if _soft_name_match(str(nm or ""), str(prod.get("name", "")))), None)
                if p:
                    _diag_add(diagnostics, "мягкое совпадение имени",
                              map_name=nm, product_name=p.get("name"), product_id=p.get("id"))
                    break
        if p:
            out.append((g, p))
        else:
            _diag_add(diagnostics, "товар из карты не найден в TDUgame",
                      map_product_id=map_pid if map_pid >= 0 else None,
                      name=(names[0] if names else g.get("name") or "без названия"))
    return out


def _load_misc_games() -> dict:
    if _misc_cache:
        return _misc_cache
    data = _load_json(MISC_GAMES_FILE, {})
    by_name, by_norm = {}, {}
    for g in data.get("games", []):
        nm = (g.get("name") or "").strip()
        if not nm:
            continue
        by_name[nm.lower()] = g
        by_norm[_norm_game(nm)] = g
    _misc_cache.update({"meta": data, "by_name": by_name, "by_norm": by_norm})
    return _misc_cache


def _name_overrides() -> dict:
    data = _load_json(NAME_OVERRIDES_FILE, {})
    out = {}
    if isinstance(data, dict):
        for k, v in data.items():
            if k and v:
                out[str(k).strip().lower()] = str(v)
    return out


def _display_name(product_name_raw: str) -> str:
    ov = _name_overrides()
    return ov.get((product_name_raw or "").strip().lower(), product_name_raw or "")


def _misc_any_game_id() -> "str | None":
    m = _load_misc_games().get("by_name") or {}
    for key in ("любая игра", "любая", "any game", "other"):
        g = m.get(key)
        if g and g.get("id"):
            return str(g["id"])
    return None


def _match_misc_game(product_name_raw: str) -> "dict | None":
    m = _load_misc_games()
    kn = (product_name_raw or "").strip().lower()
    if kn in m["by_name"]:
        return m["by_name"][kn]
    nn = _norm_game(product_name_raw)
    if nn in m["by_norm"]:
        return m["by_norm"][nn]
    kt = nn.split()
    best, best_len = None, 0
    for nm, g in m["by_norm"].items():
        ft = nm.split()
        if not ft:
            continue
        if len(ft) == 1:
            if len(ft[0]) < 4:
                continue
            if ft[0] not in kt:
                continue
        elif kt[: len(ft)] != ft:
            continue
        if len(ft) > best_len:
            best_len, best = len(ft), g
    return best


def _all_sellable_games(api: "TDUgameAPI", diagnostics: "list | None" = None) -> list:
    meta = _load_misc_games().get("meta") or {}
    misc_sub = int(meta.get("subcategory_id") or 451)
    misc_field = meta.get("field") or "server_id"
    mapped_pids = set()
    out = []

    def _add_misc(pid, raw_name):
        disp = _display_name(raw_name) or raw_name
        mg = _match_misc_game(disp) or _match_misc_game(raw_name)
        fields = {"fields[type]": "Аренда"}
        if mg:
            fields[misc_field] = str(mg["id"])
        else:
            any_id = _misc_any_game_id()
            if any_id:
                fields[misc_field] = any_id
        out.append({
            "product_id": pid,
            "product_name": raw_name,
            "game_name": (mg.get("name") if mg else None) or disp,
            "subcategory_id": misc_sub,
            "category_id": None,
            "fields": fields,
            "source": "misc",
        })

    for g, prod in _map_games_available(api, diagnostics):
        pid = int(prod["id"])
        gname = prod.get("name") or g.get("product_name") or ""
        mapped_pids.add(pid)
        if not _norm_game(gname):
            _dbg(f"игра без латиницы/кириллицы → прочие игры: {gname!r} → {_display_name(gname)!r} (pid={pid})")
            _add_misc(pid, gname)
            continue
        fp = g.get("funpay", {})
        fields = _repair_mojibake_value(dict(fp.get("fields") or {}))
        out.append({
            "product_id": pid,
            "product_name": gname,
            "game_name": gname,
            "subcategory_id": int(fp["subcategory_id"]),
            "category_id": fp.get("category_id"),
            "fields": fields,
            "source": "map",
        })

    for prod in (_products(api) or []):
        pid = int(prod["id"])
        if pid in mapped_pids:
            continue
        raw = prod.get("name", "")
        mg = _match_misc_game(_display_name(raw)) or _match_misc_game(raw)
        if not mg:
            route = _discover_funpay_game_route(raw)
            if route:
                _diag_add(diagnostics, "маршрут найден в категориях FunPay", product_id=pid, name=raw,
                          category_id=route["category_id"], subcategory_id=route["subcategory_id"])
                out.append({
                    "product_id": pid,
                    "product_name": raw,
                    "game_name": raw,
                    "subcategory_id": route["subcategory_id"],
                    "category_id": route["category_id"],
                "fields": route["fields"],
                "source": "discovered",
            })
                _remember_map_override(pid, raw, route)
                continue
            _diag_add(diagnostics, "нет маршрута в карте и категориях FunPay", product_id=pid, name=raw)
            continue
        fields = {"fields[type]": "Аренда", misc_field: str(mg["id"])}
        out.append({
            "product_id": pid,
            "product_name": raw,
            "game_name": mg.get("name") or raw,
            "subcategory_id": misc_sub,
            "category_id": None,
            "fields": fields,
            "source": "misc",
        })
    return out


def _existing_lot_keys() -> set:
    keys = set()
    for m in MAPPINGS:
        pid, hrs = m.get("product_id"), m.get("hours")
        if pid is not None and hrs is not None:
            try:
                keys.add((int(pid), int(hrs), m.get("placement") or "main"))
            except Exception:
                continue
    return keys


def _mapping_key(product_id, hours, placement: str = "main") -> "tuple[int, int, str] | None":
    try:
        return int(product_id), int(hours), placement or "main"
    except Exception:
        return None


def _mapping_exists(product_id, hours, placement: str = "main") -> bool:
    key = _mapping_key(product_id, hours, placement)
    if key is None:
        return False
    return key in _existing_lot_keys()


def _rent_code_exists(code: str, ignore_idx: "int | None" = None) -> bool:
    code = (code or "").strip().lower()
    if not code:
        return False
    for idx, m in enumerate(MAPPINGS):
        if ignore_idx is not None and idx == ignore_idx:
            continue
        if code in _mapping_rent_codes(m):
            return True
    return False


def _lot_title_norm(text: str) -> str:
    return re.sub(r"\s+", " ", _strip_rent_code_from_title(text)).strip().casefold()


def _my_subcat_lots(subcat_id: int, cache: "dict | None" = None) -> list:
    sid = int(subcat_id)
    if cache is not None and sid in cache:
        return cache[sid]
    lots = []
    try:
        lots = cardinal_instance.account.get_my_subcategory_lots(sid)
    except Exception as e:
        logger.debug(f"{LP} [AC] my lots fail sub={sid}: {_short_err(e)}")
        try:
            all_lots = cardinal_instance.profile.get_lots()
            lots = [
                lot for lot in (all_lots or [])
                if str(getattr(getattr(lot, "subcategory", None), "id", "")) == str(sid)
                or f"/lots/{sid}/" in (getattr(lot, "html", "") or "")
            ]
        except Exception as e2:
            logger.debug(f"{LP} [AC] profile lots fallback fail sub={sid}: {_short_err(e2)}")
    if cache is not None:
        cache[sid] = lots
    return lots


def _find_existing_own_lot(subcat_id: int, summary_ru: str, cache: "dict | None" = None) -> "int | None":
    want = _lot_title_norm(summary_ru)
    if not want:
        return None
    for lot in _my_subcat_lots(subcat_id, cache):
        if _lot_title_norm(getattr(lot, "description", "") or getattr(lot, "title", "")) == want:
            try:
                return int(lot.id)
            except Exception:
                return lot.id
    return None


def _find_existing_own_lot_with_code(subcat_id: int, summary_ru: str, rent_code: str,
                                     cache: "dict | None" = None) -> "int | None":
    want = _lot_title_norm(summary_ru)
    code = (rent_code or "").strip().lower()
    if not want or not code:
        return None
    for lot in _my_subcat_lots(subcat_id, cache):
        title = getattr(lot, "description", "") or getattr(lot, "title", "")
        text = "\n".join(str(x or "") for x in (title, getattr(lot, "html", ""))).lower()
        if _lot_title_norm(title) == want and code in text:
            try:
                return int(lot.id)
            except Exception:
                return lot.id
    return None


def _append_mapping_for_lot(lot_id, item: dict) -> bool:
    if not lot_id:
        return False
    lid = str(lot_id)
    if any(str(m.get("lot_id")) == lid for m in MAPPINGS if m.get("lot_id")):
        return False
    placement = item.get("placement") or "main"
    key = _mapping_key(item.get("product_id"), item.get("hours"), placement)
    if key is not None and key in _existing_lot_keys():
        return False
    rent_code = item.get("rent_code") or _make_rent_code(item.get("product_id"), item.get("hours"), "" if placement == "main" else placement)
    if _rent_code_exists(rent_code):
        return False
    MAPPINGS.append({
        "lot_id": lid,
        "subcategory_id": item.get("subcategory_id"),
        "title_keyword": (item.get("game_name") or "")[:40],
        "product_id": item.get("product_id"),
        "product_name": item.get("product_name"),
        "hours": item.get("hours"),
        "rent_code": rent_code,
        "placement": placement,
        "currency": SETTINGS.get("currency", "RUB"),
        "auto_created": True,
        **({"custom_hours": True} if item.get("custom_hours") else {}),
    })
    save_mappings()
    return True


def _fp_public_lots(subcat_id: int) -> list:
    sid = int(subcat_id)
    c = _pub_lots_cache.get(sid)
    now = time.time()
    if c and now - c["ts"] < AC_CACHE_TTL:
        return c["lots"]
    out = []
    try:
        lots = cardinal_instance.account.get_subcategory_public_lots(SubCategoryTypes.COMMON, sid)
        for l in lots:
            pr = float(getattr(l, "price", 0) or 0)
            if pr <= 0:
                continue
            desc = (getattr(l, "description", "") or "").lower()
            server = (getattr(l, "server", "") or "")
            server = server.lower() if isinstance(server, str) else ""
            out.append((pr, desc, server))
        _pub_lots_cache[sid] = {"ts": now, "lots": out}
        _dbg(f"FP подкат {sid}: загружено {len(out)} лотов (кэш 15 мин)")
    except Exception as e:
        logger.debug(f"{LP} [AC] public lots fail sub={sid}: {_short_err(e)}")
    return out


_RX_MINORDER = re.compile(r"от\s*\d+\s*(?:час\w*|ч\b|дн\w*|д\b|сут\w*|нед\w*|мес\w*)", re.I)
_RX_BONUS = re.compile(r"[+(]?\s*\d+\s*(?:час\w*|ч\b|дн\w*|д\b)?\s*за\s*отзыв", re.I)


def _duration_regex(hours: int) -> "re.Pattern":
    pats = []
    h = hours
    pats.append(rf"(?<!\d){h}\s*ч(?:ас(?:а|ов)?)?\b")
    if h % 24 == 0:
        d = h // 24
        pats.append(rf"(?<!\d){d}\s*д(?:\b|ень|ня|ней|н\.)")
        pats.append(rf"(?<!\d){d}\s*сут")
        if h == 24:
            pats.append(r"\bсуток\b|\bсутки\b")
    if h % 168 == 0:
        w = h // 168
        pats.append(rf"(?<!\d){w}\s*недел")
    if h % 720 == 0:
        mo = h // 720
        pats.append(rf"(?<!\d){mo}\s*мес(?:яц|яца|яцев)?\b")
    return re.compile("|".join(pats), re.I)


def _fp_lowest_prices_for(subcat_id: int, hours: int, game_name: "str | None" = None, n: int = 5) -> list:
    lots = _fp_public_lots(subcat_id)
    if not lots:
        return []
    rx = _duration_regex(hours)
    gtok = None
    if game_name:
        gtok = _norm_game(game_name).split()
        gtok = gtok[0] if gtok else None
    matched = []
    for pr, desc, server in lots:
        if game_name and gtok and gtok not in desc and gtok not in server:
            continue
        clean = _RX_MINORDER.sub(" ", desc)
        clean = _RX_BONUS.sub(" ", clean)
        if rx.search(clean):
            matched.append(pr)
    if not matched:
        return []
    return sorted(matched)[:n]


def _fp_lowest_prices(subcat_id: int, n: int = 5) -> list:
    lots = _fp_public_lots(subcat_id)
    return sorted(p for p, _, _ in lots)[:n]


def _ascii(text: str, keep_formatting: bool = False) -> str:
    if not text:
        return ""
    text = re.sub(r"[\u0400-\u04FF]", "", text)
    text = (text.replace("•", "-")
            .replace("—", "-")
            .replace("–", "-")
            .replace("→", "->")
            .replace("“", '"')
            .replace("”", '"')
            .replace("’", "'"))
    if keep_formatting:
        text = text.replace("\r\n", "\n").replace("\r", "\n")
        text = re.sub(r"[^\x09\x0A\x20-\x7E]", " ", text)
        lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n")]
        while lines and not lines[0]:
            lines.pop(0)
        while lines and not lines[-1]:
            lines.pop()
        return "\n".join(lines)
    text = re.sub(r"[^\x20-\x7E]", " ", text)
    return re.sub(r"\s+", " ", text).strip()


def _compose_limited_title(prefix: str, suffix: str, limit: int = FP_SUMMARY_LIMIT) -> str:
    prefix = re.sub(r"\s+", " ", str(prefix or "")).strip()
    suffix = re.sub(r"\s+", " ", str(suffix or "")).strip()
    if suffix:
        suffix = " " + suffix
    if len(prefix) + len(suffix) <= limit:
        return (prefix + suffix).strip()
    keep = max(1, limit - len(suffix) - 3)
    if keep < 8:
        return _limit_lot_title((prefix + suffix).strip(), limit)
    return (prefix[:keep].rstrip(" .,;:!?|/-_") + "..." + suffix).strip()


def _en_title_ok(text: str) -> bool:
    compact = re.sub(r"[^a-z0-9]+", "", (text or "").lower())
    return len(compact) >= 8 and bool(re.search(r"[a-z]", compact))


def _default_summary_en(game_name: str, hours: int) -> str:
    game = _ascii(game_name) or "Game"
    return _compose_limited_title(f"{game} rental account", f"| Steam | {_format_time_text(hours, 'en')}")


def _default_desc_en(game_name: str, hours: int) -> str:
    game = _ascii(game_name) or "game"
    return (
        f"Rent a {game} account for {_format_time_text(hours, 'en')}.\n"
        "Instant automatic delivery after payment.\n"
        "Steam Guard code: use !code login in chat."
    )


def _ensure_full_game_line(text: str, game_name: str, lang: str = "ru") -> str:
    game = re.sub(r"\s+", " ", str(game_name or "")).strip()
    if not game:
        return text or ""
    if lang == "en":
        game_line_value = _ascii(game).strip() or "the selected game"
        line = f"Game: {game_line_value}"
    else:
        line = f"Игра: {game}"
    base = (text or "").strip()
    if line.lower() in base.lower():
        return base
    return f"{line}\n\n{base}" if base else line


def _durations_for(prod: dict, tpl: "dict | None" = None) -> list:
    t = tpl or _ac_tpl()
    want = t.get("durations") or [1, 24, 72, 168]
    mn = int(prod.get("min_hours") or 1)
    mx = int(prod.get("max_hours") or 720)
    return sorted((h for h in want if mn <= h <= mx))


def _build_lot_texts(game_name: str, hours: int, tpl: "dict | None" = None,
                     product_id=None, placement: str = ""):
    t = tpl or _ac_tpl()
    sru = _apply_title_placeholders(t.get("summary_ru", ""), game_name, hours, "ru")
    sen = _apply_title_placeholders(t.get("summary_en", ""), game_name, hours, "en")
    sen = _normalize_short_en(sen)
    fru = _apply_placeholders(t.get("desc_ru", ""), game_name, hours, "ru")
    fen = _ascii(_apply_placeholders(t.get("desc_en", ""), game_name, hours, "en"), keep_formatting=True)
    fru = _strip_public_contacts(fru)
    fen = _strip_public_contacts(fen)
    if not _en_title_ok(sen):
        sen = _default_summary_en(game_name, hours)
    if not fen:
        fen = _default_desc_en(game_name, hours)
    if not sru:
        sru = _compose_limited_title(game_name, _format_time_text(hours))
    if not sen:
        sen = "RENTAL ACCOUNT INSTANT AUTO DELIVERY"
    fru = _ensure_full_game_line(fru, game_name, "ru")
    fen = _ensure_full_game_line(fen, game_name, "en")
    if product_id is not None:
        code = _make_rent_code(product_id, hours, "" if not placement or placement == "main" else placement)
        fru = _append_rent_code_full(fru, code)
        fen = _append_rent_code_full(fen, code)
    return sru, sen, fru, fen


def _node_form(subcat_id: int) -> dict:
    sid = int(subcat_id)
    c = _node_form_cache.get(sid)
    now = time.time()
    if c and now - c["ts"] < AC_CACHE_TTL:
        return c["form"]
    account = cardinal_instance.account
    form = {"defaults": {}, "selects": {}, "required": set()}
    try:
        from bs4 import BeautifulSoup
        r = account.method("get", f"lots/offerEdit?node={sid}", {}, {}, raise_not_200=True)
        bs = BeautifulSoup(r.content.decode(), "lxml")
        f = bs.find("form", class_="form-offer-editor") or bs
        for inp in f.find_all("input"):
            name = inp.get("name")
            if not name or name == "query":
                continue
            if inp.get("type") == "checkbox":
                if inp.has_attr("checked"):
                    form["defaults"][name] = "on"
            else:
                form["defaults"][name] = inp.get("value") or ""
            if inp.get("required") is not None or "required" in (inp.get("class") or []):
                form["required"].add(name)
        for ta in f.find_all("textarea"):
            if ta.get("name"):
                form["defaults"][ta["name"]] = ta.text or ""
        for sel in f.find_all("select"):
            name = sel.get("name")
            if not name:
                continue
            parent = sel.find_parent(class_="form-group")
            pcls = (parent.get("class") or []) if parent else []
            if "hidden" in pcls and "lot-field" not in pcls:
                continue
            opts = []
            selected_val = ""
            for o in sel.find_all("option"):
                val = o.get("value") or ""
                opts.append((val, (o.text or "").strip()))
                if o.get("selected") is not None and val:
                    selected_val = val
            form["selects"][name] = opts
            form["defaults"][name] = selected_val
            if sel.get("required") is not None or "required" in (sel.get("class") or []):
                form["required"].add(name)
        csrf = f.find("input", {"name": "csrf_token"})
        if csrf and csrf.get("value"):
            account.csrf_token = csrf["value"]
        _dbg(f"_node_form sub={sid}: selects={list(form['selects'].keys())} required={sorted(form['required'])}")
    except Exception as e:
        logger.debug(f"{LP} [AC] node_form sub={sid}: {_short_err(e)}")
    _node_form_cache[sid] = {"ts": now, "form": form}
    return form


_PLATFORM_WORDS = {"pc", "пк", "ps", "ps3", "ps4", "ps5", "xbox", "switch", "steam",
                   "origin", "epic", "windows", "win", "mac", "computer", "компьютер"}
_PLATFORM_DETECT = _PLATFORM_WORDS | {"playstation", "плейстейшн", "macos", "linux", "линукс",
                                      "android", "андроид", "ios", "айос", "mobile", "мобил",
                                      "телефон", "nintendo", "нинтендо", "ps2"}
_PLATFORM_AVOID = ("android", "андроид", "ios", "айос", "mobile", "мобил", "телефон",
                   "ps", "xbox", "switch", "nintendo", "playstation", "плейстейшн")


def _looks_platform(nonempty: list) -> bool:
    if not nonempty:
        return False
    hits = 0
    for _v, t in nonempty:
        tl = (t or "").strip().lower()
        if any(p == tl or p in tl for p in _PLATFORM_DETECT):
            hits += 1
    return hits >= max(1, (len(nonempty) + 1) // 2)
_ROMAN = {"i": "1", "ii": "2", "iii": "3", "iv": "4", "v": "5", "vi": "6", "vii": "7",
          "viii": "8", "ix": "9", "x": "10"}
_EDITIONS = [
    ("ultimate", "ультимейт", "ультиматив"),
    ("collector", "коллекцион"),
    ("legendary", "легендар"),
    ("definitive", "дефинитив"),
    ("complete", "complet", "комплит", "полное", "полная"),
    ("game of the year", "goty", "года"),
    ("premium", "премиум"),
    ("deluxe", "делюкс", "делюх"),
    ("gold", "голд", "золот"),
    ("anniversary", "юбилейн", "годовщин"),
    ("enhanced", "энханс", "улучшен"),
    ("special", "спешл", "специальн"),
    ("standard", "standart", "стандарт", "обычн"),
]


def _norm_roman(s: str) -> str:
    s = _norm_game(s)
    return " ".join(_ROMAN.get(tok, tok) for tok in s.split())


def _pick_option(name: str, opts: list, game_name: str = "") -> str:
    nonempty = [(v, (t or "").strip()) for v, t in opts if v]
    if not nonempty:
        return ""
    low = name.lower()
    if "platform" in low or _looks_platform(nonempty):
        for v, t in nonempty:
            if "steam" in t.lower():
                return v
        for v, t in nonempty:
            tl = t.lower()
            if ("windows" in tl or "win" in tl) and "epic" not in tl:
                return v
        for v, t in nonempty:
            tl = t.lower()
            if ("pc" in tl or "пк" in tl or "computer" in tl or "компьютер" in tl) and "epic" not in tl:
                return v
        for v, t in nonempty:
            tl = t.lower()
            if "epic" not in tl and "origin" not in tl and not any(a in tl for a in _PLATFORM_AVOID):
                return v
        return nonempty[0][0]
    if "server" in low or low.endswith("[game]") or low == "game":
        g = set(_norm_roman(game_name).split())
        if g:
            best, best_key = None, (0, 0.0, 0)
            for v, t in nonempty:
                tt = set(_norm_roman(t).split())
                if not tt:
                    continue
                inter = len(g & tt)
                sc = inter / len(tt)
                tl = t.lower()
                if "steam" in tl:
                    plat = 3
                elif ("pc" in tl or "пк" in tl or "windows" in tl) and "epic" not in tl:
                    plat = 2
                elif any(b in tl for b in ("ps", "xbox", "switch", "epic", "playstation", "плейстейшн")):
                    plat = -1
                else:
                    plat = 1
                key = (inter, sc, plat)
                if inter and key > best_key:
                    best_key, best = key, v
            if best and best_key[0] >= 1 and best_key[1] >= 0.5:
                return best
        for key in ("steam",):
            for v, t in nonempty:
                if key in t.lower():
                    return v
        for key in ("pc", "пк"):
            for v, t in nonempty:
                if key in t.lower():
                    return v
        return nonempty[0][0]
    if "offer" in low or "type" in low:
        for v, t in nonempty:
            tl = t.lower()
            if "аренд" in tl or "rent" in tl:
                return v
        gl = (game_name or "").lower()
        for variants in _EDITIONS:
            if any(k in gl for k in variants):
                for v, t in nonempty:
                    if any(k in t.lower() for k in variants):
                        return v
                break
        for key in ("стандарт", "standart", "standard", "обычн", "basic", "base", "common"):
            for v, t in nonempty:
                if key in t.lower():
                    return v
        return nonempty[0][0]
    return nonempty[0][0]


def _field_hours_from_form(nf: dict) -> int:
    text = " ".join(str(nf.get(key) or "") for key in (
        "fields[summary][ru]",
        "fields[summary][en]",
        "fields[desc][ru]",
        "fields[desc][en]",
    ))
    match = re.search(r"\b(1|3|6|12|24|72|168)\s*(?:h|hour|hours|ч|час)", text, re.I)
    return int(match.group(1)) if match else 1


def _fill_missing_from_error(nf: dict, node_form: dict, err_text: str, game_name: str) -> bool:
    pairs = re.findall(r'\[\s*"([^"]+)"\s*,\s*"([^"]*)"\s*\]', err_text or "")
    changed = False
    selects = node_form.get("selects") or {}
    for fname, _msg in pairs:
        cur = nf.get(fname)
        msg_low = str(_msg or "").lower()
        too_long = "слишком длин" in msg_low or "too long" in msg_low or "long text" in msg_low
        bad_english = (
            "некорректного английского" in msg_low
            or ("слишком коротк" in msg_low and "английск" in msg_low)
            or "incorrect english" in msg_low
            or "invalid english" in msg_low
            or ("too short" in msg_low and "english" in msg_low)
        )
        if bad_english and "desc][en" in fname:
            nf[fname] = _default_desc_en(game_name, _field_hours_from_form(nf))
            changed = True
            _dbg(f"  заменил некорректное английское описание {fname}")
            continue
        if bad_english and "summary][en" in fname:
            nf[fname] = _default_summary_en(game_name, _field_hours_from_form(nf))
            changed = True
            _dbg(f"  заменил некорректное английское название {fname}")
            continue
        if cur and too_long:
            if "summary][en" in fname:
                val = _limit_lot_title(_normalize_short_en(cur), FP_SUMMARY_RETRY_LIMIT)
                if not _en_title_ok(val):
                    val = _default_summary_en(game_name, 1)
                nf[fname] = val
                changed = True
            elif "summary][ru" in fname:
                nf[fname] = _limit_lot_title(cur, FP_SUMMARY_RETRY_LIMIT)
                changed = True
            elif "desc]" in fname:
                nf[fname] = str(cur)[:3500].rstrip()
                changed = True
            if changed:
                _dbg(f"  укоротил слишком длинное поле {fname}")
            continue
        if cur:
            continue
        if fname in selects:
            chosen = _pick_option(fname, selects[fname], game_name)
            if chosen:
                nf[fname] = chosen
                changed = True
                _dbg(f"  дозаполнил select {fname}={chosen} :D")
        else:
            low = fname.lower()
            if "level" in low or "уровен" in low:
                val = "1"
            elif "summary][en" in fname or "desc][en" in fname:
                val = "RENTAL ACCOUNT INSTANT AUTO DELIVERY"
            else:
                val = "1"
            nf[fname] = val
            changed = True
            _dbg(f"  дозаполнил input {fname}={val} :D")
    return changed


class _RateLimited(Exception):
    pass


class _AccessDenied(_RateLimited):
    """FunPay несколько раз подряд ответил «Доступ запрещен» на создание лота — дальше пробовать бессмысленно."""


ACCESS_DENIED_STREAK_LIMIT = 3
_ac_denied = {"streak": 0, "hit": False}


def _is_funpay_access_denied(err: str) -> bool:
    return "доступ запрещ" in str(err or "").lower()


_FP_FIELD_NAMES = {
    "fields[summary][ru]": "Название (RU)",
    "fields[summary][en]": "Название (EN)",
    "fields[desc][ru]": "Описание (RU)",
    "fields[desc][en]": "Описание (EN)",
    "fields[platform]": "Платформа",
    "fields[type]": "Тип",
    "server_id": "Сервер/игра",
    "price": "Цена",
}


def _is_funpay_rate_limit(err: str) -> bool:
    low = str(err or "").lower()
    markers = (
        "слишком много предложений",
        "много предложений",
        "too many offers",
        "rate limit",
        "rate_limited",
        "лимит создания лотов",
    )
    return any(marker in low for marker in markers)


def _humanize_funpay_err(err: str) -> str:
    if not err:
        return "неизвестная ошибка"
    s = str(err)
    if _is_funpay_rate_limit(s):
        return "Лимит создания лотов FunPay (кулдаун)"
    try:
        m = re.search(r'\{.*\}', s, re.S)
        if m:
            data = json.loads(m.group(0))
            main = (data.get("error") or "").strip()
            parts = []
            for pair in (data.get("errors") or []):
                if isinstance(pair, (list, tuple)) and len(pair) >= 2:
                    fld = _FP_FIELD_NAMES.get(pair[0], pair[0])
                    parts.append(f"{fld}: {pair[1]}")
            if parts:
                return (main + " — " if main and main != "Заполните все поля." else "") + "; ".join(parts)
            if main:
                return main
    except Exception:
        pass
    s = s.strip()
    return s[-160:] if len(s) > 160 else s


def _make_lot_fields(lot_id: int, fields: dict, subcat_obj=None):
    try:
        return LotFields(lot_id, fields, subcat_obj, getattr(cardinal_instance.account, "currency", None))
    except TypeError:
        return LotFields(lot_id, fields)


def _get_subcategory_obj(subcat_id: int, category_id=None, game_name: str = ""):
    account = cardinal_instance.account
    try:
        subcat = account.get_subcategory(SubCategoryTypes.COMMON, int(subcat_id))
        if subcat:
            return subcat
    except Exception:
        pass
    try:
        from FunPayAPI.types import Category, SubCategory
        cat = None
        if category_id:
            try:
                cat = account.get_category(int(category_id))
            except Exception:
                cat = None
        if not cat:
            cat = Category(int(category_id or 0), game_name or str(category_id or subcat_id), [])
        return SubCategory(int(subcat_id), game_name or str(subcat_id), SubCategoryTypes.COMMON, cat)
    except Exception as e:
        logger.debug(f"{LP} [AC] subcategory fallback failed sub={subcat_id}: {e}")
        return None


def _create_one_lot(subcat_id: int, subcat_obj, map_fields: dict, summary_ru: str,
                    summary_en: str, full_ru: str, full_en: str, input_price: float,
                    game_name: str = "", product_id=None, hours=None) -> "int | None":
    account = cardinal_instance.account
    lots_before = _my_subcat_lots(int(subcat_id))
    before = {int(l.id) for l in lots_before if str(getattr(l, "id", "")).isdigit()}

    template_fields = None
    try:
        my = lots_before
        if my:
            template_fields = account.get_lot_fields(int(my[0].id))
    except Exception as e:
        logger.debug(f"{LP} [AC] no template for sub={subcat_id}: {e}")

    node_form = _node_form(subcat_id)

    if template_fields:
        nf = dict(template_fields.fields)
    else:
        nf = dict(node_form.get("defaults") or {})
        nf.setdefault("query", "")
        nf.setdefault("location", "")
        nf.setdefault("deleted", "")
        nf.setdefault("auto_delivery", "")
        nf.setdefault("secrets", "")
        nf.setdefault("fields[payment_msg][ru]", "")
        nf.setdefault("fields[payment_msg][en]", "")

    nf["offer_id"] = "0"
    nf["node_id"] = str(subcat_id)
    nf["deleted"] = ""
    nf["fields[summary][ru]"] = summary_ru
    nf["fields[summary][en]"] = summary_en
    nf["fields[desc][ru]"] = full_ru
    nf["fields[desc][en]"] = full_en
    _sanitize_summary_fields(nf)
    nf["price"] = str(input_price)
    nf["amount"] = "99999"
    nf["active"] = "on"
    nf["offer_type_id"] = "1"
    nf["csrf_token"] = account.csrf_token
    nf["form_created_at"] = str(int(time.time()))
    for k, v in (map_fields or {}).items():
        if v:
            nf[k] = v

    for k in list(nf.keys()):
        kl = k.lower()
        if "secret" in kl or "auto_delivery" in kl or "autodelivery" in kl or kl == "secrets":
            nf.pop(k, None)
    nf["auto_delivery"] = ""
    nf["secrets"] = ""

    selects = node_form.get("selects") or {}
    for name, opts in selects.items():
        valid = {v for v, _ in opts if v}
        cur = nf.get(name)
        low = name.lower()
        ne = [(v, t) for v, t in opts if v]
        is_platform = ("platform" in low) or _looks_platform(ne)
        is_game_select = ("server" in low or low.endswith("[game]") or low == "game"
                          or "type" in low or "offer" in low) and not is_platform
        if is_game_select or is_platform or (not cur) or (valid and cur not in valid):
            chosen = _pick_option(name, opts, game_name)
            if chosen and chosen != cur:
                nf[name] = chosen
                _dbg(f"sub={subcat_id} подобрал {name}={chosen} (игра: {game_name})")
            elif chosen and not cur:
                nf[name] = chosen
    if "fields[type]" not in selects and not nf.get("fields[type]"):
        nf["fields[type]"] = "Аренда"

    if AC_DEBUG or SETTINGS.get("ac_debug"):
        dump = {k: v for k, v in nf.items() if k not in ("csrf_token",)}
        _dbg(f"_create_one_lot sub={subcat_id} поля лота:\n{json.dumps(dump, ensure_ascii=False, indent=1)[:1500]}")

    saved = False
    last_err = ""
    attempt = 0
    while attempt < 6:
        nf["csrf_token"] = account.csrf_token
        lf = _make_lot_fields(0, dict(nf), subcat_obj)
        lf.active = True
        try:
            account.save_lot(lf)
            saved = True
            _dbg(f"save_lot OK sub={subcat_id} (попытка {attempt + 1})")
            break
        except Exception as e:
            msg = str(e)
            last_err = msg
            low_msg = msg.lower()
            ctx = f"pid={product_id} game={game_name} sub={subcat_id} hours={hours}"
            logger.warning(f"{LP} [AC] save_lot response {ctx}: {msg[-800:]}")
            if _is_funpay_rate_limit(msg):
                _dbg(f"rate-limit FunPay sub={subcat_id}: лимит создания, остановка прогона")
                raise _RateLimited(msg)
            _dbg(f"save_lot ошибка {ctx} (попытка {attempt + 1}): {msg[-300:]}")
            changed = _fill_missing_from_error(nf, node_form, msg, game_name)
            if ("fields[summary][en]" in msg or "fields[desc][en]" in msg) and not nf.get("fields[summary][en]"):
                nf["fields[summary][en]"] = "RENTAL ACCOUNT INSTANT AUTO DELIVERY"
                nf["fields[desc][en]"] = ("Rent a licensed account with instant automatic delivery "
                                          "right after payment. Steam Guard code on request in chat.")
                changed = True
            attempt += 1
            if not changed and "подождите" in low_msg and attempt < 3:
                time.sleep(5 * attempt)
                continue
            if not changed:
                logger.warning(f"{LP} [AC] save_lot failed pid={product_id} game={game_name} "
                               f"sub={subcat_id} hours={hours}: {msg[-200:]}")
                break

    if saved:
        _ac_denied["streak"] = 0
    else:
        if _is_funpay_access_denied(last_err):
            _ac_denied["streak"] += 1
            if _ac_denied["streak"] >= ACCESS_DENIED_STREAK_LIMIT:
                _ac_denied["hit"] = True
                logger.warning(f"{LP} [AC] FunPay {ACCESS_DENIED_STREAK_LIMIT} раза подряд ответил "
                               f"«Доступ запрещен» — создание остановлено")
                raise _AccessDenied(last_err)
        else:
            _ac_denied["streak"] = 0
        return None, _humanize_funpay_err(last_err)

    want = _lot_title_norm(summary_ru)

    def activated(lot_id):
        lot_id = int(lot_id)
        if _set_lot_active(lot_id, True):
            return lot_id, None
        return None, "FunPay did not activate the newly created lot"

    try:
        after = _my_subcat_lots(int(subcat_id))
        new = [l for l in after if int(l.id) not in before]
        if len(new) == 1:
            return activated(new[0].id)
        if len(new) > 1:
            for l in new:
                if _lot_title_norm(getattr(l, "description", "") or getattr(l, "title", "")) == want:
                    return activated(l.id)
            _dbg(f"sub={subcat_id}: >1 новых лота, точного совпадения по названию нет — беру первый")
            return activated(new[0].id)
        for l in after:
            if _lot_title_norm(getattr(l, "description", "") or getattr(l, "title", "")) == want:
                _dbg(f"sub={subcat_id}: lot_id найден по названию (листинг с задержкой)")
                return activated(l.id)
    except Exception as e:
        logger.debug(f"{LP} [AC] post-list fail sub={subcat_id}: {e}")
    return None, "лот создан, но не найден в списке (задержка FunPay)"


def _build_plan_items(missing_only: bool = True, bind_existing: bool = False,
                      diagnostics: "list | None" = None, product_id=None,
                      durations_override=None, only_ids: "set | None" = None) -> list:
    api = _get_api()
    if not api or not _ac_has_template():
        if not api:
            _diag_add(diagnostics, "нет API ключа TDUgame")
        elif not _ac_has_template():
            _diag_add(diagnostics, "шаблон автолотов не настроен")
        return []
    tpl = _ac_tpl()
    have = _existing_lot_keys() if missing_only else set()
    items = []
    own_lots_cache = {}
    prods_by_id = {int(p["id"]): p for p in (_products(api) or [])}
    sellable = _all_sellable_games(api, diagnostics)
    _dbg(f"_build_plan_items: sellable={len(sellable)} missing_only={missing_only} have={len(have)}")
    for sg in sellable:
        if product_id is not None and str(sg.get("product_id")) != str(product_id):
            continue
        if only_ids is not None and sg.get("product_id") not in only_ids:
            continue
        # исключения действуют на массовое создание; явный запуск «для одной игры» их обходит
        if product_id is None and _is_excluded(sg.get("product_id")):
            _diag_add(diagnostics, "игра исключена из автосоздания",
                      product_id=sg.get("product_id"), name=sg.get("product_name") or sg.get("game_name"))
            continue
        block_reason = _autolot_block_reason(sg)
        if block_reason:
            _diag_add(diagnostics, "товар в стоп-листе автолотов",
                      product_id=sg.get("product_id"), name=sg.get("product_name") or sg.get("game_name"),
                      reason_detail=block_reason)
            continue
        prod = prods_by_id.get(sg["product_id"])
        if not prod:
            _diag_add(diagnostics, "товар TDUgame пропал из каталога",
                      product_id=sg.get("product_id"), name=sg.get("product_name") or sg.get("game_name"))
            continue
        block_reason = _autolot_block_reason(prod, sg)
        if block_reason:
            _diag_add(diagnostics, "товар в стоп-листе автолотов",
                      product_id=sg.get("product_id"), name=sg.get("product_name") or sg.get("game_name"),
                      reason_detail=block_reason)
            continue
        durations = _durations_for(prod, tpl)
        if product_id is not None and durations_override is not None:
            mn = int(prod.get("min_hours") or 1)
            mx = int(prod.get("max_hours") or 720)
            durations = sorted({h for h in durations_override if mn <= h <= mx})
        if not durations:
            _diag_add(diagnostics, "нет подходящих длительностей",
                      product_id=sg.get("product_id"), name=sg.get("game_name"),
                      min_hours=prod.get("min_hours"), max_hours=prod.get("max_hours"))
            continue
        for h in durations:
            placement = sg.get("placement") or "main"
            key = _mapping_key(sg["product_id"], h, placement)
            if missing_only and key in have:
                _diag_add(diagnostics, "уже есть привязка",
                          product_id=sg.get("product_id"), name=sg.get("game_name"), hours=h,
                          placement=placement)
                continue
            item = {
                "product_id": sg["product_id"],
                "product_name": sg["product_name"],
                "game_name": sg["game_name"],
                "subcategory_id": sg["subcategory_id"],
                "category_id": sg.get("category_id"),
                "fields": sg["fields"],
                "hours": h,
                "source": sg.get("source", "map"),
                "placement": placement,
            }
            if product_id is not None and durations_override is not None:
                item["custom_hours"] = True      # свои сроки для одной игры — не считаем «лишними»
            if missing_only and bind_existing:
                sru, _sen, _fru, _fen = _build_lot_texts(item["game_name"], h, tpl,
                                                         item["product_id"], item.get("placement") or "main")
                if item.get("placement") == "steam":
                    rent_code = _make_rent_code(item["product_id"], h, "steam")
                    existing_id = _find_existing_own_lot_with_code(item["subcategory_id"], sru, rent_code, own_lots_cache)
                else:
                    existing_id = _find_existing_own_lot(item["subcategory_id"], sru, own_lots_cache)
                if existing_id:
                    if bind_existing and _append_mapping_for_lot(existing_id, item):
                        _dbg(f"auto-bind existing lot={existing_id}: {item['game_name']} {h}ч")
                    if key is not None:
                        have.add(key)
                    continue
            items.append(item)
    return items       # игра за игрой: все сроки одной игры (от короткого к длинному), затем следующая


def _build_plan(user_id: int, missing_only: bool = True, product_id=None, durations=None) -> list:
    items = _build_plan_items(missing_only=missing_only, bind_existing=False,
                              product_id=product_id, durations_override=durations)
    _ac_plan[user_id] = {"items": items, "product_id": product_id,
                         "durations": durations, "ts": time.time()}
    _dbg(f"_build_plan: план из {len(items)} лотов (цены считаются лениво :D)")
    return items


def _fmt_eta(seconds: float) -> str:
    s = int(round(seconds))
    if s < 60:
        return f"~{max(s, 1)} сек"
    m = s // 60
    if m < 60:
        rem = s % 60
        return f"~{m} мин" + (f" {rem} сек" if rem and m < 5 else "")
    h = m // 60
    mm = m % 60
    return f"~{h} ч" + (f" {mm} мин" if mm else "")


def _card(rows: list) -> str:
    """Чёрная карточка (моноширинный блок) в общем стиле меню плагина."""
    return '<pre><code class="language-TDUgame">' + _html.escape("\n".join(rows)) + "</code></pre>\n"


def _ac_result_card(created: int, bound: int, skipped: int, failed: int, created_label: str = "Создано") -> str:
    w = 15
    return _card([
        f"✅  {created_label.ljust(w)}{_money_spaced(created)}",
        f"🔗  {'Привязано'.ljust(w)}{_money_spaced(bound)}",
        f"⏭  {'Пропущено'.ljust(w)}{_money_spaced(skipped)}",
        f"❌  {'Ошибок'.ljust(w)}{_money_spaced(failed)}",
        "─" * 30,
        f"📦  {'Привязок всего'.ljust(w)}{_money_spaced(len(MAPPINGS))}",
    ])


def _ac_progress_edit(chat_id, mid, done, total, created, skipped, failed,
                      title="Создание лотов…", verb="Создано", started=None):
    _ac_running.update({"title": title, "verb": verb, "done": done, "total": total,
                        "created": created, "skipped": skipped, "failed": failed,
                        "started": started})
    if not (chat_id and mid):
        return
    eta_txt = "—"
    if started and done < total:
        if done >= 3:
            elapsed = time.time() - started
            eta_txt = _fmt_eta(max(0.0, elapsed / done * (total - done)))
        else:
            eta_txt = "подсчёт…"
    w = 11
    card = _card([
        _bar(done, total),
        "─" * 30,
        f"✅  {verb.ljust(w)}{_money_spaced(created)}",
        f"⏭  {'Пропущено'.ljust(w)}{_money_spaced(skipped)}",
        f"❌  {'Ошибок'.ljust(w)}{_money_spaced(failed)}",
        f"⏳  {'Осталось'.ljust(w)}{eta_txt}",
    ])
    txt = (f"🚀 <b>{title}</b>\n" + card +
           "<i>Можно свернуть — процесс идёт в фоне.</i>")
    kb = None
    if _ac_cancel.is_set():
        txt += "\n\n⏹ <b>Останавливаю после текущего шага…</b>"
    else:
        kb = K()
        kb.row(B("⛔ Остановить", callback_data=f"{P}_ac_stop", style="danger"))
    try:
        bot.edit_message_text(txt, chat_id, mid, parse_mode="HTML", reply_markup=kb)
    except Exception as e:
        if "not modified" not in str(e).lower() and kb is not None:
            try:        # запасной вариант: без цвета кнопки
                bot.edit_message_text(txt, chat_id, mid, parse_mode="HTML", reply_markup=_plain_kb(kb))
            except Exception:
                pass


def _subcategory_cap(subcat_id: int, category_id=None) -> int:
    """Return FunPay's offer cap for the target subcategory."""
    try:
        sid = int(subcat_id)
    except (TypeError, ValueError):
        sid = 0
    # FunPay allows 20 offers in the shared Steam account section and 15 in
    # game-specific account sections.
    if sid == 89:
        return 20
    try:
        return int(SETTINGS.get("ac_subcat_cap", 15) or 15)
    except (TypeError, ValueError):
        return 15


def _funpay_limit_wait_text() -> str:
    """Пояснение к отказу FunPay создавать лоты: у него скользящее окно «500 новых лотов за 24 часа»,
    поэтому ждать можно от минут до суток — зависит от того, когда создавались прежние лоты."""
    wait = _quota_next_slot_in()
    when = (f"Ближайшие места освободятся примерно через <b>{_fmt_eta(wait)}</b>, когда из окна выйдут "
            f"самые ранние из созданных лотов. " if wait > 0 else
            "Точное время зависит от того, когда создавались прежние лоты. ")
    return (f"⏳ Это лимит FunPay: не больше {CREATE_LIMIT_24H} новых лотов за 24 часа (окно скользящее, "
            f"учитываются и лоты, созданные вручную). {when}"
            f"Дальше создавать сейчас бессмысленно — позже снова нажмите «Создать недостающие»: "
            f"плагин дозальёт только оставшиеся.")


def _shared_subcategories(plan: list) -> set:
    """Разделы FunPay, куда попадают несколько игр сразу (например, «Прочие игры»): лимит «15 лотов
    на игру» к ним применять нельзя — иначе после первых 15 лотов пропускаются все остальные игры.
    Общий Steam-раздел (89) имеет свой известный лимит и остаётся под контролем."""
    games: dict = {}
    for it in plan:
        games.setdefault(it.get("subcategory_id"), set()).add(it.get("product_id"))
    for m in MAPPINGS:
        try:
            games.setdefault(int(m.get("subcategory_id")), set()).add(int(m.get("product_id")))
        except (TypeError, ValueError):
            continue
    out = set()
    for sid, pids in games.items():
        try:
            if len(pids) > 1 and int(sid) != 89:
                out.add(sid)
                out.add(int(sid))
        except (TypeError, ValueError):
            continue
    return out


def _kick_stock_sync():
    """Сразу после создания лотов проверяем наличие: лоты игр без аккаунтов не должны висеть активными до следующего планового прохода."""
    def run():
        try:
            if SETTINGS.get("enabled") and (SETTINGS.get("auto_hide_no_stock") or SETTINGS.get("auto_hide_zero_balance")):
                _poll_cycle()
        except Exception as e:
            logger.error(f"{LP} stock sync after create error: {e}", exc_info=True)
    threading.Thread(target=run, daemon=True).start()


def _autocreate_run(user_id: int, chat_id=None, mid=None):
    created = skipped = failed = bound_existing = 0
    rate_limited = False
    cancelled = False
    quota_hit = False
    _ac_denied.update(streak=0, hit=False)
    err_counter = Counter()
    err_samples: dict = {}
    skip_counter = Counter()
    auto_raise_prev = _pause_cardinal_autoraise("autocreate")
    try:
        plan = (_ac_plan.get(user_id) or {}).get("items") or []
        total = len(plan)
        shared_subs = _shared_subcategories(plan)
        existing_lots = {str(m.get("lot_id")) for m in MAPPINGS if m.get("lot_id")}
        sub_counts: dict = {}
        own_lots_cache = {}
        tpl = _ac_tpl()
        api = _get_api()
        prods_by_id = {int(p["id"]): p for p in (_products(api) or [])}
        _dbg(f"_autocreate_run: старт, лотов={total}, caps=20/15")
        started = time.time()
        _ac_progress_edit(chat_id, mid, 0, total, 0, 0, 0, started=started)

        for i, it in enumerate(plan, 1):
            if _ac_cancel.is_set():
                cancelled = True
                break
            block_reason = _autolot_block_reason(it)
            if block_reason:
                skipped += 1
                skip_counter["игра в стоп-листе автолотов"] += 1
                _dbg(f"skip {it.get('game_name')} {it.get('hours')}ч — стоп-лист автолотов ({block_reason})")
                if i <= 10 or i % 3 == 0 or i == total:
                    _ac_progress_edit(chat_id, mid, i, total, created, skipped, failed, started=started)
                continue
            sub_id = it["subcategory_id"]
            placement = it.get("placement") or "main"
            if sub_id not in sub_counts:
                sub_counts[sub_id] = len(_my_subcat_lots(int(sub_id), own_lots_cache))
            if _mapping_exists(it["product_id"], it["hours"], placement):
                skipped += 1
                skip_counter["привязка уже есть"] += 1
                _dbg(f"skip {it['game_name']} {it['hours']}ч — привязка уже есть")
            else:
                sru, sen, fru, fen = _build_lot_texts(it["game_name"], it["hours"], tpl,
                                                      it["product_id"], placement)
                if placement == "steam":
                    rent_code = _make_rent_code(it["product_id"], it["hours"], "steam")
                    existing_id = _find_existing_own_lot_with_code(int(sub_id), sru, rent_code, own_lots_cache)
                else:
                    existing_id = _find_existing_own_lot(int(sub_id), sru, own_lots_cache)
                if existing_id:
                    if _append_mapping_for_lot(existing_id, it):
                        bound_existing += 1
                        existing_lots.add(str(existing_id))
                        _dbg(f"bind existing lot={existing_id}: {it['game_name']} {it['hours']}ч")
                    else:
                        skip_counter["лот уже есть на FunPay, но привязать не вышло (код или лот уже заняты)"] += 1
                    skipped += 1
                    if i <= 10 or i % 3 == 0 or i == total:
                        _ac_progress_edit(chat_id, mid, i, total, created, skipped, failed, started=started)
                    continue
                cap = _subcategory_cap(sub_id, it.get("category_id"))
                if sub_id not in shared_subs and sub_counts[sub_id] >= cap:
                    skipped += 1
                    skip_counter[f"лимит раздела FunPay ({cap} лотов на игру)"] += 1
                    _dbg(f"skip {it['game_name']} {it['hours']}ч — лимит раздела {sub_id}")
                    if i <= 10 or i % 3 == 0 or i == total:
                        _ac_progress_edit(chat_id, mid, i, total, created, skipped, failed, started=started)
                    continue
                if _creation_quota_left() <= 0:
                    quota_hit = True
                    break
                subcat_obj = _get_subcategory_obj(int(sub_id), it.get("category_id"), it["game_name"])
                prod = prods_by_id.get(it["product_id"], {"id": it["product_id"]})
                public_price = _public_price_rub(api, prod, it["hours"], tpl)
                input_price = _lot_input_price(public_price, sub_id)
                _dbg(f"create {it['game_name']} {it['hours']}ч sub={sub_id} "
                     f"pub={public_price} input={input_price}")
                try:
                    lot_id, err = _create_one_lot(int(sub_id), subcat_obj, it.get("fields") or {},
                                                  sru, sen, fru, fen, input_price, it["game_name"],
                                                  it.get("product_id"), it.get("hours"))
                except _RateLimited:
                    rate_limited = True
                    break
                if lot_id:
                    _record_creation()     # лот реально создан на FunPay — идёт в лимит 500/24ч
                if lot_id and str(lot_id) not in existing_lots:
                    ok_map = _append_mapping_for_lot(lot_id, it)
                    existing_lots.add(str(lot_id))
                    sub_counts[sub_id] += 1
                    if ok_map:
                        created += 1
                    else:
                        failed += 1
                        reason = "лот создан, но привязка уже существовала"
                        err_counter[reason] += 1
                        err_samples.setdefault(reason, f"{it['game_name']} pid={it['product_id']} sub={sub_id} ({it['hours']}ч)")
                else:
                    failed += 1
                    reason = err or "неизвестная ошибка"
                    _evt("lot_create", "error", f"Лот не создан: {it['game_name']}, {it['hours']} ч",
                         detail=f"Причина: {reason}. Раздел FunPay {sub_id}.",
                         product_id=it.get("product_id"), subcat=sub_id, hours=it.get("hours"))
                    err_counter[reason] += 1
                    err_samples.setdefault(reason, f"{it['game_name']} pid={it['product_id']} sub={sub_id} ({it['hours']}ч)")
                    _dbg(f"FAILED {it['game_name']} {it['hours']}ч sub={sub_id}: {reason}")
                time.sleep(AC_CREATE_DELAY)
            if i <= 10 or i % 3 == 0 or i == total:
                _ac_progress_edit(chat_id, mid, i, total, created, skipped, failed, started=started)
    except Exception as e:
        logger.error(f"{LP} [AC] run error: {e}", exc_info=True)
    finally:
        _restore_cardinal_autoraise(auto_raise_prev)
        _ac_running["on"] = False
        _ac_plan.pop(user_id, None)
        _kick_stock_sync()

    if cancelled:
        done_txt = (
            "⏹ <b>Создание остановлено</b>\n"
            + _ac_result_card(created, bound_existing, skipped, failed, "Успели создать")
            + "Созданные лоты остаются на FunPay. «Создать недостающие» продолжит с того места, где остановились."
        )
    elif quota_hit:
        done_txt = (
            f"⏳ <b>Достигнут лимит FunPay: {CREATE_LIMIT_24H} лотов за 24 часа</b>\n"
            + _ac_result_card(created, bound_existing, skipped, failed, "Создано сейчас")
            + f"Следующий лот можно создать через <b>{_fmt_eta(_quota_reset_in())}</b>. "
            f"Позже снова нажмите «Создать недостающие»: плагин продолжит с того же места."
        )
    elif _ac_denied["hit"]:
        done_txt = (
            f"⛔️ <b>FunPay не даёт создавать лоты: «Доступ запрещен».</b>\n\n"
            f"Это ответ самого FunPay, {ACCESS_DENIED_STREAK_LIMIT} раза подряд, поэтому создание остановлено — "
            f"дальше пробовать бессмысленно и может навредить аккаунту.\n\n"
            f"✅ Успели создать: <b>{created}</b>\n"
            f"⏭ Пропущено/уже было: <b>{skipped}</b>\n\n"
            f"<b>Что проверить:</b>\n"
            f"1) Попробуйте вручную на сайте funpay.com создать один лот в этом же аккаунте. "
            f"Если сайт тоже отказывает — аккаунт ограничен (новый или не подтверждён), решать с поддержкой FunPay.\n"
            f"2) Если вручную лот создаётся — обновите golden_key и user-agent в настройках Cardinal "
            f"(возьмите из браузера, где вы вошли в этот же аккаунт) и перезапустите Cardinal.\n"
            f"Потом снова нажмите «Создать недостающие» — плагин продолжит с того же места."
        )
    elif rate_limited:
        done_txt = (
            "⛔️ <b>FunPay включил лимит на создание лотов</b>\n"
            + _ac_result_card(created, bound_existing, skipped, failed, "Успели создать")
            + _funpay_limit_wait_text()
        )
    else:
        done_txt = (
            "🤖 <b>Создание лотов завершено</b>\n"
            + _ac_result_card(created, bound_existing, skipped, failed)
        )
        if skipped and skip_counter:
            done_txt += "\nℹ️ <b>Почему пропущено:</b>"
            for reason, cnt in skip_counter.most_common(5):
                done_txt += f"\n• <b>{cnt}×</b> {_html.escape(reason)}"
        if failed and err_counter:
            done_txt += "\n\n⚠️ <b>Причины ошибок:</b>"
            for reason, cnt in err_counter.most_common(6):
                sample = err_samples.get(reason, "")
                done_txt += f"\n• <b>{cnt}×</b> {_html.escape(reason)}"
                if sample:
                    done_txt += f"\n   <i>напр.: {_html.escape(sample)}</i>"
            extra = len(err_counter) - 6
            if extra > 0:
                done_txt += f"\n…и ещё {extra} тип(ов) ошибок."
    try:
        for reason, cnt in skip_counter.most_common(8):
            _evt("lot_create", "warn", f"Пропущено при создании: {cnt} лот(ов)", detail=reason)
        _evt("lot_create", "error" if failed else ("warn" if rate_limited or quota_hit else "ok"),
             f"Создание лотов завершено: создано {created}, пропущено {skipped}, ошибок {failed}",
             detail=("Остановлено из-за лимита FunPay на создание лотов. " if rate_limited else "")
                    + ("Достигнут суточный лимит создания лотов. " if quota_hit else ""),
             advice="" if not (rate_limited or quota_hit) else _advice_for("лимит создания"))
    except Exception:
        pass
    _set_last_result(done_txt)
    if chat_id and mid:
        try:
            kb = K()
            kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
            bot.edit_message_text(done_txt, chat_id, mid, parse_mode="HTML", reply_markup=kb)
        except Exception:
            pass
    else:
        notify_admins_info(done_txt)


_AUTO_NEW_UID = 0          # «пользователь» плана для фонового автосоздания (не Telegram-id)
_auto_new_state = {"next_ts": 0.0}


def _game_names(ids, api=None) -> str:
    names = {}
    for p in (_products(api or _get_api()) or []):
        try:
            names[int(p["id"])] = p.get("name") or ""
        except (TypeError, ValueError, KeyError):
            continue
    return ", ".join(_html.escape(names.get(int(i)) or f"#{i}") for i in sorted(ids))


def _auto_new_games_check():
    """Фоновая проверка: на TDUgame появились товары, которых раньше не было.
    Без включённого переключателя новые игры тихо запоминаются (чтобы после включения
    плагин не бросился создавать лоты на всё подряд); с включённым — по ним создаются лоты."""
    if time.time() < _auto_new_state["next_ts"]:
        return
    api = _get_api()
    if not api:
        return
    prods = _products(api)
    if not prods:
        return
    cur = set()
    for p in prods:
        try:
            cur.add(int(p["id"]))
        except (TypeError, ValueError, KeyError):
            continue
    known = SETTINGS.get("ac_known_ids")
    if not isinstance(known, list):
        SETTINGS["ac_known_ids"] = sorted(cur)     # первый запуск: всё, что есть сейчас, — «не новое»
        save_settings()
        return
    known_set = set()
    for x in known:
        try:
            known_set.add(int(x))
        except (TypeError, ValueError):
            continue
    new_ids = cur - known_set
    if not new_ids:
        return
    if not SETTINGS.get("auto_new_games"):
        SETTINGS["ac_known_ids"] = sorted(known_set | new_ids)
        save_settings()
        return

    excluded_new = new_ids & _excluded_ids()
    candidates = new_ids - excluded_new
    if candidates:
        if not _ac_has_template():
            notify_admins("Автосоздание новых игр: не настроен шаблон лотов — откройте «Создание лотов» → мастер.",
                          dedup_key="autonew_tpl")
            _auto_new_state["next_ts"] = time.time() + 1800
            return
        blocked = _currency_blocked_text()
        if blocked:
            notify_admins(f"Автосоздание новых игр остановлено: {blocked}", dedup_key="autonew_cur")
            _auto_new_state["next_ts"] = time.time() + 1800
            return
        if _ac_running.get("on") or _creation_quota_left() <= 0:
            return                                  # повторим на следующем цикле
        routable = {int(sg["product_id"]) for sg in _all_sellable_games(api)}
        ready = candidates & routable
        pending = candidates - routable             # пока нет категории на FunPay — проверим позже
        items = _build_plan_items(missing_only=True, bind_existing=False, only_ids=ready) if ready else []
    else:
        ready, pending, items = set(), set(), []

    SETTINGS["ac_known_ids"] = sorted(known_set | excluded_new | ready)
    save_settings()
    if pending:
        _auto_new_state["next_ts"] = time.time() + 1800
    if not items:
        if ready:
            notify_admins_info(f"🆕 Новые игры на TDUgame: {_game_names(ready, api)} — лотов создавать не нужно "
                               f"(уже есть или нет подходящих сроков).")
        return
    if not _ac_begin("Автосоздание новых игр"):
        SETTINGS["ac_known_ids"] = sorted(set(SETTINGS["ac_known_ids"]) - ready)
        save_settings()
        return
    _ac_plan[_AUTO_NEW_UID] = {"items": items, "product_id": None, "durations": None, "ts": time.time()}
    notify_admins_info(f"🆕 Новые игры на TDUgame: {_game_names(ready, api)}\n"
                       f"Создаю лоты автоматически: <b>{len(items)}</b> шт. "
                       f"Остановить можно в «Создание лотов».")
    threading.Thread(target=_autocreate_run, args=(_AUTO_NEW_UID, None, None), daemon=True).start()


def _reprice_run(user_id: int, chat_id=None, mid=None, product_id=None):
    account = cardinal_instance.account
    api = _get_api()
    tpl = _ac_tpl()
    targets = [m for m in MAPPINGS
               if m.get("auto_created") and m.get("lot_id")
               and m.get("product_id") is not None and m.get("hours") is not None
               and (product_id is None or str(m.get("product_id")) == str(product_id))]
    total = len(targets)
    updated = unchanged = failed = 0
    rate_limited = False
    started = time.time()
    auto_raise_prev = _pause_cardinal_autoraise("reprice")

    def _is_rate_limit(msg: str) -> bool:
        low = (msg or "").lower()
        return _is_funpay_rate_limit(low) or "слишком часто" in low

    def _is_missing_offer(msg: str) -> bool:
        low = (msg or "").lower()
        return ("предложение не найдено" in low or "страница не найдена" in low
                or "not found" in low or "404" in low)

    try:
        logger.info(f"{LP} [AC] reprice start: targets={total}, chat_id={chat_id}, mid={mid}")
        _ac_progress_edit(chat_id, mid, 0, total, 0, 0, 0, "Пересчёт цен…", "Обновлено", started=started)
        logger.info(f"{LP} [AC] reprice loading TDUgame products")
        prods_by_id = {int(p["id"]): p for p in (_products(api) or [])}
        logger.info(f"{LP} [AC] reprice products loaded: {len(prods_by_id)}")
        for i, m in enumerate(targets, 1):
            if _ac_cancel.is_set():
                break
            did_write = False
            try:
                prod = prods_by_id.get(int(m["product_id"]), {"id": m["product_id"]})
                public = _public_price_rub(api, prod, int(m["hours"]), tpl)
                inp = _lot_input_price(public, int(m["subcategory_id"]))
                lf = account.get_lot_fields(int(m["lot_id"]))
                old = lf.fields.get("price")
                try:
                    old_val = float(str(old).replace(",", ".")) if old not in (None, "") else None
                except Exception:
                    old_val = None
                title_changed = _sanitize_summary_fields(lf.fields)
                price_changed = old_val is None or abs(round(old_val, 2) - round(inp, 2)) >= 0.005
                if not price_changed and not title_changed:
                    unchanged += 1
                    _dbg(f"reprice SKIP lot={m['lot_id']} {m['hours']}ч цена не изменилась ({old_val})")
                else:
                    if price_changed:
                        lf.price = inp
                    lf.fields["csrf_token"] = account.csrf_token
                    account.save_lot(lf)
                    did_write = True
                    updated += 1
                    _dbg(f"reprice lot={m['lot_id']} {m['hours']}ч {old}→{inp}")
            except Exception as e:
                msg = str(e)
                if _is_rate_limit(msg):
                    _dbg(f"reprice rate-limit FunPay на lot={m.get('lot_id')}: остановка прогона")
                    rate_limited = True
                    break
                if _is_missing_offer(msg):
                    failed += 1
                    _dbg(f"reprice MISS lot={m.get('lot_id')}: offer not found")
                    continue
                failed += 1
                _dbg(f"reprice FAIL lot={m.get('lot_id')}: {msg[-200:]}")
            finally:
                _ac_progress_edit(chat_id, mid, i, total, updated, unchanged, failed,
                                  "Пересчёт цен…", "Обновлено", started=started)
            if did_write:
                time.sleep(AC_CREATE_DELAY + random.uniform(0.0, 1.0))
            else:
                time.sleep(0.4 + random.uniform(0.0, 0.4))
    except Exception as e:
        logger.error(f"{LP} [AC] reprice error: {e}", exc_info=True)
    finally:
        _restore_cardinal_autoraise(auto_raise_prev)
        _ac_running["on"] = False

    floor = _money(_min_price_floor(tpl))
    _rows = [
        f"✅  {'Обновлено'.ljust(15)}{_money_spaced(updated)}",
        f"➖  {'Без изменений'.ljust(15)}{_money_spaced(unchanged)}",
        f"❌  {'Ошибок'.ljust(15)}{_money_spaced(failed)}",
    ]
    if rate_limited:
        done = ("⛔️ <b>FunPay лимит на изменение цен</b>\n" + _card(_rows) +
                "⏳ Это лимит FunPay: вернитесь позже и снова нажмите «Пересчитать цены». "
                "Уже выставленные цены пропустятся, плагин до-обновит только оставшиеся.")
    else:
        _rows += ["─" * 30, f"📈  {'Наценка'.ljust(15)}{_pct(tpl.get('markup_percent', 0))}%",
                  f"💰  {'Мин. цена'.ljust(15)}{floor} ₽"]
        done = "♻️ <b>Пересчёт цен завершён</b>\n" + _card(_rows)
    _set_last_result(done)
    if chat_id and mid:
        try:
            kb = K()
            kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
            bot.edit_message_text(done, chat_id, mid, parse_mode="HTML", reply_markup=kb)
        except Exception:
            pass
    else:
        notify_admins_info(done)


def _reprice_thread_entry(user_id: int, chat_id=None, mid=None, product_id=None):
    logger.info(f"{LP} [AC] reprice thread entered: user_id={user_id}, chat_id={chat_id}, mid={mid}")
    try:
        _reprice_run(user_id, chat_id, mid, product_id)
    except Exception as e:
        logger.error(f"{LP} [AC] reprice thread crashed: {e}", exc_info=True)
        _ac_running["on"] = False
        if chat_id and mid:
            try:
                kb = K()
                kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
                bot.edit_message_text(
                    f"❌ <b>Пересчёт упал.</b>\n\n<code>{_html.escape(str(e)[:800])}</code>",
                    chat_id,
                    mid,
                    parse_mode="HTML",
                    reply_markup=kb,
                )
            except Exception:
                pass


def _extra_duration_mappings() -> list:
    """Лоты, созданные плагином, чей срок убран из списка сроков шаблона («лишние»).
    Лоты, созданные с индивидуальными сроками для одной игры, сюда не попадают."""
    try:
        want = {int(h) for h in (_ac_tpl().get("durations") or [])}
    except (TypeError, ValueError):
        want = set()
    if not want:
        return []
    out = []
    for m in MAPPINGS:
        if not (m.get("lot_id") and m.get("auto_created")) or m.get("custom_hours"):
            continue
        try:
            h = int(m.get("hours"))
        except (TypeError, ValueError):
            continue
        if h not in want:
            out.append(m)
    return out


def _removed_game_mappings() -> list:
    """Лоты, созданные плагином, для игр, которых больше нет в каталоге TDUgame."""
    try:
        prods = _products(_get_api())
    except Exception:
        return []
    ids = set()
    for p in (prods or []):
        try:
            ids.add(int(p["id"]))
        except (TypeError, ValueError, KeyError):
            continue
    if len(ids) < 10:          # каталог не загрузился или пришёл неполным — ничего не трогаем
        return []
    out = []
    for m in MAPPINGS:
        if not (m.get("lot_id") and m.get("auto_created")):
            continue
        try:
            pid = int(m.get("product_id"))
        except (TypeError, ValueError):
            continue
        if pid not in ids:
            out.append(m)
    return out


def _cleanup_mappings() -> list:
    """Всё «лишнее»: лоты на убранные сроки и лоты игр, удалённых с TDUgame."""
    seen, out = set(), []
    for m in _extra_duration_mappings() + _removed_game_mappings():
        k = str(m.get("lot_id"))
        if k not in seen:
            seen.add(k)
            out.append(m)
    return out


def _hidden_mappings() -> list:
    """Привязанные лоты, которые плагин сейчас держит скрытыми (нет аккаунтов, нулевой баланс и т. п.)."""
    return [m for m in MAPPINGS if m.get("lot_id") and str(m.get("lot_id")) in HIDDEN]


def _delete_plugin_run(user_id: int, chat_id=None, mid=None, only_extra: bool = False,
                       only_hidden: bool = False):
    account = cardinal_instance.account
    deleted = failed = 0
    if only_hidden:
        targets = _hidden_mappings()
    elif only_extra:
        targets = _cleanup_mappings()
    else:
        targets = [m for m in MAPPINGS if m.get("lot_id")]
    total = len(targets)
    _dbg(f"delete_plugin: к удалению {total} привязанных лотов")
    started = time.time()
    try:
        _ac_progress_edit(chat_id, mid, 0, total, 0, 0, failed, "Удаление лотов…", "Удалено", started=started)
        done_ids = set()
        for i, m in enumerate(targets, 1):
            if _ac_cancel.is_set():
                break
            lot_id = m.get("lot_id")
            try:
                account.delete_lot(int(lot_id))
                deleted += 1
                done_ids.add(str(lot_id))
                _dbg(f"delete OK lot={lot_id} ({m.get('product_name')} {m.get('hours')}ч)")
            except Exception as e:
                msg = str(e)
                if "не найден" in msg.lower() or "not found" in msg.lower() or "offer" in msg.lower():
                    done_ids.add(str(lot_id))
                    _dbg(f"delete lot={lot_id}: уже нет на FunPay, чищу привязку")
                else:
                    failed += 1
                    _dbg(f"delete FAIL lot={lot_id}: {msg[-160:]}")
            time.sleep(AC_DELETE_DELAY)
            if i % 10 == 0 or i == total:
                _ac_progress_edit(chat_id, mid, i, total, deleted, 0, failed,
                                  "Удаление лотов…", "Удалено", started=started)
        MAPPINGS[:] = [m for m in MAPPINGS if str(m.get("lot_id")) not in done_ids]
        save_mappings()
        if done_ids:
            for lid in done_ids:
                HIDDEN.pop(str(lid), None)
            save_hidden()
    except Exception as e:
        logger.error(f"{LP} [AC] delete_plugin error: {e}", exc_info=True)
    finally:
        _ac_running["on"] = False

    done = ("🗑 <b>Удаление лотов завершено</b>\n" + _card([
        f"✅  {'Удалено'.ljust(15)}{_money_spaced(deleted)}",
        f"❌  {'Ошибок'.ljust(15)}{_money_spaced(failed)}",
        "─" * 30,
        f"🔗  {'Осталось привязок'.ljust(15)}{_money_spaced(len(MAPPINGS))}",
    ]))
    _set_last_result(done)
    if chat_id and mid:
        try:
            kb = K()
            kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
            bot.edit_message_text(done, chat_id, mid, parse_mode="HTML", reply_markup=kb)
        except Exception:
            pass
    else:
        notify_admins_info(done)


PLUGIN_HELP_TEXT = (
    f"<b>ℹ️ Справка {NAME}</b>\n\n"
    "<b>Команды покупателя (в чат FunPay)</b>\n"
    "Команда должна быть точным первым словом сообщения: <code>!продл</code> не сработает.\n"
    "• <code>!кодстим логин</code> — Steam Guard код для выданного аккаунта\n"
    "• <code>!продлить логин</code> — продление определенного аккаунта если у покупателя несколько арендованных аккаунтов (после повторной оплаты)\n"
    "• <code>!друг</code> — режим «для друга»; <code>!friend</code> тоже понимается как алиас\n\n"
    "<b>Обычная выдача (режим «для друга» выключен)</b>\n"
    "• Первый заказ по игре — новый аккаунт.\n"
    "• Повторный заказ по той же игре — продление того же аккаунта.\n"
    "• Если покупатель берёт <b>1 шт</b>, время за заказ: часы в привязке лота.\n"
    "• Если покупатель берёт <b>несколько шт</b>, бот сначала спросит: нужны отдельные аккаунты или один аккаунт на суммарное время.\n"
    "  Ответ <b>Да</b> → отдельные аккаунты по времени из привязки. Ответ <b>Нет</b> → один аккаунт/продление на часы × qty.\n"
    "  Пример: в привязке 3 ч, покупатель взял 1 шт → <b>3 ч</b> на одном аккаунте.\n"
    "• Лимит TDUgame на одну операцию: от 1 до 720 ч. Повторными продлениями суммарно можно больше 720 ч.\n\n"
    "<b>Режим <code>!друг</code> (для друга)</b>\n"
    "• Покупатель пишет <code>!друг</code>, затем оплачивает лот.\n"
    "• Следующая оплата выдаёт <b>новый аккаунт</b>, а не продлевает текущий.\n"
    "• При qty > 1 бот всё равно спросит покупателя: несколько отдельных аккаунтов или один новый аккаунт на суммарное время.\n"
    "• После этой оплаты режим выключается автоматически.\n\n"
    "<b>Коды привязки</b>\n"
    "• В подробном описании автолотов есть код вида <code>rent:ID-HOURS</code>; в название он не добавляется.\n"
    "• Для ручного лота можно добавить код из карточки привязки или <code>rent:12345678</code> с ID лота.\n"
    "• Если FunPay не передал ID лота, бот ищет этот код в описании заказа.\n\n"
    "<b>Привязки лотов</b>\n"
    "• Привязка по ID лота — переименование лота не ломает выдачу.\n"
    "• «Часы за 1 шт» в привязке — сколько часов аренды за одну штуку в заказе.\n\n"
    "<b>Частые вопросы</b>\n"
    "• Лот «цена за 1 час», qty=3 → бот спрашивает: 3 аккаунта по 1 ч или 1 аккаунт на 3 ч.\n"
    "• Один аккаунт на 3 ч → qty=3 в часовом лоте или лот с 3 ч в привязке.\n"
    "• Несколько активных аккаунтов одной игры — бот спросит, какой продлить (!продлить логин)."
)


def tg_info(call):
    kb = K()
    kb.row(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, PLUGIN_HELP_TEXT, kb)






def _run_status_line() -> str:
    if not _ac_running.get("on"):
        return ""
    rt = _ac_running.get("title", "Процесс")
    rd, rtot = _ac_running.get("done", 0), _ac_running.get("total", 0)
    pct = int(rd * 100 / rtot) if rtot else 0
    rv, rc = _ac_running.get("verb", "Готово"), _ac_running.get("created", 0)
    return f"⏳ <b>{rt}</b> {pct}% · {rv}: {rc}/{rtot}"


def tg_basic(call):
    s = SETTINGS
    en = s.get("enabled")
    kb = K()
    kb.row(B(f"{'🟢 Автовыдача: ВКЛ' if en else '🔴 Автовыдача: ВЫКЛ'}", callback_data=f"{P}_toggle:enabled", style=_sw("enabled")))
    kb.row(B(f"{_chk('lot_sync_enabled')} Синхронизация лотов TDUgame", callback_data=f"{P}_toggle:lot_sync_enabled", style=_sw("lot_sync_enabled")))
    kb.row(B(f"{_chk('auto_hide_no_stock')} Скрывать лоты: нет аккаунтов", callback_data=f"{P}_toggle:auto_hide_no_stock", style=_sw("auto_hide_no_stock")))
    kb.row(B(f"{_chk('auto_hide_zero_balance')} Скрывать лоты: нет баланса", callback_data=f"{P}_toggle:auto_hide_zero_balance", style=_sw("auto_hide_zero_balance")))
    kb.row(B(f"{_chk('auto_refund')} Авто-возврат при сбое выдачи", callback_data=f"{P}_toggle:auto_refund", style=_sw("auto_refund")))
    kb.row(B(f"{_chk('price_guard_enabled')} Защита от минусовой цены", callback_data=f"{P}_toggle:price_guard_enabled", style=_sw("price_guard_enabled")))
    kb.row(B(f"{_chk('multi_qty_confirm_enabled')} Спрашивать при покупке >1 шт", callback_data=f"{P}_toggle:multi_qty_confirm_enabled", style=_sw("multi_qty_confirm_enabled")))
    kb.row(B(f"{_chk('review_bonus_enabled')} Бонус за отзыв", callback_data=f"{P}_toggle:review_bonus_enabled", style=_sw("review_bonus_enabled")))
    kb.row(B(f"{_chk('notify_rental_end')} Сообщение об окончании аренды", callback_data=f"{P}_toggle:notify_rental_end", style=_sw("notify_rental_end")))
    kb.row(B(f"{_chk('notify_sales')} Уведомлять о продажах", callback_data=f"{P}_toggle:notify_sales", style=_sw("notify_sales")))
    kb.row(B(f"{_chk('custom_emoji')} Анимированные эмодзи в меню", callback_data=f"{P}_toggle:custom_emoji", style=_sw("custom_emoji")))
    kb.row(B(f"{_chk('colored_buttons')} Цветные кнопки", callback_data=f"{P}_toggle:colored_buttons", style=_sw("colored_buttons")))
    kb.row(_back_btn(f"{P}_main"))
    text = (
        "<b>🎛 Основные параметры</b>\n\n"
        "Нажмите на переключатель — он сменит состояние и коротко объяснит, что делает.\n"
        "🟢 включено · 🔴 выключено"
    )
    _edit(call, text, kb)


def _mask_api_key(key: str) -> str:
    """tdu_a1b2c3d4••• — начало ключа видно, остальное скрыто."""
    key = (key or "").strip()
    if not key:
        return ""
    if len(key) <= 12:
        return key
    return key[:12] + "•••"


_menu_balance_cache = {"ts": 0.0, "rub": None}


def _menu_balance_rub():
    api = _get_api()
    if not api:
        return None
    now = time.time()
    if now - _menu_balance_cache["ts"] > 30:
        try:
            bal = api.balance()
        except Exception:
            bal = None
        if bal is not None:
            _menu_balance_cache.update({"ts": now, "rub": bal.get("balance_rub", 0) or 0})
        elif now - _menu_balance_cache["ts"] > 300:
            _menu_balance_cache["rub"] = None
    return _menu_balance_cache["rub"]


def _money_spaced(x) -> str:
    """10313 → «10 313», 1234.5 → «1 234.5» (неразрывные пробелы между тысячами)."""
    txt = _money(x)
    whole, dot, frac = txt.partition(".")
    neg = whole.startswith("-")
    digits = whole.lstrip("-")
    groups = []
    while digits:
        groups.insert(0, digits[-3:])
        digits = digits[:-3]
    return ("-" if neg else "") + "\u00a0".join(groups or ["0"]) + (dot + frac if dot else "")


def tg_main(call):
    s = SETTINGS
    en = s.get("enabled")
    kb = K()
    if _ac_running.get("on"):
        kb.row(B(f"⏳ {_ac_running.get('title','Процесс')} — обновить", callback_data=f"{P}_main"))
    kb.row(B("📦 Создание лотов", callback_data=f"{P}_ac", style="success"))
    _wa_url = _webapp_open_url()
    if _wa_url.startswith("https://"):
        kb.row(B("📱 Мини-приложение", web_app=WebAppInfo(url=_wa_url)))
    kb.row(B("🎛 Основные параметры", callback_data=f"{P}_basic"),
           B("⚙️ Параметры", callback_data=f"{P}_params"))
    kb.row(B("🔑 API ключ", callback_data=f"{P}_apikey"),
           B("🔗 Привязки лотов", callback_data=f"{P}_maps:{_page_of('maps')}"))
    kb.row(B("✏️ Тексты", callback_data=f"{P}_texts"),
           B("ℹ️ Справка", callback_data=f"{P}_info"))
    kb.row(B("📊 Статистика", callback_data=f"{P}_stats"),
           _back_btn(f"{CBT.EDIT_PLUGIN}:{UUID}:0"))

    status = _run_status_line()
    _rub = _menu_balance_rub()
    if _rub is None:
        bal_txt = "—"
    else:
        bal_txt = f"{_money_spaced(_rub)} ₽"
        try:
            if float(_rub) <= float(s.get("balance_threshold", 0) or 0):
                bal_txt += "  ⚠️ пополните"
        except Exception:
            pass
    _kmask = _mask_api_key(s.get("api_key", "")) or "не задан"
    sep = "─" * 30
    rows = [
        f"💰  {'Баланс'.ljust(13)}{bal_txt}",
        f"{'🟢' if en else '🔴'}  {'Автовыдача'.ljust(13)}{'включена' if en else 'выключена'}",
        f"🔒  {'Ключ'.ljust(13)}{_kmask}",
        sep,
        f"🔗  {'Привязок'.ljust(13)}{len(MAPPINGS)}",
        f"🤫  {'Скрыто'.ljust(13)}{len(HIDDEN)}",
    ]
    card = '<pre><code class="language-TDUgame">' + _html.escape("\n".join(rows)) + "</code></pre>"
    text = (
        f"<b>🎮 {NAME}</b>  <i>v{VERSION}</i>\n"
        + (("\n" + status + "\n") if status else "")
        + card
    )
    _edit(call, text, kb)


def _nav(prefix: str, page: int, total: int, per: int):
    pages = max(1, (total + per - 1) // per)
    row = []
    if page > 0:
        row.append(B("‹", callback_data=f"{prefix}{page-1}"))
    row.append(B(f"{page+1}/{pages}", callback_data=f"{P}_noop"))
    if (page + 1) * per < total:
        row.append(B("›", callback_data=f"{prefix}{page+1}"))
    return (row if len(row) > 1 else []), pages


def tg_noop(call):
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def _chk(key) -> str:
    return "🟢" if SETTINGS.get(key) else "🔴"


# Кастомные (анимированные) эмодзи из паков NewsEmoji и FinanceEmoji: имя → (id, обычный смайл).
# Обычный смайл идёт внутрь тега как запасной: если бот не имеет права слать кастомные эмодзи
# (или выключен переключатель), покупатель видит его, и ничего не ломается.
CUSTOM_EMOJI = {
    "title": ("5361741454685256344", "🎮"),
    "balance": ("5287231198098117669", "💰"),
    "on": ("5416081784641168838", "🟢"),
    "off": ("5411225014148014586", "🔴"),
    "key": ("5296369303661067030", "🔒"),
    "links": ("5271604874419647061", "🔗"),
    "hidden": ("5431609822288033666", "🤫"),
    "help": ("5395695537687123235", "🚨"),
    "plus": ("5397916757333654639", "➕"),
    "game": ("5361741454685256344", "🎮"),
    "preview": ("5210956306952758910", "👀"),
    "edit": ("5395444784611480792", "✏️"),
    "gear": ("5341715473882955310", "⚙️"),
    "target": ("5310278924616356636", "🎯"),
    "redo": ("5375338737028841420", "🔄"),
    "new": ("5382357040008021292", "🆕"),
    "ban": ("5240241223632954241", "🚫"),
    "map": ("5391032818111363540", "📍"),
    "trash": ("5445267414562389170", "🗑"),
    "stop": ("5260293700088511294", "⛔️"),
    "stats": ("5231200819986047254", "📊"),
    "warn": ("5447644880824181073", "⚠️"),
    "info": ("5334544901428229844", "ℹ️"),
    "ok": ("5206607081334906820", "✔️"),
}
_RX_CE_TOKEN = re.compile(r"\{ce:(\w+)\}")
_RX_CE_TAG = re.compile(r"<tg-emoji[^>]*>(.*?)</tg-emoji>", re.S)


def ce(name: str) -> str:
    item = CUSTOM_EMOJI.get(name)
    if not item:
        return ""
    emoji_id, fallback = item
    if not SETTINGS.get("custom_emoji", False) or _DEGRADE["on"]:
        return fallback
    return f'<tg-emoji emoji-id="{emoji_id}">{fallback}</tg-emoji>'


def _ce_render(text: str) -> str:
    """Подставляет {ce:имя} из текста помощи."""
    return _RX_CE_TOKEN.sub(lambda m_: ce(m_.group(1)), text)


def _strip_ce(text: str) -> str:
    return _RX_CE_TAG.sub(lambda m_: m_.group(1), text)


def _edit(call, text, kb):
    def _try(t, k):
        bot.edit_message_text(t, call.message.chat.id, call.message.id, reply_markup=k, parse_mode="HTML")

    try:
        _try(text, kb)
    except Exception as e:
        if "not modified" not in str(e).lower():
            done = False
            plain_text, plain = _strip_ce(text), _plain_kb(kb)
            try:        # Telegram мог не принять кастомные эмодзи или цвет кнопок — повторим без них
                _try(plain_text, plain)
                done = True
                _DEGRADE["on"] = True
                logger.warning(f"{LP} Telegram не принял цвета кнопок/эмодзи: отключаю украшения до перезапуска")
            except Exception as e2:
                done = "not modified" in str(e2).lower()
            if not done:
                try:
                    bot.send_message(call.message.chat.id, plain_text, reply_markup=plain, parse_mode="HTML")
                except Exception:
                    pass
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def _wiz_msg(chat_id, uid, text, kb):
    w = _ac_wizard.setdefault(uid, {})
    mid = w.get("message_id")
    if mid:
        try:
            bot.edit_message_text(text, chat_id, mid, reply_markup=kb, parse_mode="HTML")
            return mid
        except Exception:
            pass
    m = bot.send_message(chat_id, text, reply_markup=kb, parse_mode="HTML")
    w["message_id"] = m.message_id
    return m.message_id


# Подсказки к переключателям: у инлайн-кнопок Telegram нет «наведения», поэтому
# при нажатии показываем короткое всплывающее сообщение (до 200 символов)
TOGGLE_HINTS = {
    "enabled": "Бот сам выдаёт аккаунты по оплаченным заказам. Выключите, чтобы временно остановить выдачу.",
    "lot_sync_enabled": "Бот сам скрывает и возвращает лоты по наличию аккаунтов и балансу TDUgame.",
    "auto_hide_no_stock": "Скрывать лоты игры, когда на TDUgame нет свободных аккаунтов. Вернутся сами, когда появятся.",
    "auto_hide_zero_balance": "Скрывать все лоты, когда баланс TDUgame ниже порога (порог в «Параметры»). Вернутся после пополнения.",
    "auto_refund": "Если выдать аккаунт не вышло (нет аккаунтов или баланса), бот сам вернёт деньги покупателю на FunPay.",
    "price_guard_enabled": "Не выдавать аккаунт, если покупатель заплатил меньше себестоимости на TDUgame: защита от продажи в минус.",
    "multi_qty_confirm_enabled": "При покупке больше 1 шт бот спросит: отдельные аккаунты или один на суммарное время.",
    "review_bonus_enabled": "За хороший отзыв бот добавляет покупателю бонусные часы аренды (сколько и за сколько звёзд — в «Параметры»).",
    "notify_rental_end": "Покупателю приходит сообщение, когда его аренда закончилась.",
    "colored_buttons": "Кнопки меню окрашены: синие, зелёные (включено, создать) и красные (удалить). Выключите, если ваш Telegram цвета не показывает или они не нравятся: будут обычные серые.",
    "custom_emoji": "Анимированные эмодзи в тексте меню и помощи. Работают не у всех ботов (нужно разрешение Telegram); если не отображаются, оставьте выключенным: будут обычные смайлы.",
    "notify_sales": "Вам приходит уведомление в Telegram о каждой выдаче и продлении: цена заказа, себестоимость, прибыль.",
}


def tg_toggle(call):
    key = call.data.split(":", 1)[1]
    SETTINGS[key] = not SETTINGS.get(key, False)
    save_settings()
    hint = TOGGLE_HINTS.get(key)
    if hint:
        state = "🟢 Включено" if SETTINGS[key] else "🔴 Выключено"
        try:
            bot.answer_callback_query(call.id, f"{state}: {hint}"[:200])
        except Exception:
            pass
    tg_basic(call)


def tg_apikey(call):
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_set_apikey")
    cur = SETTINGS.get("api_key", "")
    masked = _mask_api_key(cur) or "(не задан)"
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, f"<b>🔑 API ключ TDUgame</b>\n\nТекущий: <code>{_html.escape(masked)}</code>\n\n"
                f"API-ключ генерируется на странице https://tdugame.ru/api-docs.html\n\n"
                f"Отправьте новый ключ:", kb)


def tg_stats(call):
    earned = float(STATS.get("funpay_earned_rub", 0) or 0)
    spent = float(STATS.get("spent_rub_total", 0) or 0)
    profit = earned - spent
    margin = (profit / earned * 100) if earned > 0 else 0
    active = sum(len(_active_rentals(str(k))) for k in RENTALS)
    products = []
    for item in (STATS.get("by_product") or {}).values():
        try:
            p = float(item.get("earned_rub", 0) or 0) - float(item.get("spent_rub", 0) or 0)
        except Exception:
            p = 0.0
        products.append((p, item))
    products.sort(key=lambda x: x[0], reverse=True)

    lines = [
        "<b>📊 Статистика TDUgameResell</b>",
        "",
        f"Заказов FunPay: <b>{int(STATS.get('orders_total', 0) or 0)}</b>",
        f"Выдач аккаунтов: <b>{int(STATS.get('rentals_total', 0) or 0)}</b>",
        f"Продлений: <b>{int(STATS.get('extensions_total', 0) or 0)}</b>",
        f"Бонусов за отзыв: <b>{int(STATS.get('review_bonuses_total', 0) or 0)}</b>",
        f"Активных аренд сейчас: <b>{active}</b>",
        f"Выдано часов: <b>{_money(STATS.get('hours_total', 0))}</b>",
        "",
        f"Заработано на FunPay: <b>{_money(earned)}₽</b>",
        f"Потрачено на TDUgame: <b>{_money(spent)}₽</b>",
        f"Чистый заработок: <b>{_money(profit)}₽</b>",
        f"Маржа: <b>{_money(margin)}%</b>",
    ]

    if products:
        lines.append("")
        lines.append("<b>Топ товаров:</b>")
        for profit_value, item in products[:8]:
            name = _html.escape(str(item.get("name") or "аккаунт")[:42])
            earned_i = float(item.get("earned_rub", 0) or 0)
            spent_i = float(item.get("spent_rub", 0) or 0)
            lines.append(
                f"• {name}: <b>{_money(profit_value)}₽</b> "
                f"(FP {_money(earned_i)}₽ / TDUgame {_money(spent_i)}₽)"
            )

    kb = K()
    kb.add(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, "\n".join(lines), kb)


_ui_page: dict = {}


def _save_page(key: str, page):
    try:
        _ui_page[key] = max(0, int(page))
    except Exception:
        _ui_page[key] = 0


def _page_of(key: str) -> int:
    return _ui_page.get(key, 0)


def _lot_link(lot_id) -> str:
    return f"https://funpay.com/lots/offer?id={lot_id}" if lot_id else ""


def tg_maps(call):
    page = int(call.data.split(":")[1]) if ":" in call.data else 0
    if page * 6 >= len(MAPPINGS):
        page = max(0, (len(MAPPINGS) - 1) // 6)
    _save_page("maps", page)
    per = 6
    start = page * per
    chunk = MAPPINGS[start:start + per]
    kb = K()
    for i, m in enumerate(chunk):
        idx = start + i
        label = f"{m.get('product_name', '?')} · {m.get('hours', '?')}ч · лот {m.get('lot_id', '?')}"
        kb.row(B(label[:60], callback_data=f"{P}_map:{idx}"))
    nav, _ = _nav(f"{P}_maps:", page, len(MAPPINGS), per)
    if nav:
        kb.row(*nav)
    kb.row(B("➕ Добавить привязку", callback_data=f"{P}_addmap:0"))
    kb.row(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, f"<b>🔗 Привязки лотов ({len(MAPPINGS)})</b>\n\n"
                f"Каждая привязка: лот FunPay → товар TDUgame + кол-во часов за 1 шт.\n"
                f"Один товар можно привязать к нескольким лотам — при отсутствии аккаунтов "
                f"скроются все эти лоты.", kb)


def tg_map_detail(call):
    idx = int(call.data.split(":")[1])
    if idx >= len(MAPPINGS):
        bot.answer_callback_query(call.id, "Не найдено", show_alert=True)
        return
    m = MAPPINGS[idx]
    link = _lot_link(m.get("lot_id"))
    kb = K()
    kb.row(B("⏱ Часы", callback_data=f"{P}_map_hours:{idx}"))
    kb.row(B("🗑 Удалить", callback_data=f"{P}_map_del:{idx}"))
    kb.row(_back_btn(f"{P}_maps:{_page_of('maps')}"))
    text = (
        f"<b>🔗 Привязка</b>\n\n"
        f"Товар TDUgame: <b>{_html.escape(str(m.get('product_name', '?')))}</b> (id {m.get('product_id')})\n"
        f"Лот FunPay: <code>{m.get('lot_id', '—')}</code>"
        + (f" · <a href=\"{link}\">открыть</a>\n" if link else "\n")
        + f"Подкатегория: <code>{m.get('subcategory_id', '—')}</code>\n"
        f"Ключевое слово: <code>{_html.escape(str(m.get('title_keyword', '—')))}</code>\n"
        f"Код привязки: <code>{_html.escape(_mapping_rent_code(m))}</code>\n"
        f"Часов за 1 шт: <b>{m.get('hours', '?')}</b>\n"
    )
    _edit(call, text, kb)


def tg_map_hours(call):
    idx = int(call.data.split(":")[1])
    if idx >= len(MAPPINGS):
        bot.answer_callback_query(call.id, "Не найдено", show_alert=True)
        return
    m = MAPPINGS[idx]
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_set_hours:{idx}")
    kb = K()
    link = _lot_link(m.get("lot_id"))
    if link:
        kb.row(B("🔗 Открыть лот на FunPay", url=link))
    kb.add(B("❌ Отмена", callback_data=f"{P}_map:{idx}"))
    name = _html.escape(str(m.get("product_name", "?")))
    lot_line = f"<code>{m.get('lot_id', '—')}</code>" + (f" · <a href=\"{link}\">открыть</a>" if link else "")
    _edit(call, f"<b>⏱ Часы аренды</b>\n\n"
                f"Товар: <b>{name}</b>\n"
                f"Лот FunPay: {lot_line}\n"
                f"Текущее значение: <b>{m.get('hours', '?')} ч</b>\n\n"
                f"Сколько часов аренды выдавать за покупку 1 шт этого лота?\n"
                f"Введите число от 3 до 720:", kb)


def tg_map_del(call):
    idx = int(call.data.split(":")[1])
    kb = K()
    kb.row(B("✅ Удалить", callback_data=f"{P}_map_dely:{idx}"), B("❌ Нет", callback_data=f"{P}_map:{idx}"))
    _edit(call, "Удалить эту привязку?", kb)


def tg_map_del_yes(call):
    idx = int(call.data.split(":")[1])
    if idx < len(MAPPINGS):
        MAPPINGS.pop(idx)
        save_mappings()
    bot.answer_callback_query(call.id, "✅ Удалено")
    call.data = f"{P}_maps:{_page_of('maps')}"
    tg_maps(call)


_addmap_tmp: dict = {}


def tg_addmap(call):
    page = int(call.data.split(":")[1]) if ":" in call.data else 0
    lots = []
    try:
        if cardinal_instance and cardinal_instance.profile:
            lots = cardinal_instance.profile.get_lots()
    except Exception as e:
        logger.error(f"{LP} get_lots failed: {e}")
    per = 7
    start = page * per
    chunk = lots[start:start + per]
    kb = K()
    for lot in chunk:
        title = (lot.description or str(lot.id))[:45]
        kb.row(B(f"{title} (id {lot.id})"[:60], callback_data=f"{P}_picklot:{lot.id}:{page}"))
    nav, _ = _nav(f"{P}_addmap:", page, len(lots), per)
    if nav:
        kb.row(*nav)
    kb.row(B("✍️ Ввести Lot ID / ссылку вручную", callback_data=f"{P}_addmap_manual"))
    kb.row(_back_btn(f"{P}_maps:{_page_of('maps')}"))
    if not lots:
        _edit(call, "Лоты FunPay не загружены. "
                    "Нажмите «Ввести Lot ID / ссылку вручную».", kb)
    else:
        _edit(call, "<b>➕ Шаг 1/3 — лот FunPay</b>\nВыберите свой лот, к которому привяжем товар TDUgame:", kb)


def _load_products_cache(user_id):
    api = _get_api()
    products = api.products() if api else None
    info = _addmap_tmp.setdefault(user_id, {})
    info["_all"] = products if products else []
    info.pop("_filtered", None)
    return products


def _product_list_kb(user_id, page: int):
    info = _addmap_tmp.get(user_id, {})
    products = info.get("_filtered")
    if products is None:
        products = info.get("_all", [])
    per = 8
    start = page * per
    chunk = products[start:start + per]
    kb = K()
    for p in chunk:
        kb.row(B(f"{p.get('name')} · {p.get('available_accounts', 0)} шт"[:60],
                 callback_data=f"{P}_pickprod:{p.get('id')}"))
    nav, _ = _nav(f"{P}_prodlist:", page, len(products), per)
    if nav:
        kb.row(*nav)
    kb.row(B("🔍 Поиск по названию", callback_data=f"{P}_prodsearch"))
    kb.row(B("✍️ Ввести ID товара вручную", callback_data=f"{P}_prodmanual"))
    kb.row(B("❌ Отмена", callback_data=f"{P}_maps:{_page_of('maps')}"))
    if not products:
        if info.get("_filtered") is not None:
            text = ("<b>➕ Шаг 2/3 — товар TDUgame</b>\n\nПо запросу ничего не найдено. "
                    "Нажмите «Поиск» снова или введите ID товара вручную.")
        else:
            text = ("<b>➕ Шаг 2/3 — товар TDUgame</b>\n\nСписок товаров пуст "
                    "(проверьте API ключ) — можно ввести ID товара вручную.")
    else:
        flt = " (фильтр)" if info.get("_filtered") is not None else ""
        text = (f"<b>➕ Шаг 2/3 — товар TDUgame{flt}</b>\n\n"
                f"Это привязка игры с TDUgame к лоту FunPay.\n"
                f"Выберите товар из списка или задайте ID вручную:")
    return text, kb


def tg_pick_lot(call):
    parts = call.data.split(":")
    lot_id = parts[1]
    lots = []
    try:
        lots = cardinal_instance.profile.get_lots()
    except Exception:
        pass
    subcat, kw, rent_code = None, "", ""
    for lot in lots:
        if str(lot.id) == str(lot_id):
            subcat = getattr(getattr(lot, "subcategory", None), "id", None)
            desc = lot.description or ""
            kw = desc[:40]
            codes = _extract_rent_codes(desc)
            rent_code = codes[0] if codes else ""
            break
    _addmap_tmp[call.from_user.id] = {"lot_id": lot_id, "subcategory_id": subcat,
                                      "title_keyword": kw, "rent_code": rent_code}
    _load_products_cache(call.from_user.id)
    text, kb = _product_list_kb(call.from_user.id, 0)
    _edit(call, text, kb)


def tg_addmap_manual(call):
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_manual_lot")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_maps:{_page_of('maps')}"))
    _edit(call, "<b>➕ Шаг 1/3 — лот FunPay</b>\n\nОтправьте ID лота или ссылку на лот FunPay\n"
                "(напр. <code>63635527</code> или <code>https://funpay.com/lots/offer?id=63635527</code>):", kb)


def tg_prodlist(call):
    page = int(call.data.split(":")[1]) if ":" in call.data else 0
    text, kb = _product_list_kb(call.from_user.id, page)
    _edit(call, text, kb)


def tg_prodsearch(call):
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_prodsearch")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_maps:{_page_of('maps')}"))
    _edit(call, "<b>🔍 Поиск товара TDUgame</b>\n\nВведите часть названия игры (напр. <code>counter</code>):", kb)


def tg_prodmanual(call):
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_prodmanual")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_maps:{_page_of('maps')}"))
    _edit(call, "<b>✍️ ID товара TDUgame</b>\n\nОтправьте числовой ID товара (из каталога TDUgame):", kb)


def _resolve_product_name(user_id, pid) -> str:
    for p in (_addmap_tmp.get(user_id, {}).get("_all") or []):
        if str(p.get("id")) == str(pid):
            return p.get("name") or f"#{pid}"
    return f"#{pid}"


def tg_pick_product(call):
    pid = call.data.split(":")[1]
    info = _addmap_tmp.setdefault(call.from_user.id, {})
    info["product_id"] = int(pid) if _is_int_id(pid) else pid
    info["product_name"] = _resolve_product_name(call.from_user.id, pid)
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_set_newhours")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_maps:{_page_of('maps')}"))
    _edit(call, f"<b>➕ Шаг 3/3 — часы</b>\n\nТовар: <b>{_html.escape(str(info['product_name']))}</b>\n\n"
                f"Сколько часов аренды выдавать за покупку 1 шт лота? Введите число 3-720:", kb)


TEXT_LABELS = {
    "text_delivery": "Выдача аккаунта",
    "text_extension": "Продление аренды",
    "text_friend_activated": "Режим друга включён",
    "text_friend_already": "Режим друга уже активен",
    "text_code": "Steam Guard код",
    "text_code_ask_login": "Код: запрос логина",
    "text_code_not_found": "Код: не найдено",
    "text_ask_which_account": "Какой аккаунт продлить",
    "text_extend_applied": "Продление применено",
    "text_extend_no_pending": "Продление: нет ожидающих",
    "text_review_bonus": "Бонус за отзыв",
    "text_partial": "Частичная выдача (друг)",
    "text_qty_choice": "Вопрос при покупке >1 шт",
    "text_qty_timeout_admin": "Таймаут ответа qty (админу)",
    "text_problem": "Сообщение при сбое",
    "text_rental_ended": "Окончание аренды (отзыв)",
}

PARAM_DESC = {
    "friend_minutes": "Сколько минут действует режим «Для друга» после команды !друг (по истечении — снова продление).",
    "review_bonus_hours": "Сколько часов аренды добавлять покупателю за оставленный отзыв (списывается с баланса TDUgame).",
    "balance_threshold": "Минимальный баланс TDUgame (в выбранной валюте), ниже которого лоты автоматически скрываются.",
    "poll_sec": "Как часто (в секундах) проверять наличие аккаунтов и баланс TDUgame для авто-скрытия лотов. От 60, по умолчанию 120: лимит запросов к TDUgame общий, реже — спокойнее для него.",
    "ac_min_price": "Минимальная цена авто-лота в рублях («пол»). Если себестоимость × наценка выходит дешевле — ставится это значение. Применяется при создании и пересчёте цен. Минимум 1₽.",
    "price_guard_margin_rub": "Минимальный запас к себестоимости TDUgame. Если заказ дешевле себестоимости + запас — выдача не запускается.",
    "multi_qty_confirm_ttl_min": "Сколько минут ждать ответ покупателя при покупке нескольких штук одного лота.",
    "tz_offset_hours": "Часовой пояс для показа времени покупателям (смещение от UTC). 3 = МСК. Допустимы отрицательные (напр. -3). Влияет только на отображение «Действует до», расчёты идут в UTC.",
}


def tg_params(call):
    s = SETTINGS
    kb = K()
    kb.row(B(f"👥 Режим «друг», мин: {s.get('friend_minutes')}", callback_data=f"{P}_p_set:friend_minutes"))
    kb.row(B(f"🎁 Бонус за отзыв, ч: {s.get('review_bonus_hours')}", callback_data=f"{P}_p_set:review_bonus_hours"))
    kb.row(B(f"⭐ Мин. звёзд для бонуса: {s.get('review_bonus_min_stars')}", callback_data=f"{P}_p_stars"))
    kb.row(B(f"💸 Порог баланса: {_money(s.get('balance_threshold', 0))}", callback_data=f"{P}_p_set:balance_threshold"))
    kb.row(B(f"💰 Мин. цена лота, ₽: {_money(s.get('ac_min_price', 2))}", callback_data=f"{P}_p_set:ac_min_price"))
    kb.row(B(f"🛡 Запас к себестоимости, ₽: {_money(s.get('price_guard_margin_rub', 0))}", callback_data=f"{P}_p_set:price_guard_margin_rub"))
    kb.row(B(f"🔢 Таймаут qty, мин: {s.get('multi_qty_confirm_ttl_min', 30)}", callback_data=f"{P}_p_set:multi_qty_confirm_ttl_min"))
    kb.row(B(f"⏲ Проверка склада/баланса, сек: {s.get('poll_sec')}", callback_data=f"{P}_p_set:poll_sec"))
    kb.row(B(f"🕒 Часовой пояс ({_tz_label(int(s.get('tz_offset_hours', 3) or 0))}): {s.get('tz_offset_hours', 3)}",
             callback_data=f"{P}_p_set:tz_offset_hours"))
    kb.row(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, "<b>⚙️ Параметры</b>\n\n"
                "⭐ <b>Мин. звёзд</b> - минимальное количество звезд для получения бонуса за отзыв.\n"
                "Остальное — нажмите для ввода значения.", kb)


def tg_p_stars(call):
    cur = int(SETTINGS.get("review_bonus_min_stars", 5) or 5)
    SETTINGS["review_bonus_min_stars"] = (cur % 5) + 1
    save_settings()
    tg_params(call)


def tg_p_set(call):
    key = call.data.split(":")[1]
    state = f"{P}_pset:{key}"
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, state)
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_params"))
    desc = PARAM_DESC.get(key, "")
    cur = SETTINGS.get(key)
    _edit(call, f"<b>✏️ Изменение параметра</b>\n\n{desc}\n\nТекущее значение: <b>{cur}</b>\n\nОтправьте новое число:", kb)


def tg_texts(call):
    page = int(call.data.split(":")[1]) if ":" in call.data else 0
    keys = list(DEFAULT_TEXTS.keys())
    if page * 7 >= len(keys):
        page = max(0, (len(keys) - 1) // 7)
    _save_page("texts", page)
    per = 7
    start = page * per
    chunk = keys[start:start + per]
    kb = K()
    for key in chunk:
        kb.row(B(f"✏️ {TEXT_LABELS.get(key, key)}", callback_data=f"{P}_text:{key}"))
    nav, _ = _nav(f"{P}_texts:", page, len(keys), per)
    if nav:
        kb.row(*nav)
    kb.row(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    _edit(call, "<b>✏️ Тексты для FunPay</b>\n\nВсе сообщения покупателям можно изменить.", kb)


def tg_text_edit(call):
    key = call.data.split(":", 1)[1]
    _set_tg_state(call.message.chat.id, call.message.id, call.from_user.id, f"{P}_settext:{key}")
    cur = get_text(key)
    placeholders = re.findall(r"\{(\w+)\}", DEFAULT_TEXTS.get(key, ""))
    ph = ", ".join(f"<code>{{{p}}}</code>" for p in dict.fromkeys(placeholders)) or "нет"
    kb = K()
    kb.row(B("♻️ Сбросить по умолчанию", callback_data=f"{P}_textreset:{key}"))
    kb.row(B("❌ Отмена", callback_data=f"{P}_texts:{_page_of('texts')}"))
    _edit(call, f"<b>✏️ {TEXT_LABELS.get(key, key)}</b>\n\n"
                f"Текущий текст:\n<code>{_html.escape(cur)}</code>\n\n"
                f"Доступные переменные: {ph}\n\nОтправьте новый текст:", kb)


def tg_text_reset(call):
    key = call.data.split(":", 1)[1]
    SETTINGS["texts"][key] = DEFAULT_TEXTS.get(key, "")
    save_settings()
    bot.answer_callback_query(call.id, "♻️ Сброшено")
    call.data = f"{P}_texts:{_page_of('texts')}"
    tg_texts(call)


def tg_msg_dispatch(message):
    chat_id = message.chat.id
    user_id = message.from_user.id
    text = (message.text or "").strip()
    sd = _get_tg_state(chat_id, user_id)
    if not sd:
        return _continue_tg_handling()
    state = sd.get("state", "") if isinstance(sd, dict) else ""
    if not state.startswith(P):
        return _continue_tg_handling()
    if text.casefold() in {"/start", "start", "/menu", "menu", "меню", "главное меню"}:
        _clear_tg_state(chat_id, user_id, True)
        return _continue_tg_handling()
    mid = getattr(message, "message_id", None)
    dkey = f"{chat_id}:{user_id}:{mid}:{state}"
    if dkey in _tg_processed_msg_ids:
        return
    _tg_processed_msg_ids.add(dkey)
    if len(_tg_processed_msg_ids) > 500:
        _tg_processed_msg_ids.clear()

    try:
        bot.delete_message(chat_id, message.message_id)
    except Exception:
        pass

    def done_kb(cb):
        kb = K()
        kb.row(B("◀️ Назад", callback_data=cb))
        return kb

    if state == f"{P}_set_apikey":
        _clear_tg_state(chat_id, user_id, True)
        key = _sanitize_key(text)
        back_kb = done_kb(f"{CBT.PLUGIN_SETTINGS}:{UUID}")
        if not key:
            try:
                bot.send_message(chat_id, "❌ Пустой ключ. Отправьте API-ключ ещё раз.", reply_markup=back_kb)
            except Exception:
                pass
            return
        SETTINGS["api_key"] = key
        save_settings()
        bal = None
        for _ in range(2):
            try:
                api = _get_api()
                bal = api.balance() if api else None
            except Exception as e:
                logger.warning(f"{LP} apikey balance check error: {e}")
                bal = None
            if bal:
                break
            time.sleep(1.5)
        if bal:
            extra = f"\nБаланс: {_money(bal.get('balance_rub', 0))} ₽"
        else:
            extra = ("\n⚠️ Ключ сохранён, но баланс получить не удалось. "
                     "Это может быть временный сбой сети/TDUgame — проверьте позже. "
                     "Если повторяется — проверьте правильность ключа.")
        try:
            bot.send_message(chat_id, f"✅ API ключ сохранён (длина {len(key)}).{extra}", reply_markup=back_kb)
        except Exception as e:
            logger.error(f"{LP} apikey send_message error: {e}")

    elif state.startswith(f"{P}_set_hours:"):
        idx = int(state.split(":")[1])
        _clear_tg_state(chat_id, user_id, True)
        if text.isdigit() and idx < len(MAPPINGS):
            new_hours = max(MIN_RENT_HOURS, min(720, int(text)))
            pid = MAPPINGS[idx].get("product_id")
            new_key = _mapping_key(pid, new_hours)
            duplicate = any(
                j != idx and new_key is not None and _mapping_key(m.get("product_id"), m.get("hours")) == new_key
                for j, m in enumerate(MAPPINGS)
            )
            if duplicate:
                bot.send_message(chat_id, "❌ Такая пара товар TDUgame + часы уже есть в привязках.",
                                 reply_markup=done_kb(f"{P}_map:{idx}"))
                return
            MAPPINGS[idx]["hours"] = new_hours
            MAPPINGS[idx]["rent_code"] = _make_rent_code(pid, new_hours)
            save_mappings()
            bot.send_message(chat_id, f"✅ Часы: {MAPPINGS[idx]['hours']}\n"
                                      f"Код привязки: <code>{MAPPINGS[idx]['rent_code']}</code>",
                             reply_markup=done_kb(f"{P}_map:{idx}"), parse_mode="HTML")
        else:
            bot.send_message(chat_id, "❌ Нужно число 3-720.", reply_markup=done_kb(f"{P}_map:{idx}"))

    elif state == f"{P}_manual_lot":
        _clear_tg_state(chat_id, user_id, True)
        m = re.search(r"id=(\d+)", text) or re.search(r"(\d{4,})", text)
        if not m:
            bot.send_message(chat_id, "❌ Не нашёл ID. Отправьте число или ссылку вида .../offer?id=12345.",
                             reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"))
            return
        lot_id = m.group(1)
        codes = _extract_rent_codes(text)
        _addmap_tmp[user_id] = {"lot_id": lot_id, "subcategory_id": None,
                                "title_keyword": "", "rent_code": codes[0] if codes else ""}
        _load_products_cache(user_id)
        text_pl, kb_pl = _product_list_kb(user_id, 0)
        bot.send_message(chat_id, f"✅ Лот {lot_id} принят.\n\n" + text_pl, reply_markup=kb_pl, parse_mode="HTML")

    elif state == f"{P}_prodsearch":
        _clear_tg_state(chat_id, user_id, True)
        info = _addmap_tmp.setdefault(user_id, {})
        q = text.lower()
        info["_filtered"] = [p for p in (info.get("_all") or []) if q in str(p.get("name", "")).lower()]
        text_pl, kb_pl = _product_list_kb(user_id, 0)
        bot.send_message(chat_id, text_pl, reply_markup=kb_pl, parse_mode="HTML")

    elif state == f"{P}_prodmanual":
        _clear_tg_state(chat_id, user_id, True)
        info = _addmap_tmp.setdefault(user_id, {})
        m = re.search(r"-?\d+", text)
        if not m:
            bot.send_message(chat_id, "❌ Нужен числовой ID товара.", reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"))
            return
        pid = m.group()
        info["product_id"] = int(pid)
        info["product_name"] = _resolve_product_name(user_id, pid)
        msg = bot.send_message(
            chat_id,
            f"Товар: <b>{_html.escape(str(info['product_name']))}</b>\n\n"
            f"Сколько часов аренды выдавать за покупку 1 шт лота? Введите число 3-720:",
            parse_mode="HTML")
        _set_tg_state(chat_id, msg.message_id, user_id, f"{P}_set_newhours")

    elif state == f"{P}_set_newhours":
        _clear_tg_state(chat_id, user_id, True)
        info = _addmap_tmp.get(user_id, {})
        if not text.isdigit() or "product_id" not in info:
            bot.send_message(chat_id, "❌ Нужно число 3-720.", reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"))
            return
        for k in ("_all", "_filtered"):
            info.pop(k, None)
        info["hours"] = max(MIN_RENT_HOURS, min(720, int(text)))
        info["currency"] = SETTINGS.get("currency", "RUB")
        lot_id = info.get("lot_id")
        if lot_id and any(str(m.get("lot_id")) == str(lot_id) for m in MAPPINGS if m.get("lot_id")):
            bot.send_message(chat_id, "❌ Этот лот уже привязан. Удалите старую привязку или измените её часы.",
                             reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"))
            return
        if _mapping_exists(info.get("product_id"), info["hours"]):
            bot.send_message(chat_id, "❌ Такая пара товар TDUgame + часы уже привязана. "
                                      "Повторную привязку не создаю, чтобы не было дублей.",
                             reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"))
            return
        rent_code = info.get("rent_code") or _make_rent_code(info.get("product_id"), info["hours"])
        if _rent_code_exists(rent_code):
            bot.send_message(chat_id, f"❌ Код <code>{_html.escape(rent_code)}</code> уже используется другой привязкой.",
                             reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"), parse_mode="HTML")
            return
        MAPPINGS.append({
            "lot_id": lot_id,
            "subcategory_id": info.get("subcategory_id"),
            "title_keyword": info.get("title_keyword", ""),
            "product_id": info.get("product_id"),
            "product_name": info.get("product_name", ""),
            "hours": info["hours"],
            "rent_code": rent_code,
            "currency": info["currency"],
        })
        save_mappings()
        _addmap_tmp.pop(user_id, None)
        bot.send_message(chat_id, f"✅ Привязка создана: {MAPPINGS[-1]['product_name']} · "
                                  f"{MAPPINGS[-1]['hours']}ч · лот {MAPPINGS[-1]['lot_id']}\n"
                                  f"Код для описания лота: <code>{MAPPINGS[-1]['rent_code']}</code>",
                         reply_markup=done_kb(f"{P}_maps:{_page_of('maps')}"), parse_mode="HTML")

    elif state.startswith(f"{P}_pset:"):
        key = state.split(":")[1]
        _clear_tg_state(chat_id, user_id, True)
        cur_sym = {"USD": "$", "RUB": "₽"}.get(SETTINGS.get("currency", "RUB"), SETTINGS.get("currency", ""))
        labels = {
            "friend_minutes": "Окно режима «друг» (мин)",
            "review_bonus_hours": "Бонус за отзыв (ч)",
            "balance_threshold": f"Порог баланса ({cur_sym})",
            "poll_sec": "Проверка склада/баланса (сек)",
            "ac_min_price": "Мин. цена лота (₽)",
            "price_guard_margin_rub": "Запас к себестоимости (₽)",
            "multi_qty_confirm_ttl_min": "Таймаут qty (мин)",
            "tz_offset_hours": "Часовой пояс (ч)",
        }
        nice = labels.get(key, key)
        float_keys = {"balance_threshold", "ac_min_price", "price_guard_margin_rub"}
        try:
            num = float(text.replace(",", "."))
            if key in float_keys:
                val = round(num, 2)
                if key == "ac_min_price" and val < 1:
                    val = 1.0
                if key == "price_guard_margin_rub" and val < 0:
                    val = 0.0
            else:
                val = int(num)
                if num != val:
                    raise ValueError("only_int")
                if key == "tz_offset_hours":
                    val = max(-12, min(14, val))
                if key == "multi_qty_confirm_ttl_min":
                    val = max(1, min(1440, val))
                if key == "poll_sec":
                    # чаще раза в минуту нет смысла и нельзя: лимит запросов к TDUgame общий
                    val = max(60, val)
            SETTINGS[key] = val
            save_settings()
            if key == "ac_min_price":
                t = _ac_tpl()
                if t:
                    t["min_price"] = val
                    SETTINGS["ac_template"] = t
                    save_settings()
                try:
                    _price_cache.clear()
                except Exception:
                    pass
            bot.send_message(chat_id, f"✅ <b>{nice}</b>: {_money(val)}",
                             reply_markup=done_kb(f"{P}_params"), parse_mode="HTML")
        except ValueError as ve:
            if str(ve) == "only_int":
                bot.send_message(chat_id, "❌ Здесь нужно целое число.", reply_markup=done_kb(f"{P}_params"))
            else:
                bot.send_message(chat_id, "❌ Нужно число.", reply_markup=done_kb(f"{P}_params"))
        except Exception:
            bot.send_message(chat_id, "❌ Нужно число.", reply_markup=done_kb(f"{P}_params"))

    elif state.startswith(f"{P}_settext:"):
        key = state.split(":", 1)[1]
        _clear_tg_state(chat_id, user_id, True)
        SETTINGS["texts"][key] = text
        save_settings()
        bot.send_message(chat_id, f"✅ Текст «{key}» обновлён.", reply_markup=done_kb(f"{P}_texts:{_page_of('texts')}"))

    elif state == f"{P}_ac_w_dcustom":
        _clear_tg_state(chat_id, user_id, True)
        hrs = []
        for tok in re.split(r"[,\s]+", text.strip()):
            if tok.isdigit():
                h = max(MIN_RENT_HOURS, min(720, int(tok)))
                if h not in hrs:
                    hrs.append(h)
        if hrs:
            _ac_wizard.setdefault(user_id, {})["durations"] = sorted(hrs)
            _ac_w_goto(chat_id, user_id, f"{P}_ac_w_sum_ru",
                       f"<b>Шаг 2/6 — краткое описание (RU)</b>\n\n{AC_PLACEHOLDER_HINT}\n\n"
                       f"Пример:\n<code>🟩 АВТОВЫДАЧА {PH_GAME} STEAM {PH_TIME}</code>")
        else:
            _wiz_msg(chat_id, user_id, "❌ Пример: 3, 24, 72, 168\nОтправьте часы ещё раз.",
                     done_kb(f"{P}_ac"))
            _set_tg_state(chat_id, _ac_wizard.get(user_id, {}).get("message_id", message.message_id),
                          user_id, f"{P}_ac_w_dcustom")

    elif state == f"{P}_ac_dur_custom":
        _clear_tg_state(chat_id, user_id, True)
        hrs = sorted({max(MIN_RENT_HOURS, min(720, int(tok))) for tok in re.split(r"[,\s]+", text.strip()) if tok.isdigit()})
        if hrs and _ac_has_template():
            _ac_tpl()["durations"] = hrs[:12]
            save_settings()
            bot.send_message(chat_id, f"✅ Сроки обновлены: {', '.join(_dur_label(h) for h in hrs[:12])}.",
                             reply_markup=done_kb(f"{P}_ac_dur"))
        else:
            bot.send_message(chat_id, "❌ Пример: 1, 24, 72, 168", reply_markup=done_kb(f"{P}_ac_dur"))

    elif state == f"{P}_ac_w_sum_ru":
        _clear_tg_state(chat_id, user_id, True)
        _ac_wizard.setdefault(user_id, {})["summary_ru"] = text
        _ac_w_goto(chat_id, user_id, f"{P}_ac_w_sum_en",
                   "<b>Шаг 3/6 — краткое описание (EN)</b>\n\n"
                   f"Тот же смысл на английском. {PH_TIME} / {PH_GAME} работают так же.\n"
                   f"До ~70 символов.")

    elif state == f"{P}_ac_w_sum_en":
        _clear_tg_state(chat_id, user_id, True)
        _ac_wizard.setdefault(user_id, {})["summary_en"] = text
        _ac_w_goto(chat_id, user_id, f"{P}_ac_w_desc_ru",
                   f"<b>Шаг 4/6 — подробное описание (RU)</b>\n\n{AC_PLACEHOLDER_HINT}")

    elif state == f"{P}_ac_w_desc_ru":
        _clear_tg_state(chat_id, user_id, True)
        _ac_wizard.setdefault(user_id, {})["desc_ru"] = text
        _ac_w_goto(chat_id, user_id, f"{P}_ac_w_desc_en",
                   "<b>Шаг 5/6 — подробное описание (EN)</b>\n\nАнглийская версия описания.")

    elif state == f"{P}_ac_w_desc_en":
        _clear_tg_state(chat_id, user_id, True)
        _ac_wizard.setdefault(user_id, {})["desc_en"] = text
        kb = K()
        kb.row(B("📈 Наценка % от себестоимости TDUgame", callback_data=f"{P}_ac_w_pmode:markup"))
        kb.row(B("💰 Своя цена ₽ на каждое время", callback_data=f"{P}_ac_w_pmode:fixed"))
        kb.row(B("❌ Отмена", callback_data=f"{P}_ac"))
        _wiz_msg(chat_id, user_id,
                 "<b>Шаг 6/6 — цены</b>\n\n"
                 "• <b>Наценка</b> — берём себестоимость TDUgame по каждому времени и добавляем ваш %.\n"
                 "• <b>Своя цена</b> — вы вручную вписываете цену в ₽ для покупателя отдельно "
                 "на каждое время (3 часа, 1 день, …).\n\n"
                 "В обоих случаях комиссия FunPay учитывается автоматически — "
                 "покупатель видит ровно вашу цену.", kb)

    elif state == f"{P}_ac_w_markup":
        _clear_tg_state(chat_id, user_id, True)
        try:
            _ac_wizard.setdefault(user_id, {})["markup_percent"] = round(float(text.replace(",", ".")), 2)
        except Exception:
            _wiz_msg(chat_id, user_id, "❌ Нужно число (процент). Отправьте ещё раз, напр. <code>50</code>.",
                     done_kb(f"{P}_ac"))
            _set_tg_state(chat_id, _ac_wizard.get(user_id, {}).get("message_id", message.message_id),
                          user_id, f"{P}_ac_w_markup")
            return
        _ac_finish(chat_id, user_id)

    elif state == f"{P}_ac_game_miss_input":
        _clear_tg_state(chat_id, user_id, True)
        parts = text.strip().split(maxsplit=1)
        if not parts or not _is_int_id(parts[0]):
            _wiz_msg(chat_id, user_id, "❌ Отправьте числовой ID товара TDUgame, например <code>343</code>.",
                     done_kb(f"{P}_ac"))
            return
        pid = int(parts[0])
        durations = None
        if len(parts) > 1:
            try:
                durations = sorted({int(x) for x in re.split(r"[,;\s]+", parts[1]) if x})
            except ValueError:
                durations = None
            if not durations or any(h < 1 or h > 720 for h in durations):
                _wiz_msg(chat_id, user_id, "❌ Часы укажите числами от 1 до 720: <code>1,3,24,72</code>.", done_kb(f"{P}_ac"))
                return
        if not _build_plan(user_id, missing_only=True, product_id=pid, durations=durations):
            _wiz_msg(chat_id, user_id, f"✅ Для товара TDUgame <code>{pid}</code> недостающих лотов нет.",
                     done_kb(f"{P}_ac"))
            return
        kb = K()
        kb.row(B("👁 Предпросмотр лотов", callback_data=f"{P}_ac_prev:0"))
        kb.row(_back_btn(f"{P}_ac"))
        _wiz_msg(chat_id, user_id, f"✅ План для товара TDUgame <code>{pid}</code> готов.", kb)

    elif state == f"{P}_acx_input":
        _clear_tg_state(chat_id, user_id, True)
        tokens = text.split()
        names = _acx_names()
        back = done_kb(f"{P}_acx")
        if not names:
            _wiz_msg(chat_id, user_id, "❌ Не удалось загрузить список игр TDUgame. Попробуйте позже.", back)
            return
        if tokens and all(_is_int_id(t) for t in tokens):
            ids = [int(t) for t in tokens]
            unknown = [i for i in ids if i not in names]
            ok = [i for i in ids if i in names]
            n = _acx_add_ids(ok)
            msg = f"✅ Исключено игр: <b>{n}</b>."
            if unknown:
                msg += "\n❌ Нет на TDUgame: " + ", ".join(f"<code>{i}</code>" for i in unknown)
            kb = K()
            kb.row(B("🚫 К списку исключений", callback_data=f"{P}_acx"))
            _wiz_msg(chat_id, user_id, msg, kb)
            return
        q = _norm_game(text)
        found = [(pid, nm) for pid, nm in names.items() if q and q in _norm_game(nm)]
        if not found:
            _wiz_msg(chat_id, user_id, f"❌ По запросу «{_html.escape(text[:40])}» ничего не найдено.", back)
            return
        if len(found) == 1:
            _acx_add_ids([found[0][0]])
            kb = K()
            kb.row(B("🚫 К списку исключений", callback_data=f"{P}_acx"))
            _wiz_msg(chat_id, user_id, f"✅ Исключено: <b>{_html.escape(found[0][1])}</b>", kb)
            return
        found.sort(key=lambda x: x[1].lower())
        kb = K()
        for pid, nm in found[:10]:
            kb.row(B(nm[:50], callback_data=f"{P}_acx_pick:{pid}"))
        kb.row(_back_btn(f"{P}_acx"))
        more = f"\n<i>Найдено {len(found)}, показаны первые 10 — уточните запрос.</i>" if len(found) > 10 else ""
        _wiz_msg(chat_id, user_id, f"<b>Выберите игру:</b>{more}", kb)

    elif state == f"{P}_ac_game_markup_input":
        _clear_tg_state(chat_id, user_id, True)
        parts = text.split()
        if len(parts) != 2 or not _is_int_id(parts[0]):
            _wiz_msg(chat_id, user_id, "❌ Формат: <code>ID процент</code>, например <code>343 30</code>.",
                     done_kb(f"{P}_ac"))
            return
        pid = str(int(parts[0]))
        overrides = SETTINGS.setdefault("product_markup_percent", {})
        if parts[1].lower() == "default":
            overrides.pop(pid, None)
            result = f"Наценка для товара {pid} сброшена на общую из шаблона."
        else:
            try:
                pct = round(float(parts[1].replace(",", ".")), 2)
            except Exception:
                _wiz_msg(chat_id, user_id, "❌ Процент должен быть числом, например <code>30</code>.",
                         done_kb(f"{P}_ac"))
                return
            if not -90 <= pct <= 1000:
                _wiz_msg(chat_id, user_id, "❌ Укажите процент от -90 до 1000.", done_kb(f"{P}_ac"))
                return
            overrides[pid] = pct
            result = f"Наценка для товара {pid}: {pct:g}%."
        save_settings()
        _price_cache.clear()
        targets = [m for m in MAPPINGS
                   if m.get("auto_created") and str(m.get("product_id")) == pid
                   and m.get("lot_id") and m.get("hours") is not None]
        if not _ac_begin("Пересчёт цен игры"):
            _wiz_msg(chat_id, user_id, f"✅ {result}\n\nДругой пересчёт уже выполняется.", done_kb(f"{P}_ac"))
            return
        mid = _wiz_msg(chat_id, user_id,
                       f"✅ {result}\n\nЗапускаю пересчёт только этой игры: {len(targets)} лотов...\n"
                       f"<code>{_bar(0, len(targets))}</code>", None)
        th = threading.Thread(target=_reprice_thread_entry,
                               args=(user_id, chat_id, mid, int(pid)), daemon=True)
        th.start()

    elif state == f"{P}_ac_reprice_pct":
        _clear_tg_state(chat_id, user_id, True)
        try:
            pct = round(float(text.replace(",", ".")), 2)
        except Exception:
            _wiz_msg(chat_id, user_id, "❌ Нужно число (процент). Напр. <code>8</code>.", done_kb(f"{P}_ac"))
            _set_tg_state(chat_id, _ac_wizard.get(user_id, {}).get("message_id", message.message_id),
                          user_id, f"{P}_ac_reprice_pct")
            return
        t = _ac_tpl()
        t["price_mode"] = "markup"
        t["markup_percent"] = pct
        SETTINGS["ac_template"] = t
        save_settings()
        _price_cache.clear()
        mid = _ac_wizard.get(user_id, {}).get("message_id", message.message_id)
        reprice_total = len([m for m in MAPPINGS
                             if m.get("auto_created") and m.get("lot_id")
                             and m.get("product_id") is not None and m.get("hours") is not None])
        if not _ac_begin("Пересчёт цен…"):
            _wiz_msg(chat_id, user_id, "Уже идёт другой процесс — дождитесь завершения.", done_kb(f"{P}_ac"))
            return
        mid = _wiz_msg(chat_id, user_id,
                       f"♻️ Запускаю пересчёт по наценке <b>{_pct(pct)}%</b>…\n\n<code>{_bar(0, reprice_total)}</code>", None)
        logger.info(f"{LP} [AC] reprice thread starting: user_id={user_id}, chat_id={chat_id}, mid={mid}, total={reprice_total}")
        th = threading.Thread(target=_reprice_thread_entry, args=(user_id, chat_id, mid), daemon=True)
        th.start()
        logger.info(f"{LP} [AC] reprice thread started: alive={th.is_alive()}")

    elif state == f"{P}_ac_e_markup":
        _clear_tg_state(chat_id, user_id, True)
        try:
            pct = round(float(text.replace(",", ".")), 2)
        except Exception:
            _wiz_msg(chat_id, user_id, "❌ Нужно число (процент). Напр. <code>5</code>.", done_kb(f"{P}_ac_edit"))
            _set_tg_state(chat_id, _ac_wizard.get(user_id, {}).get("message_id", message.message_id),
                          user_id, f"{P}_ac_e_markup")
            return
        t = _ac_tpl()
        t["price_mode"] = "markup"
        t["markup_percent"] = pct
        SETTINGS["ac_template"] = t
        save_settings()
        _price_cache.clear()
        _wiz_msg(chat_id, user_id,
                 f"✅ Наценка обновлена: <b>{_pct(pct)}%</b>.\n\nДля уже созданных лотов нажмите "
                 f"«♻️ Пересчитать цены».", done_kb(f"{P}_ac_edit"))

    elif state == f"{P}_ac_e_title":
        _clear_tg_state(chat_id, user_id, True)
        ru, en = (text.split("|||", 1) + [""])[:2]
        ru = ru.strip()
        en = en.strip() or _ascii(ru, keep_formatting=True)
        t = _ac_tpl()
        t["summary_ru"] = ru
        t["summary_en"] = en
        SETTINGS["ac_template"] = t
        save_settings()
        _wiz_msg(chat_id, user_id,
                 f"✅ Название обновлено.\n\nRU: <code>{_html.escape(ru)}</code>\n"
                 f"EN: <code>{_html.escape(en)}</code>", done_kb(f"{P}_ac_edit"))

    elif state == f"{P}_ac_e_desc":
        _clear_tg_state(chat_id, user_id, True)
        ru, en = (text.split("|||", 1) + [""])[:2]
        ru = ru.strip()
        en = en.strip() or _ascii(ru)
        t = _ac_tpl()
        t["desc_ru"] = ru
        t["desc_en"] = en
        SETTINGS["ac_template"] = t
        save_settings()
        _wiz_msg(chat_id, user_id, "✅ Описание обновлено.", done_kb(f"{P}_ac_edit"))

    elif state == f"{P}_ac_w_fixprice":
        _clear_tg_state(chat_id, user_id, True)
        w = _ac_wizard.setdefault(user_id, {})
        durs = w.get("durations") or [1, 24, 72, 168]
        idx = int(w.get("fix_idx", 0))
        try:
            price = float(text.replace(",", "."))
        except Exception:
            _wiz_msg(chat_id, user_id,
                     f"❌ Нужно число. Цена для <b>{_format_time_text(durs[idx])}</b> (₽):",
                     done_kb(f"{P}_ac"))
            _set_tg_state(chat_id, w.get("message_id", message.message_id), user_id, f"{P}_ac_w_fixprice")
            return
        w.setdefault("fixed_prices", {})[str(durs[idx])] = price
        w["fix_idx"] = idx + 1
        _ac_ask_fixprice(chat_id, user_id)

    elif state == f"{P}_ac_w_usd":
        _clear_tg_state(chat_id, user_id, True)
        try:
            rate = float(text.replace(",", "."))
        except Exception:
            _wiz_msg(chat_id, user_id, "❌ Нужно число (курс). Напр. <code>95</code>.", done_kb(f"{P}_ac"))
            _set_tg_state(chat_id, _ac_wizard.get(user_id, {}).get("message_id", message.message_id),
                          user_id, f"{P}_ac_w_usd")
            return
        _ac_wizard.setdefault(user_id, {})["usd_rub"] = rate
        SETTINGS["ac_usd_rub"] = rate
        save_settings()
        _ac_finish(chat_id, user_id)


# ============================================================================
#  АВТОСОЗДАНИЕ ЛОТОВ
# ============================================================================

AC_PLACEHOLDER_HINT = (
    f"<b>Плейсхолдеры</b> (подставляются в каждый лот автоматически):\n"
    f"• <code>{PH_TIME}</code> — время аренды со склонением (3 часа, 1 день, 2 дня…)\n"
    f"• <code>{PH_GAME}</code> — название игры\n\n"
    f"В названии лота длинная игра/DLC будет сокращена так, чтобы <code>{PH_TIME}</code> не пропал. "
    f"Полное название автоматически добавляется первой строкой в подробное описание.\n"
    f"Не указывайте игру и время вручную — только плейсхолдеры.\n\n"
    f"<b>Команды покупателя</b> (добавьте их в подробное описание):\n"
    f"Команда должна быть точным первым словом: <code>!продл</code> не считается командой.\n"
    f"• <code>!кодстим логин</code> — прислать актуальный код Steam Guard для выданного аккаунта.\n"
    f"• <code>!продлить логин</code> — продлить аренду этого же аккаунта (после повторной оплаты лота).\n"
    f"• <code>!друг</code> — режим «для друга»: см. ниже. <code>!friend</code> тоже работает как алиас.\n\n"
    f"<b>Как работает <code>!друг</code></b> (объясните это покупателю):\n"
    f"По умолчанию, если покупатель оплачивает лот повторно, бот <b>продлевает</b> его текущий "
    f"аккаунт (то же время на том же аккаунте). Команда <code>!друг</code> включает на несколько "
    f"минут режим, при котором следующая оплата выдаёт <b>новый отдельный аккаунт</b>, а не продлевает "
    f"старый. Если покупатель берёт несколько штук, бот спросит: выдать несколько отдельных аккаунтов "
    f"или один аккаунт на суммарное время.\n"
    f"Порядок: написать <code>!друг</code> → оплатить лот в течение окна (по умолчанию ~10 мин). "
    f"После этой оплаты режим выключается автоматически.\n\n"
    f"<b>Код привязки</b>\n"
    f"Плагин автоматически добавляет в подробное описание автолота код вида <code>rent:ID-HOURS</code>. "
    f"В название код не добавляется. Не удаляйте его из подробного описания: по нему бот точно определяет товар, если FunPay не передал ID лота.\n\n"
    f"<i>{FP_LIMITS_HINT}</i>"
)


def _ac_bar(done: int, total: int, width: int = 10) -> str:
    total = max(1, int(total))
    fill = int(round(width * min(1.0, max(0, done) / total)))
    return "▰" * fill + "▱" * (width - fill)


def _ac_durations_label(tpl: "dict | None" = None) -> str:
    t = tpl or _ac_tpl()
    ds = t.get("durations") or []
    return ", ".join(f"{h // 24}д" if h >= 24 and h % 24 == 0 else f"{h}ч" for h in ds) or "—"


def _ac_price_label(tpl: "dict | None" = None) -> str:
    t = tpl or _ac_tpl()
    if t.get("price_mode") == "fixed":
        fp = t.get("fixed_prices") or {}
        if fp:
            parts = []
            for h in (t.get("durations") or []):
                v = fp.get(str(h), fp.get(h))
                if v is not None:
                    lbl = f"{h}ч" if h < 48 else f"{h//24}д"
                    parts.append(f"{lbl}={int(v)}₽")
            return "фикс. " + ", ".join(parts) if parts else "фикс."
        return f"фикс. {int(t.get('fixed_public_rub') or 0)}₽"
    return f"наценка {_pct(t.get('markup_percent', 0))}%"


def _ac_count_missing() -> int:
    return len(_build_plan_items(missing_only=True, bind_existing=False))


def _diag_example(d: dict) -> str:
    name = d.get("name") or d.get("product_name") or d.get("map_name") or d.get("product_name") or "—"
    bits = []
    for key, label in (("product_id", "pid"), ("map_product_id", "map_pid"),
                       ("subcategory_id", "sub"), ("hours", "ч")):
        val = d.get(key)
        if val not in (None, ""):
            bits.append(f"{label}={val}")
    if d.get("placement"):
        bits.append(f"place={d.get('placement')}")
    if d.get("min_hours") is not None or d.get("max_hours") is not None:
        bits.append(f"лимит={d.get('min_hours', '?')}-{d.get('max_hours', '?')}ч")
    if d.get("count") is not None and d.get("cap") is not None:
        bits.append(f"{d.get('count')}/{d.get('cap')} лотов")
    return f"{name}" + (f" ({', '.join(bits)})" if bits else "")


def _ac_diagnostics_text() -> str:
    api = _get_api()
    if not api:
        return "⛔️ <b>Диагностика карты</b>\n\nAPI ключ TDUgame не задан."
    if _products(api) is None:
        return "⛔️ <b>Диагностика карты</b>\n\nTDUgame API не отвечает или ключ неверный."

    diagnostics = []
    plan = _build_plan_items(missing_only=True, bind_existing=False, diagnostics=diagnostics)

    try:
        cap = None
    except Exception:
        cap = None
    own_lots_cache = {}
    sub_counts = {}
    for it in plan:
        try:
            sub_id = int(it["subcategory_id"])
        except Exception:
            continue
        if sub_id not in sub_counts:
            sub_counts[sub_id] = len(_my_subcat_lots(sub_id, own_lots_cache))
        cap = _subcategory_cap(sub_id, it.get("category_id"))
        if sub_counts[sub_id] >= cap:
            _diag_add(diagnostics, "лимит подкатегории",
                      product_id=it.get("product_id"), name=it.get("game_name"),
                      subcategory_id=sub_id, hours=it.get("hours"),
                      count=sub_counts[sub_id], cap=cap)

    counts = Counter(d.get("reason", "другое") for d in diagnostics)
    lines = [
        "<b>🧭 Диагностика карты автолотов</b>",
        "",
        f"К созданию по текущему шаблону: <b>{len(plan)}</b>",
        f"Диагностических записей: <b>{len(diagnostics)}</b>",
    ]
    if counts:
        lines.append("")
        lines.append("<b>Причины:</b>")
        for reason, cnt in counts.most_common(8):
            lines.append(f"• <b>{cnt}×</b> {_html.escape(reason)}")
    else:
        lines.append("")
        lines.append("✅ Явных проблем не найдено.")

    examples = [d for d in diagnostics if d.get("reason") != "уже есть привязка"]
    if not examples:
        examples = diagnostics
    if examples:
        lines.append("")
        lines.append("<b>Примеры:</b>")
        for d in examples[:25]:
            lines.append(f"• {_html.escape(d.get('reason', 'другое'))}: "
                         f"<code>{_html.escape(_diag_example(d)[:160])}</code>")
        if len(examples) > 25:
            lines.append(f"…и ещё {len(examples) - 25}.")

    return "\n".join(lines)[:3900]


def _ac_wrap_slash(items: list, width: int = 30, indent: str = "    ") -> str:
    """['1ч','12ч',…] → «1ч / 12ч / 24ч /» + «3д / 7д / 14д», строки примерно равной длины
    (≤ width), чтобы блок кода не уезжал вбок на телефоне."""
    if not items:
        return indent + "—"
    joined_len = len(" / ".join(items))
    rows = max(1, -(-joined_len // width))          # ceil
    per = -(-len(items) // rows)                    # элементов на строку
    lines = []
    for i in range(0, len(items), per):
        chunk = items[i:i + per]
        tail = " /" if i + per < len(items) else ""
        lines.append(indent + " / ".join(chunk) + tail)
    return "\n".join(lines)


def _ac_menu_card(tpl: dict, games: int, included: int, excluded: int, created: int, missing: int) -> str:
    """Карточка статуса в моноширинном блоке. Эмодзи только в начале строк (в блоке кода
    премиум-эмодзи не работают, а эмодзи посреди строки ломают выравнивание колонок)."""
    total = created + missing
    pct = int(round(100 * created / total)) if total else 0
    left = max(0, CREATE_LIMIT_24H - _creations_24h())
    sep = "─" * 30
    durs = [x.strip() for x in _ac_durations_label(tpl).split(",") if x.strip()]
    new_on = SETTINGS.get("auto_new_games")
    rows = [
        "⏱  Сроки",
        "    " + (" / ".join(durs) if durs else "—"),
        f"💰  Цена        {_ac_price_label(tpl)}",
        sep,
        f"🎮  Игр         {_money_spaced(games)}",
        f"🟢  Включено    {_money_spaced(included)}",
        f"🔴  Исключено   {_money_spaced(excluded)}",
        f"🆕  Новые игры  {'вкл' if new_on else 'выкл'}",
        sep,
        f"📊  Создано     {_money_spaced(created)} / {_money_spaced(total)}",
        f"    {_ac_bar(created, total, 12)} {pct}%",
        f"⏳  Лимит 24ч   осталось {left} из {CREATE_LIMIT_24H}",
    ]
    return '<pre><code class="language-TDUgame">' + _html.escape("\n".join(rows)) + "</code></pre>"


def tg_ac_menu(call):
    try:
        bot.answer_callback_query(call.id, "⏳ Загружаю каталог…")
    except Exception:
        pass
    api = _get_api()
    mp = _fetch_map()
    api_err = ""
    sellable = 0
    sellable_ids = set()
    if not api:
        api_err = ("🔑 <b>API-ключ TDUgame не задан.</b> Откройте «🔑 API ключ» и вставьте ключ.\n"
                   "Ключ генерируется на странице https://tdugame.ru/api-docs.html")
    else:
        prods = _products(api)
        if prods is None:
            api_err = ("⛔️ <b>TDUgame API не отвечает или ключ неверный</b> (401 invalid_api_key).\n"
                       "Проверьте ключ в «🔑 API ключ» — без него список игр пуст.")
        else:
            _sg = _all_sellable_games(api)
            sellable = len(_sg)
            sellable_ids = {int(x["product_id"]) for x in _sg}
    cur_block = _currency_blocked_text()
    if cur_block:
        api_err = (api_err + "\n" if api_err else "") + f"⛔️ <b>{_html.escape(cur_block)}</b>"
    missing = _ac_count_missing() if (_ac_has_template() and not api_err) else 0
    _created_total = sum(1 for m_ in MAPPINGS if m_.get("lot_id"))
    run_banner = ""
    if _ac_running.get("on"):
        rt = _ac_running.get("title", "Процесс")
        rd, rtot = _ac_running.get("done", 0), _ac_running.get("total", 0)
        rv, rc = _ac_running.get("verb", "Готово"), _ac_running.get("created", 0)
        eta = ""
        st = _ac_running.get("started")
        if st and rd and rd < rtot:
            rem = max(0.0, (time.time() - st) / rd * (rtot - rd))
            eta = f" · осталось {_fmt_eta(rem)}"
        run_banner = (f"⏳ <b>Идёт: {rt}</b>\n<code>{_bar(rd, rtot)}</code>\n"
                      f"{rv}: {rc} из {rtot}{eta}\n\n")
    kb = K()
    if _ac_running.get("on"):
        kb.row(B("🔄 Обновить статус", callback_data=f"{P}_ac"),
               B("⛔ Остановить", callback_data=f"{P}_ac_stop", style="danger"))
    if _ac_has_template():
        tpl = _ac_tpl()
        kb.row(B(f"➕ Создать недостающие ({missing})", callback_data=f"{P}_ac_miss", style="success"))
        _mini = str(SETTINGS.get("miniapp_url") or "").strip()
        if _mini.startswith("https://"):
            kb.row(B("📱 Выбрать игры (мини-приложение)", web_app=WebAppInfo(url=_mini)))
        kb.row(B("🎮 Создать для одной игры", callback_data=f"{P}_ac_game_miss"))
        kb.row(B("👁 Предпросмотр цен", callback_data=f"{P}_ac_prev:0"),
               B("🚫 Исключить игры", callback_data=f"{P}_acx"))
        _n_extra = len(_cleanup_mappings())
        if _n_extra:
            kb.row(B(f"🗑 Удалить лишние лоты ({_n_extra})", callback_data=f"{P}_ac_xdel", style="danger"))
        text = (
            f"<b>📦 Создание лотов TDUgame</b>\n\n"
            + (api_err + "\n\n" if api_err else "")
            + _ac_menu_card(tpl, sellable, len(sellable_ids - _excluded_ids()),
                            len(sellable_ids & _excluded_ids()), _created_total, missing)
        )
    else:
        kb.row(B("▶️ Начать мастер настройки", callback_data=f"{P}_ac_w_start"))
        text = (
            f"<b>📦 Создание лотов TDUgame</b>\n\n"
            + (api_err + "\n\n" if api_err else "")
            + f"Разовый мастер: задаёте шаблон текстов ({PH_TIME}, {PH_GAME}), "
            f"длительности и цены — затем бот создаёт лоты на FunPay.\n\n"
            f"Игр доступно: <b>{sellable}</b> (своя категория + «Прочие игры»)\n"
            f"Карта игр: <b>{len((mp or {}).get('games', []))}</b> записей"
        )
    kb.row(B("⚙️ Параметры", callback_data=f"{P}_ac_params"),
           B("🆘 Помощь", callback_data=f"{P}_ac_help:0"))
    kb.row(_back_btn(f"{CBT.PLUGIN_SETTINGS}:{UUID}"))
    text = run_banner + text
    _edit(call, text, kb)


def tg_ac_params(call):
    """Редкие настройки и сервисные действия создания лотов — вынесены из главного меню."""
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass
    kb = K()
    if _ac_has_template():
        kb.row(B("⏱ Длительности аренды", callback_data=f"{P}_ac_dur"),
               B("🎯 Наценка для игры", callback_data=f"{P}_ac_game_markup"))
        kb.row(B("✏️ Изменить шаблон", callback_data=f"{P}_ac_edit"))
        kb.row(B("🧙 Мастер настройки (всё заново)", callback_data=f"{P}_ac_w_start"))
        kb.row(B("♻️ Пересчитать цены", callback_data=f"{P}_ac_reprice"),
               B("🧭 Диагностика карты", callback_data=f"{P}_ac_diag"))
        new_on = SETTINGS.get("auto_new_games", False)
        kb.row(B(f"🆕 Новые игры: {'🟢 вкл' if new_on else '⚪️ выкл'}", callback_data=f"{P}_ac_newtoggle",
                 style=("success" if new_on else None)))
    dbg = "🟢" if SETTINGS.get("ac_debug") else "⚪️"
    kb.row(B("🔄 Обновить карту", callback_data=f"{P}_ac_refresh"),
           B(f"{dbg} Debug", callback_data=f"{P}_ac_dbg"))
    kb.row(B(f"🙈 Удалить скрытые лоты ({len(_hidden_mappings())})", callback_data=f"{P}_ac_hdel"))
    kb.row(B("🗑 Удалить ВСЕ лоты", callback_data=f"{P}_ac_delall", style="danger"))
    kb.row(_back_btn(f"{P}_ac"))
    _edit(call, "<b>⚙️ Параметры создания лотов</b>\n\n"
                "Наценка, шаблон, пересчёт цен, диагностика, автосоздание новых игр, обновление карты, "
                "отладка и удаление лотов (скрытых или всех). Описание каждой кнопки — в «🆘 Помощь».", kb)


_DUR_CHOICES = [1, 3, 6, 12, 24, 48, 72, 168, 336, 720]


def _dur_label(h: int) -> str:
    return f"{h // 24}д" if h >= 24 and h % 24 == 0 else f"{h}ч"


def tg_ac_dur(call):
    """Выбор сроков аренды для автолотов: правит только список сроков в шаблоне."""
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass
    if not _ac_has_template():
        kb = K()
        kb.row(B("▶️ Начать мастер настройки", callback_data=f"{P}_ac_w_start"))
        kb.row(_back_btn(f"{P}_ac"))
        _edit(call, "<b>⏱ Длительности аренды</b>\n\nСначала настройте шаблон лотов.", kb)
        return
    cur = sorted({int(h) for h in (_ac_tpl().get("durations") or [])})
    kb = K()
    row = []
    for h in sorted(set(_DUR_CHOICES) | set(cur)):
        row.append(B(f"{'✅' if h in cur else '▫️'} {_dur_label(h)}", callback_data=f"{P}_ac_dtog:{h}"))
        if len(row) == 3:
            kb.row(*row)
            row = []
    if row:
        kb.row(*row)
    kb.row(B("🔁 Стандарт: 1ч, 1д, 3д, 7д", callback_data=f"{P}_ac_dtog:std"))
    kb.row(B("✍️ Свой список", callback_data=f"{P}_ac_dcust"))
    n_extra = len(_extra_duration_mappings())
    if n_extra:
        kb.row(B(f"🗑 Удалить лишние лоты ({n_extra})", callback_data=f"{P}_ac_xdel", style="danger"))
    kb.row(_back_btn(f"{P}_ac_params"))
    _edit(call, "<b>⏱ Длительности аренды</b>\n\n"
                f"Сейчас: <b>{', '.join(_dur_label(h) for h in cur)}</b>\n\n"
                "Нажмите на срок, чтобы включить или выключить его. На каждую игру создаётся по одному лоту "
                "на каждый включённый срок.\n\n"
                + (f"⚠️ Лотов на убранные сроки: <b>{n_extra}</b> — они остаются на FunPay, пока вы их не удалите "
                   "кнопкой ниже.\n\n" if n_extra else "")
                + "<i>Смена сроков сама лоты не удаляет. После изменения нажмите "
                "«Создать недостающие» — бот добавит лоты на новые сроки.</i>", kb)


def tg_ac_dtog(call):
    arg = call.data.split(":", 1)[1]
    t = _ac_tpl()
    if not t:
        bot.answer_callback_query(call.id, "Сначала настройте шаблон", show_alert=True)
        return
    cur = {int(h) for h in (t.get("durations") or [])}
    if arg == "std":
        cur = set(DURATION_PRESETS["standard"])
    else:
        try:
            h = int(arg)
        except ValueError:
            return
        if h in cur:
            if len(cur) == 1:
                bot.answer_callback_query(call.id, "Нужен хотя бы один срок", show_alert=True)
                return
            cur.discard(h)
        else:
            cur.add(h)
    t["durations"] = sorted(cur)
    save_settings()
    tg_ac_dur(call)


def tg_ac_dcust(call):
    _set_tg_state(call.message.chat.id, call.message.message_id, call.from_user.id, f"{P}_ac_dur_custom")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac_dur"))
    _edit(call, "<b>✍️ Свой список сроков</b>\n\nЧасы через запятую, от 1 до 720: <code>1, 24, 72, 168</code>", kb)


AC_HELP_PAGES = [
    (
        "<b>{ce:help} Помощь: создание лотов</b>  <i>1/3 · лоты</i>\n\n"
        "Бот сам создаёт на FunPay лоты на игры TDUgame по вашему шаблону. Цена лота — "
        "себестоимость аренды на TDUgame × ваша наценка. Повторный запуск создаёт только то, "
        "чего ещё нет в привязках, поэтому его безопасно нажимать сколько угодно раз.\n\n"
        "<b>Что написано в шапке меню</b>\n"
        "• <b>Игр на TDUgame</b> — сколько игр бот умеет выставить: для них найден раздел FunPay.\n"
        "• <b>Недостающих лотов</b> — сколько лотов (игра × срок) ещё не создано.\n"
        "• <b>Лимит FunPay</b> — FunPay разрешает не больше 500 новых лотов за 24 часа; "
        "здесь видно, сколько уже создано.\n"
        "• <b>Автосоздание новых игр</b> и <b>исключено игр</b> — состояние двух настроек со страницы 3.\n\n"
        "{ce:plus} <b>Создать недостающие</b> — главная кнопка. Сначала покажет план (сколько лотов будет создано), "
        "после подтверждения создаёт их с индикатором прогресса и оценкой времени. "
        "Если упрётесь в лимит 500 за 24 часа или в лимит раздела FunPay (15 лотов на игру), "
        "бот остановится и скажет, когда можно продолжить: просто нажмите кнопку снова.\n"
        "{ce:stop} <b>Остановить</b> — появляется, пока идёт создание или пересчёт. Бот закончит текущий шаг и "
        "остановится; уже созданные лоты остаются.\n\n"
        "⏱ <b>Длительности аренды</b> (в «⚙️ Параметры») — включайте и выключайте сроки (1ч, 1д, 3д, 7д и другие) для новых лотов. "
        "«🗑 Удалить лишние лоты» появляется, когда есть лоты на убранные сроки или игры, удалённые с TDUgame. «⚙️ Параметры» — остальные настройки: наценка, шаблон, пересчёт цен, "
        "диагностика, новые игры, обновление карты, Debug и удаление лотов.\n\n"
        "{ce:game} <b>Создать для одной игры</b> — отправьте ID товара TDUgame, например <code>343</code>, "
        "или ID и свои сроки в часах: <code>343 1,3,24,72</code>. Свои сроки действуют только на этот запуск "
        "и не меняют шаблон. Эта кнопка создаёт лоты, даже если игра в исключениях."
    ),
    (
        "<b>{ce:help} Помощь: цены и шаблон</b>  <i>2/3 · цены</i>\n\n"
        "{ce:preview} <b>Предпросмотр цен</b> — показывает, какие лоты будут созданы: игра, срок, себестоимость на "
        "TDUgame и цена для покупателя. Листается по страницам, ничего не создаёт и не меняет.\n\n"
        "{ce:edit} <b>Изменить шаблон</b> — наценка в процентах, название лота и описание (русская и английская "
        f"версии). В тексте можно писать <code>{PH_TIME}</code> (подставится срок аренды) и "
        f"<code>{PH_GAME}</code> (название игры). Изменения касаются новых лотов; цены уже созданных "
        "обновляются кнопкой «Пересчитать цены».\n\n"
        "{ce:gear} <b>Мастер настройки (всё заново)</b> — пошагово перенастраивает шаблон с нуля, 6 шагов: сроки аренды, название и "
        "описание (RU и EN), режим цен. Новый шаблон заменяет текущий. Созданные лоты мастер не трогает.\n\n"
        "{ce:target} <b>Наценка для игры</b> — отдельный процент для одной игры, например <code>343 30</code>. "
        "Чтобы вернуть общий процент шаблона, напишите <code>343 default</code>. Цены уже созданных лотов "
        "этой игры пересчитываются сразу.\n\n"
        "{ce:redo} <b>Пересчитать цены</b> — отправьте новый процент наценки, и бот обновит цену во всех лотах, "
        "которые создал сам. Минимальная цена лота (настраивается в «⚙️ Параметры») остаётся «полом»: "
        "всё, что выходит дешевле, ставится по ней."
    ),
    (
        "<b>{ce:help} Помощь: игры и сервис</b>  <i>3/3 · сервис</i>\n\n"
        "{ce:new} <b>Новые игры</b> — переключатель. Когда на TDUgame появляется новая игра, бот сам создаёт на "
        "неё лоты (по шаблону, с учётом лимитов FunPay) и присылает отчёт. Игры, которые уже были при "
        "включении, не затрагиваются.\n\n"
        "{ce:ban} <b>Исключить игры</b> — список всех игр: 🟢 бот создаёт лоты, 🔴 игра исключена. Нажмите на игру, "
        "чтобы переключить. Есть поиск по названию и кнопки «Включить все» и «Выключить все». Исключённая "
        "игра не создаётся ни в «Создать недостающие», ни как новая; уже созданные лоты остаются.\n\n"
        "{ce:map} <b>Диагностика карты</b> — показывает, почему часть игр не попадает в лоты: нет категории "
        "FunPay, игра в стоп-листе, лимит подкатегории, нет подходящей длительности.\n\n"
        "{ce:redo} <b>Обновить карту</b> — заново загружает каталог TDUgame и карту игр с разделами FunPay и "
        "сбрасывает кэш цен. Нажмите, если вышла новая игра или поменялись цены. Первый расчёт после "
        "этого дольше обычного.\n\n"
        "🐞 <b>Debug</b> — подробные записи в лог Cardinal (метка [AC-DEBUG]): какие категории и цены "
        "выбраны для каждой игры. Нужен только для разбора проблем, в чате ничего не меняет.\n\n"
        "🙈 <b>Удалить скрытые лоты</b> — удаляет с FunPay лоты, которые плагин сам скрыл (нет свободных "
        "аккаунтов или не хватает баланса), и очищает их привязки. Остальные лоты не трогает.\n\n"
        "{ce:trash} <b>Удалить ВСЕ лоты</b> — два режима на выбор: удалить с FunPay все лоты плагина вместе с "
        "привязками либо только привязки (лоты на FunPay останутся). Чужие и созданные вручную лоты "
        "не затрагиваются. Перед удалением бот просит подтверждение.\n\n"
        "<i>Данные каталога кэшируются на 15 минут.</i>"
    ),
]


def tg_ac_help(call):
    page = 0
    if ":" in call.data:
        try:
            page = int(call.data.split(":", 1)[1])
        except ValueError:
            page = 0
    page = max(0, min(page, len(AC_HELP_PAGES) - 1))
    kb = K()
    nav = []
    if page > 0:
        nav.append(B("‹", callback_data=f"{P}_ac_help:{page - 1}"))
    nav.append(B(f"{page + 1}/{len(AC_HELP_PAGES)}", callback_data=f"{P}_noop"))
    if page < len(AC_HELP_PAGES) - 1:
        nav.append(B("›", callback_data=f"{P}_ac_help:{page + 1}"))
    kb.row(*nav)
    kb.row(_back_btn(f"{P}_ac"))
    _edit(call, _ce_render(AC_HELP_PAGES[page]), kb)
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def tg_ac_newtoggle(call):
    on = not SETTINGS.get("auto_new_games", False)
    SETTINGS["auto_new_games"] = on
    if on and not isinstance(SETTINGS.get("ac_known_ids"), list):
        prods = _products(_get_api()) or []
        ids = []
        for p in prods:
            try:
                ids.append(int(p["id"]))
            except (TypeError, ValueError, KeyError):
                continue
        if ids:
            SETTINGS["ac_known_ids"] = sorted(ids)
    save_settings()
    if on:
        note = "🟢 Включено: когда на TDUgame появится новая игра, бот сам создаст на неё лоты и пришлёт отчёт."
        if not _ac_has_template():
            note += " Сначала настройте шаблон."
    else:
        note = "🔴 Выключено: новые игры не создаются сами — только вручную («Создать недостающие»)."
    try:
        bot.answer_callback_query(call.id, note[:200])
    except Exception:
        pass
    tg_ac_params(call)


def _acx_names() -> dict:
    names = {}
    for p in (_products(_get_api()) or []):
        try:
            names[int(p["id"])] = p.get("name") or ""
        except (TypeError, ValueError, KeyError):
            continue
    return names


ACX_PAGE_SIZE = 12


def _acx_render(call, page: int = 0):
    """Список ВСЕХ игр TDUgame: 🟢 — участвует в автосоздании, 🔴 — исключена. Нажатие переключает."""
    ex = _excluded_ids()
    names = _acx_names()
    games = sorted(names.items(), key=lambda kv: (kv[1] or "").lower())
    # исключённые ID, которых уже нет в каталоге, тоже показываем — иначе их не снять
    known = {pid for pid, _ in games}
    games += [(pid, f"ID {pid}") for pid in sorted(ex - known)]
    total = len(games)
    pages = max(1, (total + ACX_PAGE_SIZE - 1) // ACX_PAGE_SIZE)
    page = max(0, min(page, pages - 1))
    kb = K()
    for pid, nm in games[page * ACX_PAGE_SIZE:(page + 1) * ACX_PAGE_SIZE]:
        mark = "🔴" if pid in ex else "🟢"
        kb.row(B(f"{mark} {(nm or f'ID {pid}')[:42]}", callback_data=f"{P}_acx_t:{pid}:{page}"))
    if pages > 1:
        nav = []
        if page > 0:
            nav.append(B("‹", callback_data=f"{P}_acx:{page - 1}"))
        nav.append(B(f"{page + 1}/{pages}", callback_data=f"{P}_noop"))
        if page < pages - 1:
            nav.append(B("›", callback_data=f"{P}_acx:{page + 1}"))
        kb.row(*nav)
    kb.row(B("🔎 Найти игру", callback_data=f"{P}_acx_add"))
    kb.row(B("🟢 Включить все", callback_data=f"{P}_acx_clear"),
           B("🔴 Выключить все", callback_data=f"{P}_acx_all"))
    kb.row(_back_btn(f"{P}_ac"))
    text = (
        "<b>🚫 Игры в автосоздании</b>\n\n"
        "🟢 — бот создаёт лоты для игры\n"
        "🔴 — игра исключена: лоты не создаются ни в «Создать недостающие», ни для новых игр\n"
        "Нажмите на игру, чтобы переключить. Лоты, которые уже есть, не удаляются; "
        "явный запуск «🎮 Создать для одной игры» исключение обходит.\n\n"
    )
    if total:
        text += f"🟢 включено: <b>{total - len(ex & {p for p, _ in games})}</b> · 🔴 исключено: <b>{len(ex)}</b>"
    else:
        text += "<i>Список игр пуст — проверьте API-ключ TDUgame.</i>"
    _edit(call, text, kb)


def tg_acx(call):
    page = 0
    if call.data.startswith(f"{P}_acx:"):
        try:
            page = int(call.data.split(":", 1)[1])
        except ValueError:
            page = 0
    _acx_render(call, page)


def tg_acx_t(call):
    _, pid_s, page_s = call.data.split(":", 2)
    pid, page = int(pid_s), int(page_s)
    ex = _excluded_ids()
    if pid in ex:
        ex.discard(pid)
        note = "🟢 Включена в автосоздание"
    else:
        ex.add(pid)
        note = "🔴 Исключена из автосоздания"
    SETTINGS["ac_excluded_ids"] = sorted(ex)
    save_settings()
    try:
        bot.answer_callback_query(call.id, note)
    except Exception:
        pass
    _acx_render(call, page)


def tg_acx_all(call):
    ids = set(_acx_names().keys()) | _excluded_ids()
    SETTINGS["ac_excluded_ids"] = sorted(ids)
    save_settings()
    try:
        bot.answer_callback_query(call.id, "🔴 Все игры исключены — включайте нужные нажатием")
    except Exception:
        pass
    _acx_render(call, 0)


def tg_acx_add(call):
    uid = call.from_user.id
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_acx"))
    _wiz_msg(call.message.chat.id, uid,
             "<b>➕ Исключить игру из автосоздания</b>\n\n"
             "Отправьте название игры (или его часть) либо ID товара TDUgame.\n"
             "Примеры: <code>god of war</code> или <code>343</code>. "
             "Несколько ID — через пробел: <code>343 12 -20</code>.", kb)
    _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_acx_input")
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def _acx_add_ids(ids) -> int:
    cur = _excluded_ids()
    added = [i for i in ids if i not in cur]
    if added:
        SETTINGS["ac_excluded_ids"] = sorted(cur | set(added))
        save_settings()
    return len(added)


def tg_acx_pick(call):
    pid = int(call.data.split(":", 1)[1])
    n = _acx_add_ids([pid])
    name = _acx_names().get(pid) or f"ID {pid}"
    try:
        bot.answer_callback_query(call.id, f"Исключено: {name}"[:200] if n else "Уже в списке")
    except Exception:
        pass
    tg_acx(call)


def tg_acx_rm(call):
    pid = int(call.data.split(":", 1)[1])
    SETTINGS["ac_excluded_ids"] = sorted(_excluded_ids() - {pid})
    save_settings()
    try:
        bot.answer_callback_query(call.id, "Игра снова участвует в автосоздании")
    except Exception:
        pass
    tg_acx(call)


def tg_acx_clear(call):
    SETTINGS["ac_excluded_ids"] = []
    save_settings()
    try:
        bot.answer_callback_query(call.id, "Список исключений очищен")
    except Exception:
        pass
    tg_acx(call)


def tg_ac_dbg(call):
    SETTINGS["ac_debug"] = not SETTINGS.get("ac_debug")
    save_settings()
    state = "включён" if SETTINGS["ac_debug"] else "выключен"
    bot.answer_callback_query(call.id, f"Debug-режим {state}")
    tg_ac_params(call)


def tg_ac_w_start(call):
    uid = call.from_user.id
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    tg_ac_w_dur(call)


def tg_ac_w_dur(call):
    kb = K()
    for name, ds in DURATION_PRESETS.items():
        lbl = ", ".join(f"{h}ч" if h < 48 else f"{h//24}д" for h in ds)
        kb.row(B(f"{name}: {lbl}", callback_data=f"{P}_ac_w_dpre:{name}"))
    kb.row(B("✍️ Свой список", callback_data=f"{P}_ac_w_dcustom"))
    kb.row(_back_btn(f"{P}_ac"))
    _edit(call, "<b>Шаг 1/6 — длительности</b>\n\nСколько лотов на одну игру (по одному на каждое время):\n"
                "выберите пресет или введите часы через запятую.", kb)


def tg_ac_w_dpre(call):
    uid = call.from_user.id
    name = call.data.split(":", 1)[1]
    if name in DURATION_PRESETS:
        _ac_wizard.setdefault(uid, {})["durations"] = list(DURATION_PRESETS[name])
    _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_w_sum_ru")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
    _edit(call, f"<b>Шаг 2/6 — краткое описание (RU)</b>\n\n{AC_PLACEHOLDER_HINT}\n\n"
                f"Пример:\n<code>🟩 АВТОВЫДАЧА 🌿 АРЕНДА {PH_GAME} STEAM 🌿 {PH_TIME}</code>", kb)


def tg_ac_w_dcustom(call):
    _set_tg_state(call.message.chat.id, call.message.message_id, call.from_user.id, f"{P}_ac_w_dcustom")
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
    _edit(call, "<b>✍️ Свой список длительностей</b>\n\nЧасы через запятую: <code>3, 24, 72, 168</code>", kb)


def _ac_w_goto(chat_id, uid, state, text):
    kb = K()
    kb.row(B("❌ Отмена", callback_data=f"{P}_ac"))
    mid = _wiz_msg(chat_id, uid, text, kb)
    _set_tg_state(chat_id, mid, uid, state)


def _ac_ask_fixprice(chat_id, uid):
    w = _ac_wizard.setdefault(uid, {})
    durs = w.get("durations") or [1, 24, 72, 168]
    idx = int(w.get("fix_idx", 0))
    if idx >= len(durs):
        _ac_finish(chat_id, uid)
        return
    fp = w.get("fixed_prices") or {}
    done = "\n".join(
        f"  • {_format_time_text(d)} → {int(fp[str(d)])}₽"
        for d in durs[:idx] if str(d) in fp
    )
    h = durs[idx]
    txt = (f"<b>💰 Своя цена — {idx + 1}/{len(durs)}</b>\n\n"
           + (f"Уже задано:\n{done}\n\n" if done else "")
           + f"Цена <b>для покупателя</b> за <b>{_format_time_text(h)}</b> в рублях "
             f"(напр. <code>99</code>).\nКомиссия FunPay учтётся сама.")
    _ac_w_goto(chat_id, uid, f"{P}_ac_w_fixprice", txt)


def _ac_finish(chat_id, uid):
    mid = _ac_wizard.get(uid, {}).get("message_id")
    _ac_save_template_from_wizard(uid)
    n = len(_build_plan(uid, missing_only=True))
    kb = K()
    kb.row(B("👁 Предпросмотр цен", callback_data=f"{P}_ac_prev:0"))
    kb.row(B(f"➕ Создать недостающие ({n})", callback_data=f"{P}_ac_miss"))
    kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
    txt = (f"✅ <b>Шаблон сохранён.</b>\n\n"
           f"Доступно к созданию: <b>{n}</b> недостающих лотов.\n"
           f"Откройте предпросмотр цен или сразу «Создать недостающие».\n\n"
           f"<i>Если игр 0 — проверьте API-ключ TDUgame в настройках плагина.</i>")
    if mid:
        try:
            bot.edit_message_text(txt, chat_id, mid, reply_markup=kb, parse_mode="HTML")
            return
        except Exception:
            pass
    bot.send_message(chat_id, txt, reply_markup=kb, parse_mode="HTML")


def tg_ac_w_price_mode(call):
    mode = call.data.split(":")[-1]
    uid = call.from_user.id
    w = _ac_wizard.setdefault(uid, {})
    w["price_mode"] = mode
    w["message_id"] = call.message.message_id
    if mode == "markup":
        _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_w_markup")
        kb = K()
        kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
        _edit(call, "<b>Наценка</b>\n\nБерём себестоимость TDUgame по каждому времени и добавляем ваш %.\n"
                    "Отправьте процент, напр. <code>50</code> (= +50% к себестоимости):", kb)
    else:
        w["fix_idx"] = 0
        w["fixed_prices"] = {}
        bot.answer_callback_query(call.id)
        _ac_ask_fixprice(call.message.chat.id, uid)


def _ac_save_template_from_wizard(uid: int):
    w = _ac_wizard.get(uid, {})
    tpl = {
        "durations": w.get("durations") or [1, 24, 72, 168],
        "summary_ru": w.get("summary_ru", ""),
        "summary_en": w.get("summary_en", ""),
        "desc_ru": w.get("desc_ru", ""),
        "desc_en": w.get("desc_en", ""),
        "price_mode": w.get("price_mode", "markup"),
        "markup_percent": w.get("markup_percent", 50),
        "fixed_public_rub": w.get("fixed_public_rub"),
        "fixed_prices": w.get("fixed_prices") or {},
        "usd_rub": w.get("usd_rub", SETTINGS.get("ac_usd_rub", 95)),
        "min_price": SETTINGS.get("ac_min_price", 2),
    }
    SETTINGS["ac_template"] = tpl
    save_settings()
    _ac_wizard.pop(uid, None)


def tg_ac_refresh(call):
    try:
        bot.answer_callback_query(call.id, "⏳ Обновляю карту и каталог…")
    except Exception:
        pass
    _fetch_map(force=True)
    _misc_cache.clear()
    _products_cache.update({"data": None, "ts": 0, "err": False})
    _pub_lots_cache.clear()
    _commission_cache.clear()
    _node_form_cache.clear()
    _price_cache.clear()
    _QUOTE_CACHE.clear()
    with _PRICE_TABLE_LOCK:
        _PRICE_TABLE.update({"ts": 0.0, "fail_ts": 0.0})   # таблица цен тоже подтянется заново
    _map_cache["warned"] = False
    _products(_get_api(), force=True)
    tg_ac_params(call)


def tg_ac_diag(call):
    try:
        bot.answer_callback_query(call.id, "⏳ Собираю диагностику…")
    except Exception:
        pass
    kb = K()
    kb.row(B("🔄 Обновить", callback_data=f"{P}_ac_diag"))
    kb.row(_back_btn(f"{P}_ac"))
    _edit(call, _ac_diagnostics_text(), kb)


def tg_ac_preview(call, missing_only: bool = True):
    parts = call.data.split(":")
    page = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
    if not _ac_has_template():
        bot.answer_callback_query(call.id, "Сначала пройдите мастер", show_alert=True)
        return
    try:
        bot.answer_callback_query(call.id, "⏳ Считаю цены, подождите…")
    except Exception:
        pass
    api = _get_api()
    if not api or _products(api) is None:
        kb = K()
        kb.row(B("🔑 API ключ", callback_data=f"{P}_apikey"))
        kb.row(_back_btn(f"{P}_ac"))
        _edit(call, "⛔️ <b>Нет связи с TDUgame.</b>\n\n"
                    "Список игр пуст — API-ключ не задан или неверный (401). "
                    "Ключ генерируется на странице https://tdugame.ru/api-docs.html\n"
                    "Проверьте ключ, затем повторите предпросмотр.", kb)
        return
    saved_plan = _ac_plan.get(call.from_user.id) or {}
    plan = saved_plan.get("items")
    if "items" not in saved_plan:
        plan = _build_plan(call.from_user.id, missing_only=missing_only)
    tpl = _ac_tpl()
    if not plan:
        kb = K()
        kb.row(_back_btn(f"{P}_ac"))
        _edit(call, "✅ <b>Всё уже создано.</b>\n\n"
                    "Недостающих лотов нет — все пары «игра + время» из шаблона уже есть в привязках.\n"
                    "Появятся новые игры на TDUgame или обновите карту — тогда здесь снова будут лоты.", kb)
        return
    games = {}
    for it in plan:
        games.setdefault((it["product_id"], it["game_name"], it["subcategory_id"]), []).append(it)
    keys = list(games.keys())
    per = 4
    start = page * per
    chunk = keys[start:start + per]
    mode_s = _ac_price_label(tpl)
    lines = [
        f"<b>👁 Предпросмотр</b> · лотов: <b>{len(plan)}</b> · стр. {page + 1}/{max(1, (len(keys)+per-1)//per)} · {mode_s}",
    ]
    prods_by_id = {int(p["id"]): p for p in (_products(api) or [])}
    for k in chunk:
        pid, gname, sub_id = k
        prod = prods_by_id.get(pid, {"id": pid})
        its = sorted(games[k], key=lambda x: x["hours"])
        src = its[0].get("source", "")
        src_label = " · прочие" if src == "misc" else ""
        rows = [f"{'Срок':<6}{'Себестоимость':>14}{'Ваша цена':>12}"]
        for it in its:
            cost = _cost_rub(api, prod, it["hours"])
            pub = _public_price_rub(api, prod, it["hours"], tpl)
            rows.append(f"{_dur_label(it['hours']):<6}{_money(cost) + ' ₽':>14}{_money(pub) + ' ₽':>12}")
        lines.append(f"\n🎮 <b>{_html.escape(gname[:60])}</b>{src_label}\n<pre>{_html.escape(chr(10).join(rows))}</pre>")
    kb = K()
    nav, _ = _nav(f"{P}_ac_prev:", page, len(keys), per)
    if nav:
        kb.row(*nav)
    if plan:
        kb.row(B(f"✅ Создать {len(plan)} лотов", callback_data=f"{P}_ac_confirm"))
    kb.row(_back_btn(f"{P}_ac"))
    _edit(call, "\n".join(lines), kb)


def tg_ac_game_miss(call):
    uid = call.from_user.id
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
    _wiz_msg(call.message.chat.id, uid,
             "<b>🎮 Создание лотов для одной игры</b>\n\n"
             "Отправьте ID товара TDUgame и, при необходимости, свои часы.\n"
             "Примеры: <code>343</code> или <code>343 1,3,24,72,168</code>\n\n"
             "Свои часы применятся только к этому запуску и не изменят глобальный мастер.", kb)
    _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_game_miss_input")
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def tg_ac_miss(call):
    if not _ac_has_template():
        bot.answer_callback_query(call.id, "Нет шаблона — пройдите мастер", show_alert=True)
        return
    try:
        bot.answer_callback_query(call.id, "⏳ Считаю недостающие лоты…")
    except Exception:
        pass
    if not _build_plan(call.from_user.id, missing_only=True):
        kb = K()
        kb.row(_back_btn(f"{P}_ac"))
        _edit(call, "✅ <b>Всё уже создано.</b>\n\nНедостающих лотов нет.", kb)
        return
    tg_ac_preview(call)


def tg_ac_confirm(call):
    uid = call.from_user.id
    saved_plan = _ac_plan.get(uid) or {}
    if "items" not in saved_plan:
        _build_plan(uid, missing_only=True)
    plan = (_ac_plan.get(uid) or {}).get("items") or []
    if not plan:
        bot.answer_callback_query(call.id, "Нечего создавать", show_alert=True)
        return
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    kb = K()
    kb.row(B("🚀 Да, создать", callback_data=f"{P}_ac_go", style="success"))
    kb.row(_back_btn(f"{P}_ac_prev:0"))
    left = _creation_quota_left()
    run_n = min(len(plan), left)
    games_n = len({it["product_id"] for it in plan})
    eta_min = max(1, run_n * 3 // 60)
    sep = "─" * 30
    rows = [
        f"📦  {'К созданию'.ljust(13)}{_money_spaced(len(plan))}",
        f"🎮  {'Игр'.ljust(13)}{_money_spaced(games_n)}",
        sep,
        f"⏳  {'Лимит 24ч'.ljust(13)}осталось {left} из {CREATE_LIMIT_24H}",
        f"🚀  {'За запуск'.ljust(13)}{_money_spaced(run_n)}",
        f"🕒  {'Займёт'.ljust(13)}~{eta_min} мин",
        sep,
        f"📏  {'В разделе'.ljust(13)}до 15 лотов на игру",
    ]
    card = '<pre><code class="language-TDUgame">' + _html.escape("\n".join(rows)) + "</code></pre>"
    note = ""
    if len(plan) > left:
        _w = _quota_next_slot_in()
        _when = f"через {_fmt_eta(_w)}" if _w > 0 else "через 24 ч после запуска"
        note = (f"\n<i>Остальное бот создаст позже: FunPay пускает {CREATE_LIMIT_24H} новых лотов за 24 часа, "
                f"места начнут освобождаться {_when}. Потом снова нажмите «Создать недостающие».</i>")
    _edit(call, "<b>🚀 Подтверждение</b>\n\nСоздать лоты на FunPay?\n" + card + note, kb)


def tg_ac_stop(call):
    if not _ac_running.get("on"):
        bot.answer_callback_query(call.id, "Сейчас ничего не выполняется", show_alert=True)
        return
    _ac_cancel.set()
    bot.answer_callback_query(call.id, "Останавливаю после текущего шага…")


def tg_ac_go(call):
    uid = call.from_user.id
    plan = (_ac_plan.get(uid) or {}).get("items") or []
    if not plan:        # план хранится в памяти и пропадает при перезапуске бота — собираем заново
        try:
            bot.answer_callback_query(call.id, "⏳ Собираю план заново…")
        except Exception:
            pass
        plan = _build_plan(uid, missing_only=True) or []
    if not plan:
        bot.answer_callback_query(call.id, "Недостающих лотов нет — создавать нечего", show_alert=True)
        return
    blocked = _currency_blocked_text()
    if blocked:
        bot.answer_callback_query(call.id, blocked, show_alert=True)
        return
    if _creation_quota_left() <= 0:
        bot.answer_callback_query(
            call.id, f"Лимит FunPay: {CREATE_LIMIT_24H} лотов за 24 часа. "
                     f"Снова можно создавать через {_fmt_eta(_quota_reset_in())}.", show_alert=True)
        return
    if not _ac_begin("Создание лотов…"):
        bot.answer_callback_query(call.id, "Уже идёт другой процесс", show_alert=True)
        return
    eta = max(1, min(len(plan), _creation_quota_left()) * 3 // 60)
    _edit(call, f"🚀 <b>Запускаю создание {len(plan)} лотов</b>\n\n"
                f"<code>{_bar(0, len(plan))}</code>\n\n"
                f"Примерно ~{eta} мин. Прогресс обновляется здесь.", None)
    chat_id = call.message.chat.id
    mid = call.message.id
    threading.Thread(target=_autocreate_run, args=(uid, chat_id, mid), daemon=True).start()


def tg_ac_delall(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    linked = len([m for m in MAPPINGS if m.get("lot_id")])
    kb = K()
    kb.row(B(f"🗑 Удалить лоты плагина + привязки ({linked})", callback_data=f"{P}_ac_del_lots"))
    kb.row(B(f"🔗 Удалить только привязки ({len(MAPPINGS)})", callback_data=f"{P}_ac_del_links"))
    kb.row(B("◀️ Отмена", callback_data=f"{P}_ac"))
    _edit(call,
          "<b>🗑 Удаление лотов / привязок</b>\n\n"
          f"Привязанных лотов: <b>{linked}</b> · всего привязок: <b>{len(MAPPINGS)}</b>\n\n"
          "• <b>Лоты плагина + привязки</b> — удаляет с FunPay все привязанные/созданные плагином "
          "лоты и очищает привязки. Чужие/ручные лоты не трогает.\n"
          "• <b>Только привязки</b> — оставляет лоты на FunPay, но очищает связи в плагине "
          "(после этого автосоздание будет считать их «отсутствующими»).\n\n"
          "Выберите действие:", kb)


def tg_ac_del_links(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    kb = K()
    kb.row(B(f"✅ Да, очистить привязки ({len(MAPPINGS)})", callback_data=f"{P}_ac_del_links_yes"))
    kb.row(B("◀️ Отмена", callback_data=f"{P}_ac_delall"))
    _edit(call, f"🔗 <b>Подтверждение</b>\n\nОчистить <b>{len(MAPPINGS)}</b> привязок в плагине?\n"
                f"Лоты на FunPay <b>останутся</b>, но плагин будет считать их «отсутствующими».", kb)


def tg_ac_del_links_yes(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    n = len(MAPPINGS)
    MAPPINGS.clear()
    save_mappings()
    if HIDDEN:
        HIDDEN.clear()
        save_hidden()
    bot.answer_callback_query(call.id, f"Очищено привязок: {n}")
    kb = K()
    kb.row(B("◀️ В меню создания", callback_data=f"{P}_ac"))
    _edit(call, f"🔗 <b>Привязки очищены.</b>\n\nУдалено связей: <b>{n}</b>. "
                f"Лоты на FunPay остались на месте.", kb)


def tg_ac_del_lots(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    linked = len([m for m in MAPPINGS if m.get("lot_id")])
    if not linked:
        bot.answer_callback_query(call.id, "Нет привязанных лотов", show_alert=True)
        return
    kb = K()
    kb.row(B(f"✅ Да, удалить {linked} лотов", callback_data=f"{P}_ac_del_lots_yes"))
    kb.row(B("◀️ Отмена", callback_data=f"{P}_ac_delall"))
    _edit(call, f"🗑 <b>Подтверждение</b>\n\nБезвозвратно удалить с FunPay <b>{linked}</b> привязанных лотов "
                f"и очистить их привязки?\nЧужие/ручные лоты не затрагиваются.", kb)


def tg_ac_del_lots_yes(call):
    if not any(m.get("lot_id") for m in MAPPINGS):
        bot.answer_callback_query(call.id, "Нет привязанных лотов", show_alert=True)
        return
    if not _ac_begin("Удаление лотов…"):
        bot.answer_callback_query(call.id, "Уже идёт другой процесс", show_alert=True)
        return
    uid = call.from_user.id
    _edit(call, f"🗑 <b>Удаляю лоты плагина…</b>\n\n<code>{_bar(0, 1)}</code>\n\nПрогресс обновляется здесь.", None)
    threading.Thread(target=_delete_plugin_run, args=(uid, call.message.chat.id, call.message.id),
                     daemon=True).start()


def tg_ac_xdel(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    extra_d = _extra_duration_mappings()
    removed = _removed_game_mappings()
    if not extra_d and not removed:
        bot.answer_callback_query(call.id, "Лишних лотов нет", show_alert=True)
        return
    parts = []
    if extra_d:
        by_h = Counter(int(m.get("hours")) for m in extra_d)
        want = ", ".join(_dur_label(int(h)) for h in sorted(_ac_tpl().get("durations") or []))
        parts.append(f"⏱ <b>Убранные сроки</b> (в шаблоне: {want}):\n"
                     + "\n".join(f"• {_dur_label(h)} — {n} шт." for h, n in sorted(by_h.items())))
    if removed:
        names = Counter(str(m.get("product_name") or f"#{m.get('product_id')}") for m in removed)
        shown = "\n".join(f"• {_html.escape(n[:40])} — {c} шт." for n, c in names.most_common(8))
        more = f"\n…и ещё {len(names) - 8} игр." if len(names) > 8 else ""
        parts.append(f"🎮 <b>Игры, которых больше нет на TDUgame</b> ({len(names)}):\n{shown}{more}")
    total = len(_cleanup_mappings())
    kb = K()
    kb.row(B(f"✅ Да, удалить {total} лотов", callback_data=f"{P}_ac_xdel_yes"))
    kb.row(B("◀️ Отмена", callback_data=f"{P}_ac"))
    _edit(call, "🗑 <b>Удалить лишние лоты?</b>\n\n" + "\n\n".join(parts) + "\n\n"
                "Лоты будут <b>безвозвратно удалены с FunPay</b>, привязки очистятся. Ручные лоты и лоты, "
                "созданные «для одной игры» со своими сроками, не затрагиваются; уже идущие аренды продолжаются.", kb)


def tg_ac_xdel_yes(call):
    if not _cleanup_mappings():
        bot.answer_callback_query(call.id, "Лишних лотов нет", show_alert=True)
        return
    if not _ac_begin("Удаление лишних лотов…"):
        bot.answer_callback_query(call.id, "Уже идёт другой процесс", show_alert=True)
        return
    uid = call.from_user.id
    _edit(call, f"🗑 <b>Удаляю лишние лоты…</b>\n\n<code>{_bar(0, 1)}</code>\n\nПрогресс обновляется здесь.", None)
    threading.Thread(target=_delete_plugin_run, args=(uid, call.message.chat.id, call.message.id, True),
                     daemon=True).start()


def tg_ac_hdel(call):
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    hidden = _hidden_mappings()
    if not hidden:
        bot.answer_callback_query(call.id, "Скрытых лотов нет", show_alert=True)
        return
    kb = K()
    kb.row(B(f"✅ Да, удалить {len(hidden)} лотов", callback_data=f"{P}_ac_hdel_yes"))
    kb.row(B("◀️ Отмена", callback_data=f"{P}_ac_params"))
    _edit(call, f"🙈 <b>Удалить скрытые лоты?</b>\n\nПлагин сейчас держит скрытыми <b>{len(hidden)}</b> лотов "
                f"(например, нет свободных аккаунтов или не хватает баланса).\n\n"
                f"Они будут <b>безвозвратно удалены с FunPay</b>, привязки очистятся. Остальные лоты не затрагиваются; "
                f"уже идущие аренды продолжаются. Чтобы такие лоты вернулись, потом нажмите «Создать недостающие».", kb)


def tg_ac_hdel_yes(call):
    if not _hidden_mappings():
        bot.answer_callback_query(call.id, "Скрытых лотов нет", show_alert=True)
        return
    if not _ac_begin("Удаление скрытых лотов…"):
        bot.answer_callback_query(call.id, "Уже идёт другой процесс", show_alert=True)
        return
    uid = call.from_user.id
    _edit(call, f"🙈 <b>Удаляю скрытые лоты…</b>\n\n<code>{_bar(0, 1)}</code>\n\nПрогресс обновляется здесь.", None)
    threading.Thread(target=_delete_plugin_run, args=(uid, call.message.chat.id, call.message.id, False, True),
                     daemon=True).start()


def tg_ac_reprice(call):
    uid = call.from_user.id
    if not _ac_has_template():
        bot.answer_callback_query(call.id, "Сначала настройте шаблон", show_alert=True)
        return
    blocked = _currency_blocked_text()
    if blocked:
        bot.answer_callback_query(call.id, blocked, show_alert=True)
        return
    if _ac_running["on"]:
        bot.answer_callback_query(call.id, "Уже выполняется", show_alert=True)
        return
    n = len([m for m in MAPPINGS if m.get("auto_created") and m.get("lot_id")])
    if n == 0:
        bot.answer_callback_query(call.id, "Нет авто-созданных лотов для пересчёта", show_alert=True)
        return
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    t = _ac_tpl()
    cur = _pct(t.get("markup_percent", 0))
    floor = _money(_min_price_floor(t))
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
    _wiz_msg(call.message.chat.id, uid,
             f"<b>♻️ Пересчёт цен</b>\n\n"
             f"Авто-лотов под управлением: <b>{n}</b>\n"
             f"Текущая наценка: <b>{cur}%</b> · мин. цена: <b>{floor}₽</b>\n\n"
             f"Отправьте <b>новый процент наценки</b> (напр. <code>80</code>) — плагин пересчитает "
             f"себестоимость×наценку и обновит цену во всех этих лотах на FunPay.\n"
             f"<i>Мин. цена остаётся «полом»: всё, что выходит дешевле, ставится в {floor}₽.</i>", kb)
    _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_reprice_pct")
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def tg_ac_game_markup(call):
    uid = call.from_user.id
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    kb = K()
    kb.add(B("❌ Отмена", callback_data=f"{P}_ac"))
    _wiz_msg(call.message.chat.id, uid,
             "<b>🎯 Наценка для отдельной игры</b>\n\n"
             "Отправьте ID товара TDUgame и процент через пробел.\n"
             "Пример: <code>343 30</code>\n\n"
             "Чтобы вернуть общий процент шаблона: <code>343 default</code>.\n"
             "Для уже созданных лотов после этого запустите пересчёт цен.", kb)
    _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_game_markup_input")
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def tg_ac_edit(call):
    if not _ac_has_template():
        bot.answer_callback_query(call.id, "Сначала настройте шаблон", show_alert=True)
        return
    t = _ac_tpl()
    kb = K()
    kb.row(B("📈 Наценка %", callback_data=f"{P}_ac_e:markup"))
    kb.row(B("📝 Название (RU+EN)", callback_data=f"{P}_ac_e:title"))
    kb.row(B("📄 Описание (RU+EN)", callback_data=f"{P}_ac_e:desc"))
    kb.row(_back_btn(f"{P}_ac"))
    txt = (
        f"<b>✏️ Изменить шаблон</b>\n\n"
        f"📈 Наценка: <b>{_pct(t.get('markup_percent', 0))}%</b>\n\n"
        f"📝 Название RU:\n<code>{_html.escape(t.get('summary_ru', '') or '—')}</code>\n"
        f"📝 Название EN:\n<code>{_html.escape(t.get('summary_en', '') or '—')}</code>\n\n"
        f"📄 Описание RU:\n<code>{_html.escape((t.get('desc_ru', '') or '—')[:120])}</code>\n"
        f"📄 Описание EN:\n<code>{_html.escape((t.get('desc_en', '') or '—')[:120])}</code>\n\n"
        f"<i>{PH_TIME} → время, {PH_GAME} → игра. Изменения вступят в силу для новых лотов; "
        f"для уже созданных — нажмите «Пересчитать цены» (для наценки).</i>"
    )
    _edit(call, txt, kb)


def tg_ac_edit_field(call):
    field = call.data.split(":", 1)[1]
    uid = call.from_user.id
    t = _ac_tpl()
    _ac_wizard[uid] = {"message_id": call.message.message_id}
    if field == "markup":
        kb = K()
        kb.add(B("❌ Отмена", callback_data=f"{P}_ac_edit"))
        _wiz_msg(call.message.chat.id, uid,
                 f"<b>📈 Наценка</b>\n\nТекущая: <b>{_pct(t.get('markup_percent', 0))}%</b>\n\n"
                 f"Отправьте новый процент (напр. <code>50</code>). Цены новых лотов будут "
                 f"= себестоимость TDUgame × (1 + %).", kb)
        _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_e_markup")
    elif field == "title":
        d = _default_ac_template()
        kb = K()
        kb.add(B("✅ Использовать стандартное", callback_data=f"{P}_ac_edef:title"))
        kb.add(B("❌ Отмена", callback_data=f"{P}_ac_edit"))
        _wiz_msg(call.message.chat.id, uid,
                 f"<b>📝 Название (краткое описание)</b>\n\n"
                 f"Стандартное название:\n<code>{_html.escape(d['summary_ru'])}</code>\n"
                 f"<code>{_html.escape(d['summary_en'])}</code>\n\n"
                 f"Нажмите кнопку, чтобы взять его, или отправьте своё в одном сообщении: RU и EN через <code>|||</code>:\n"
                 f"<code>🟩 АРЕНДА {PH_GAME} STEAM 🌿 {PH_TIME} ||| RENTAL {PH_GAME} STEAM {PH_TIME}</code>\n\n"
                 f"Без <code>|||</code> — EN сделаю автоматически из RU (латиницей).", kb)
        _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_e_title")
    elif field == "desc":
        d = _default_ac_template()
        kb = K()
        kb.add(B("✅ Использовать стандартное", callback_data=f"{P}_ac_edef:desc"))
        kb.add(B("❌ Отмена", callback_data=f"{P}_ac_edit"))
        _wiz_msg(call.message.chat.id, uid,
                 f"<b>📄 Описание (подробное)</b>\n\n"
                 f"Стандартное описание (RU):\n<code>{_html.escape(d['desc_ru'])}</code>\n\n"
                 f"Нажмите кнопку, чтобы взять его (EN подставится вместе с ним), или отправьте своё "
                 f"в одном сообщении: RU и EN через <code>|||</code>:\n"
                 f"<code>Текст RU {PH_GAME} ||| Text EN {PH_GAME}</code>\n\n"
                 f"Без <code>|||</code> — EN сделаю автоматически из RU.", kb)
        _set_tg_state(call.message.chat.id, call.message.message_id, uid, f"{P}_ac_e_desc")
    try:
        bot.answer_callback_query(call.id)
    except Exception:
        pass


def tg_ac_edef(call):
    """«Использовать стандартное» название или описание лотов из шаблона «из коробки»."""
    field = call.data.split(":", 1)[1]
    if field not in ("title", "desc") or not _ac_has_template():
        return
    _clear_tg_state(call.message.chat.id, call.from_user.id, False)
    d, t = _default_ac_template(), _ac_tpl()
    if field == "title":
        t["summary_ru"], t["summary_en"] = d["summary_ru"], d["summary_en"]
        note = "✅ Название заменено на стандартное."
    else:
        t["desc_ru"], t["desc_en"] = d["desc_ru"], d["desc_en"]
        note = "✅ Описание заменено на стандартное."
    save_settings()
    try:
        bot.answer_callback_query(call.id, note)
    except Exception:
        pass
    kb = K()
    kb.row(B("◀️ К шаблону", callback_data=f"{P}_ac_edit"))
    _edit(call, f"{note}\n\nИзменения касаются новых лотов.", kb)


# ============================================================================
#  МИНИ-ПРИЛОЖЕНИЕ: веб-интерфейс плагина внутри Telegram
# ============================================================================
# Плагин поднимает маленький HTTP-сервер на 127.0.0.1:webapp_port. Он отдаёт страницы
# мини-приложения (папка storage/plugins/tdugame_resell/webapp) и JSON-API. Наружу сервер
# смотрит через обратный прокси с HTTPS (адрес — в настройке webapp_url). Доступ к API
# только из Telegram: каждый запрос подписан Telegram (initData, HMAC по токену бота), и
# пользователь должен быть в списке авторизованных пользователей Cardinal.
import hashlib
import hmac
import mimetypes
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

WEBAPP_DIR = os.path.join(PLUGIN_DIR, "webapp")
WEBAPP_AUTH_MAX_AGE = 24 * 3600
WEBAPP_MAX_BODY = 512 * 1024
_webapp_srv: dict = {"server": None, "thread": None}
_last_result: dict = {"text": "", "ts": 0.0}
_web_toggle_labels = [
    ("enabled", "Автовыдача"),
    ("lot_sync_enabled", "Автоскрытие и возврат лотов"),
    ("auto_hide_no_stock", "Скрывать лоты: нет аккаунтов"),
    ("auto_hide_zero_balance", "Скрывать лоты: нет баланса"),
    ("auto_refund", "Авто-возврат при сбое выдачи"),
    ("price_guard_enabled", "Защита от минусовой цены"),
    ("multi_qty_confirm_enabled", "Спрашивать при покупке больше 1 шт"),
    ("review_bonus_enabled", "Бонус за отзыв"),
    ("notify_rental_end", "Сообщение об окончании аренды"),
    ("notify_sales", "Уведомлять о продажах"),
    ("custom_emoji", "Анимированные эмодзи в меню бота"),
    ("colored_buttons", "Цветные кнопки в меню бота"),
]
# человеческие названия и пояснения параметров для мини-приложения: (название, пояснение, единица, быстрые значения)
_WEB_PARAM_INFO = {
    "friend_minutes": ("Режим «для друга»",
        "Сколько минут после команды !друг покупатель может взять второй аккаунт. Пока режим действует, повторная оплата выдаёт новый аккаунт, а не продлевает первый.",
        "мин", [5, 10, 30, 60]),
    "review_bonus_hours": ("Бонус за отзыв",
        "Сколько часов аренды дарим покупателю за отзыв. Списывается с вашего баланса TDUgame.",
        "ч", [1, 3, 6, 24]),
    "review_bonus_min_stars": ("Оценка для бонуса",
        "С какой оценки в отзыве покупатель получает бонус. 5 означает, что бонус только за пять звёзд.",
        "★", [3, 4, 5]),
    "balance_threshold": ("Когда скрывать лоты по балансу",
        "Если на балансе TDUgame меньше этой суммы, лоты скроются сами, чтобы не принимать заказы, которые нечем оплатить. 0 значит скрывать только когда деньги закончились. Работает, если включено «Скрывать лоты: нет баланса».",
        "₽", [0, 50, 100, 500]),
    "ac_min_price": ("Минимальная цена лота",
        "Дешевле этой цены лот не создаётся и не пересчитывается. Нужна, чтобы короткие сроки не уходили в копейки.",
        "₽", [1, 2, 5, 10]),
    "price_guard_margin_rub": ("Запас к себестоимости",
        "Бот не выдаст аккаунт, если вам заплатили меньше, чем он стоит на TDUgame плюс этот запас. Так вы не продадите в минус. Работает, если включена «Защита от минусовой цены».",
        "₽", [0, 1, 5, 10]),
    "multi_qty_confirm_ttl_min": ("Ожидание ответа про количество",
        "Если покупатель взял несколько штук, бот спрашивает, нужны ли отдельные аккаунты. Сколько минут ждать ответа, потом бот решит сам.",
        "мин", [10, 30, 60]),
    "poll_sec": ("Как часто проверять наличие",
        "Раз в сколько секунд бот смотрит, есть ли свободные аккаунты и деньги на балансе, чтобы скрывать и возвращать лоты. 120 подходит почти всем.",
        "сек", [60, 120, 300, 600]),
    "tz_offset_hours": ("Часовой пояс",
        "Нужен для времени «Действует до» в сообщениях покупателям. 3 это Москва, 5 Екатеринбург, 7 Новосибирск, 10 Владивосток.",
        "ч от UTC", [3, 5, 7, 10]),
}

_web_params = [
    ("friend_minutes", "Режим «друг», мин", "int", 1, 1440),
    ("review_bonus_hours", "Бонус за отзыв, ч", "int", 1, 720),
    ("review_bonus_min_stars", "Мин. звёзд для бонуса", "int", 1, 5),
    ("balance_threshold", "Порог баланса", "float", 0, 10_000_000),
    ("ac_min_price", "Мин. цена лота, ₽", "float", 1, 1_000_000),
    ("price_guard_margin_rub", "Запас к себестоимости, ₽", "float", 0, 1_000_000),
    ("multi_qty_confirm_ttl_min", "Таймаут вопроса про количество, мин", "int", 1, 1440),
    ("poll_sec", "Проверка склада и баланса, сек", "int", 60, 86400),
    ("tz_offset_hours", "Часовой пояс (смещение от UTC)", "int", -12, 14),
]


class _ApiError(Exception):
    def __init__(self, code: str, message: str = "", status: int = 400):
        super().__init__(message or code)
        self.code, self.message, self.status = code, message or code, status


def _html_to_plain(text: str) -> str:
    t = re.sub(r"<br\s*/?>", "\n", text or "")
    t = re.sub(r"<[^>]+>", "", t)
    return _html.unescape(t).strip()


def _set_last_result(done_html: str) -> None:
    _last_result.update({"text": _html_to_plain(done_html), "ts": time.time()})


def _webapp_user(init_data: str) -> "dict | None":
    """Проверяет подпись Telegram и право доступа. Возвращает пользователя или None."""
    if not init_data or not bot:
        return None
    try:
        data = dict(urllib.parse.parse_qsl(init_data, keep_blank_values=True))
        recv_hash = data.pop("hash", "")
        if not recv_hash:
            return None
        check = "\n".join(f"{k}={v}" for k, v in sorted(data.items()))
        secret = hmac.new(b"WebAppData", str(bot.token).encode(), hashlib.sha256).digest()
        calc = hmac.new(secret, check.encode(), hashlib.sha256).hexdigest()
        if not hmac.compare_digest(calc, recv_hash):
            return None
        if time.time() - int(data.get("auth_date", "0")) > WEBAPP_AUTH_MAX_AGE:
            return None
        user = json.loads(data.get("user") or "{}")
        if int(user.get("id")) not in set(_admins()):
            return None
        return user
    except Exception:
        return None


def _need_api():
    api = _get_api()
    if not api:
        raise _ApiError("no_key", "API-ключ TDUgame не задан")
    return api


def _need_template():
    if not _ac_has_template():
        raise _ApiError("no_template", "Шаблон лотов не настроен")
    return _ac_tpl()


def _need_idle():
    if _ac_running.get("on"):
        raise _ApiError("busy", "Сейчас уже выполняется другая операция")


def _run_state() -> dict:
    r = _ac_running
    return {
        "on": bool(r.get("on")), "title": r.get("title") or "", "verb": r.get("verb") or "Готово",
        "done": int(r.get("done") or 0), "total": int(r.get("total") or 0),
        "created": int(r.get("created") or 0), "skipped": int(r.get("skipped") or 0),
        "failed": int(r.get("failed") or 0), "startedAt": r.get("started"),
        "last": dict(_last_result),
    }


def _fmt_dur(h: int) -> str:
    return f"{h // 24}д" if h >= 24 and h % 24 == 0 else f"{h}ч"


# ---------- общее ----------
def _api_status(uid, body):
    key = SETTINGS.get("api_key", "")
    rub = _menu_balance_rub()
    low = False
    try:
        low = rub is not None and float(rub) <= float(SETTINGS.get("balance_threshold", 0) or 0)
    except Exception:
        pass
    tpl = _ac_tpl() if _ac_has_template() else None
    return {
        "name": NAME, "version": VERSION, "enabled": bool(SETTINGS.get("enabled")),
        "hasKey": bool(key), "keyMasked": _mask_api_key(key),
        "balanceRub": rub, "balanceLow": low,
        "mappings": len(MAPPINGS), "hidden": len(HIDDEN),
        "hasTemplate": bool(tpl), "run": _run_state(), "events": _events_counts(),
        "update": {"available": _ver_tuple(_update_state["latest"]) > _ver_tuple(VERSION), "latest": _update_state["latest"],
                   "pending": bool(_update_state["pending_restart"])},
    }


def _api_run(uid, body):
    return _run_state()


def _api_events(uid, body):
    body = body or {}
    try:
        limit = max(1, min(500, int(body.get("limit") or 200)))
    except Exception:
        limit = 200
    return {"events": _events_list(limit, str(body.get("level") or ""), str(body.get("kind") or "")),
            "counts": _events_counts()}


def _api_events_clear(uid, body):
    with _EVENTS_LOCK:
        _events_load()
        _EVENTS.clear()
        _events_flush(force=True)
    return {"ok": True}


def _api_stop(uid, body):
    if not _ac_running.get("on"):
        raise _ApiError("idle", "Сейчас ничего не выполняется")
    _ac_cancel.set()
    return {"ok": True}


def _api_set_enabled(uid, body):
    SETTINGS["enabled"] = bool(body.get("value"))
    save_settings()
    return {"enabled": SETTINGS["enabled"]}


# ---------- настройки ----------
def _api_settings(uid, body):
    toggles = [{"key": k, "label": lbl, "hint": TOGGLE_HINTS.get(k, ""), "value": bool(SETTINGS.get(k))}
               for k, lbl in _web_toggle_labels]
    params = []
    for key, label, kind, lo, hi in _web_params:
        nice = _WEB_PARAM_INFO.get(key)
        params.append({"key": key, "label": nice[0] if nice else label, "desc": nice[1] if nice else PARAM_DESC.get(key, ""),
                       "unit": nice[2] if nice else "", "presets": nice[3] if nice else [],
                       "kind": kind, "min": lo, "max": hi, "value": SETTINGS.get(key)})
    return {"toggles": toggles, "params": params}


def _api_toggle(uid, body):
    key = str(body.get("key") or "")
    if key not in {k for k, _ in _web_toggle_labels}:
        raise _ApiError("bad_key", "Неизвестная настройка")
    SETTINGS[key] = not SETTINGS.get(key, False)
    save_settings()
    return {"key": key, "value": bool(SETTINGS[key])}


def _api_param_set(uid, body):
    key = str(body.get("key") or "")
    spec = next((p for p in _web_params if p[0] == key), None)
    if not spec:
        raise _ApiError("bad_key", "Неизвестный параметр")
    _, _, kind, lo, hi = spec
    try:
        num = float(str(body.get("value")).replace(",", "."))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Нужно число")
    if kind == "int":
        if num != int(num):
            raise _ApiError("bad_value", "Нужно целое число")
        val = int(num)
    else:
        val = round(num, 2)
    clamped = None
    if val < lo:
        val, clamped = lo, "min"
    elif val > hi:
        val, clamped = hi, "max"
    SETTINGS[key] = val
    save_settings()
    return {"key": key, "value": val, "clamped": clamped, "min": lo, "max": hi}


def _api_apikey_set(uid, body):
    key = _sanitize_key(str(body.get("key") or ""))
    if not key:
        raise _ApiError("bad_value", "Пустой ключ")
    SETTINGS["api_key"] = key
    save_settings()
    _products_cache.update({"data": None, "ts": 0, "err": False})
    bal = None
    for _ in range(2):
        try:
            api = _get_api()
            bal = api.balance() if api else None
        except Exception as e:
            logger.warning(f"{LP} apikey balance check error: {e}")
            bal = None
        if bal:
            break
        time.sleep(1.0)
    _menu_balance_cache["ts"] = 0.0
    return {"ok": True, "keyMasked": _mask_api_key(key), "checked": bool(bal),
            "balanceRub": (bal or {}).get("balance_rub")}


def _api_stats(uid, body):
    earned = float(STATS.get("funpay_earned_rub", 0) or 0)
    spent = float(STATS.get("spent_rub_total", 0) or 0)
    profit = earned - spent
    items = []
    for item in (STATS.get("by_product") or {}).values():
        try:
            e_ = float(item.get("earned_rub", 0) or 0)
            s_ = float(item.get("spent_rub", 0) or 0)
        except Exception:
            e_ = s_ = 0.0
        items.append({"name": str(item.get("name") or "аккаунт")[:60], "earned": round(e_, 2),
                      "spent": round(s_, 2), "profit": round(e_ - s_, 2)})
    items.sort(key=lambda x: x["profit"], reverse=True)
    return {
        "orders": int(STATS.get("orders_total", 0) or 0), "rentals": int(STATS.get("rentals_total", 0) or 0),
        "extensions": int(STATS.get("extensions_total", 0) or 0),
        "reviewBonuses": int(STATS.get("review_bonuses_total", 0) or 0),
        "activeRentals": sum(len(_active_rentals(str(k))) for k in RENTALS),
        "hours": float(STATS.get("hours_total", 0) or 0),
        "earned": round(earned, 2), "spent": round(spent, 2), "profit": round(profit, 2),
        "margin": round(profit / earned * 100, 1) if earned > 0 else 0, "top": items[:10],
    }


def _api_texts(uid, body):
    out = []
    for key in DEFAULT_TEXTS.keys():
        ph = list(dict.fromkeys(re.findall(r"\{(\w+)\}", DEFAULT_TEXTS.get(key, ""))))
        out.append({"key": key, "label": TEXT_LABELS.get(key, key), "value": get_text(key),
                    "default": DEFAULT_TEXTS.get(key, ""), "placeholders": ph})
    return {"texts": out}


def _api_text_set(uid, body):
    key = str(body.get("key") or "")
    if key not in DEFAULT_TEXTS:
        raise _ApiError("bad_key", "Неизвестный текст")
    text = str(body.get("text") or "")
    if not text.strip():
        raise _ApiError("bad_value", "Текст не может быть пустым")
    # проверяем, что скобки парные и нет неизвестных подстановок: иначе покупатель получит сообщение с «{login}» вместо данных
    allowed = set(re.findall(r"\{(\w+)\}", DEFAULT_TEXTS.get(key, "")))
    try:
        string.Formatter().vformat(text, (), _SafeDict())
    except Exception:
        raise _ApiError("bad_value", "Скобки { } в тексте не парные. Подстановки пишутся так: {login}. Если нужна обычная скобка, удвойте её: {{ }}")
    unknown = sorted(set(re.findall(r"\{(\w+)\}", text)) - allowed)
    if unknown:
        raise _ApiError("bad_value", "Неизвестные подстановки: " + ", ".join("{" + u + "}" for u in unknown)
                        + ". Доступные: " + (", ".join("{" + a + "}" for a in sorted(allowed)) or "нет"))
    if key == "text_delivery":
        miss = [x for x in ("login", "password") if "{" + x + "}" not in text]
        if miss:
            raise _ApiError("bad_value", "В тексте выдачи должны быть " + " и ".join("{" + x + "}" for x in miss) + ", иначе покупатель не получит данные аккаунта")
    SETTINGS["texts"][key] = text[:4000]
    save_settings()
    return {"ok": True}


def _api_text_reset(uid, body):
    key = str(body.get("key") or "")
    if key not in DEFAULT_TEXTS:
        raise _ApiError("bad_key", "Неизвестный текст")
    SETTINGS["texts"][key] = DEFAULT_TEXTS.get(key, "")
    save_settings()
    return {"ok": True, "value": SETTINGS["texts"][key]}


def _api_help(uid, body):
    conv = lambda t: _strip_ce(_ce_render(t))
    return {"main": conv(PLUGIN_HELP_TEXT), "pages": [conv(p) for p in AC_HELP_PAGES]}


# ---------- привязки ----------
def _map_row(i: int, m: dict) -> dict:
    return {"i": i, "lotId": m.get("lot_id"), "productId": m.get("product_id"),
            "name": str(m.get("product_name") or "?"), "hours": m.get("hours"),
            "sub": m.get("subcategory_id"), "auto": bool(m.get("auto_created")),
            "code": _mapping_rent_code(m), "hidden": str(m.get("lot_id")) in HIDDEN,
            "url": _lot_link(m.get("lot_id"))}


def _api_maps(uid, body):
    return {"maps": [_map_row(i, m) for i, m in enumerate(MAPPINGS)]}


def _api_map_hours(uid, body):
    idx = int(body.get("i", -1))
    if not 0 <= idx < len(MAPPINGS):
        raise _ApiError("not_found", "Привязка не найдена", 404)
    try:
        new_hours = max(MIN_RENT_HOURS, min(720, int(body.get("hours"))))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", f"Нужно число от {MIN_RENT_HOURS} до 720")
    pid = MAPPINGS[idx].get("product_id")
    new_key = _mapping_key(pid, new_hours)
    if any(j != idx and new_key is not None and _mapping_key(m.get("product_id"), m.get("hours")) == new_key
           for j, m in enumerate(MAPPINGS)):
        raise _ApiError("duplicate", "Такая пара товар + часы уже есть в привязках")
    MAPPINGS[idx]["hours"] = new_hours
    MAPPINGS[idx]["rent_code"] = _make_rent_code(pid, new_hours)
    save_mappings()
    return {"map": _map_row(idx, MAPPINGS[idx])}


def _api_map_delete(uid, body):
    idx = int(body.get("i", -1))
    if not 0 <= idx < len(MAPPINGS):
        raise _ApiError("not_found", "Привязка не найдена", 404)
    MAPPINGS.pop(idx)
    save_mappings()
    return {"ok": True}


def _api_map_lots(uid, body):
    """Лоты профиля FunPay, ещё не привязанные к товарам."""
    lots = []
    try:
        if cardinal_instance and cardinal_instance.profile:
            lots = cardinal_instance.profile.get_lots()
    except Exception as e:
        raise _ApiError("funpay", f"Не удалось получить лоты FunPay: {_short_err(e)}", 502)
    bound = {str(m.get("lot_id")) for m in MAPPINGS if m.get("lot_id")}
    return {"lots": [{"id": str(l.id), "title": (l.description or str(l.id))[:90]} for l in lots
                     if str(l.id) not in bound][:400]}


def _api_map_add(uid, body):
    lot_id = re.search(r"\d{4,}", str(body.get("lotId") or ""))
    if not lot_id:
        raise _ApiError("bad_value", "Не нашёл ID лота")
    lot_id = lot_id.group(0)
    try:
        pid = int(body.get("productId"))
        hours = max(MIN_RENT_HOURS, min(720, int(body.get("hours"))))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Укажите товар и часы")
    if any(str(m.get("lot_id")) == lot_id for m in MAPPINGS if m.get("lot_id")):
        raise _ApiError("duplicate", "Этот лот уже привязан")
    if _mapping_exists(pid, hours):
        raise _ApiError("duplicate", "Такая пара товар + часы уже привязана")
    rent_code = _make_rent_code(pid, hours)
    if _rent_code_exists(rent_code):
        raise _ApiError("duplicate", f"Код {rent_code} уже используется")
    name = _acx_names().get(pid) or ""
    MAPPINGS.append({"lot_id": lot_id, "subcategory_id": None, "title_keyword": "", "product_id": pid,
                     "product_name": name, "hours": hours, "rent_code": rent_code,
                     "currency": SETTINGS.get("currency", "RUB")})
    save_mappings()
    return {"map": _map_row(len(MAPPINGS) - 1, MAPPINGS[-1]), "code": rent_code}


def _api_products(uid, body):
    _need_api()
    q = _norm_game(str(body.get("q") or ""))
    items = sorted(_acx_names().items(), key=lambda kv: (kv[1] or "").lower())
    if q:
        items = [(i, n) for i, n in items if q in _norm_game(n)]
    return {"products": [{"id": i, "name": n} for i, n in items[:60]]}


# ---------- создание лотов ----------
def _api_ac_overview(uid, body):
    tpl_ok = _ac_has_template()
    out = {"hasTemplate": tpl_ok, "quotaLeft": max(0, CREATE_LIMIT_24H - _creations_24h()),
           "quotaTotal": CREATE_LIMIT_24H, "newGames": bool(SETTINGS.get("auto_new_games")),
           "debug": bool(SETTINGS.get("ac_debug")), "run": _run_state()}
    api = _get_api()
    if not api:
        out["apiError"] = "no_key"
        return out
    if _products(api) is None:
        out["apiError"] = "bad_key"
        return out
    blocked = _currency_blocked_text()
    if blocked:
        out["currencyBlocked"] = blocked
    diag: list = []
    sellable = _all_sellable_games(api, diag)
    ids = {int(x["product_id"]) for x in sellable}
    ex = _excluded_ids()
    out.update({"games": len(sellable), "included": len(ids - ex), "excluded": len(ids & ex),
                "created": sum(1 for m in MAPPINGS if m.get("lot_id")),
                "noRoute": len(_no_route_games(diag))})
    if tpl_ok:
        tpl = _ac_tpl()
        out.update({"missing": _ac_count_missing(), "durations": sorted(int(h) for h in tpl.get("durations") or []),
                    "priceLabel": _ac_price_label(tpl), "cleanup": len(_cleanup_mappings()),
                    "hiddenLots": len(_hidden_mappings())})
    return out


def _api_ac_preview(uid, body):
    _need_template()
    api = _need_api()
    if _products(api) is None:
        raise _ApiError("bad_key", "TDUgame API не отвечает или ключ неверный")
    page = max(0, int(body.get("page") or 0))
    per = 8
    if page == 0 or "items" not in (_ac_plan.get(uid) or {}):
        _build_plan(uid, missing_only=True)
    plan = (_ac_plan.get(uid) or {}).get("items") or []
    games: dict = {}
    for it in plan:
        games.setdefault((it["product_id"], it["game_name"], it["subcategory_id"]), []).append(it)
    keys = list(games.keys())
    tpl = _ac_tpl()
    prods_by_id = {int(p["id"]): p for p in (_products(api) or [])}
    out = []
    for pid, gname, _sub in keys[page * per:(page + 1) * per]:
        prod = prods_by_id.get(pid, {"id": pid})
        its = sorted(games[(pid, gname, _sub)], key=lambda x: x["hours"])
        rows = [{"hours": it["hours"], "label": _fmt_dur(it["hours"]),
                 "cost": round(float(_cost_rub(api, prod, it["hours"])), 2),
                 "price": round(float(_public_price_rub(api, prod, it["hours"], tpl)), 2)} for it in its]
        out.append({"id": pid, "name": gname, "misc": its[0].get("source") == "misc", "rows": rows})
    return {"total": len(plan), "games": len(keys), "page": page, "pages": max(1, -(-len(keys) // per)),
            "items": out, "priceLabel": _ac_price_label(tpl),
            "quotaLeft": max(0, CREATE_LIMIT_24H - _creations_24h())}


def _api_ac_go(uid, body):
    _need_template()
    _need_api()
    plan = (_ac_plan.get(uid) or {}).get("items") or []
    if not plan:
        plan = _build_plan(uid, missing_only=True) or []
    if not plan:
        raise _ApiError("nothing", "Недостающих лотов нет — создавать нечего")
    blocked = _currency_blocked_text()
    if blocked:
        raise _ApiError("blocked", blocked)
    if _creation_quota_left() <= 0:
        raise _ApiError("quota", f"Лимит FunPay: {CREATE_LIMIT_24H} лотов за 24 часа. "
                                 f"Снова можно создавать через {_fmt_eta(_quota_reset_in())}.")
    if not _ac_begin("Создание лотов…"):
        raise _ApiError("busy", "Уже идёт другой процесс")
    threading.Thread(target=_autocreate_run, args=(uid, None, None), daemon=True).start()
    return {"started": True, "count": len(plan)}


def _api_ac_durations(uid, body):
    tpl = _need_template()
    try:
        hrs = sorted({max(MIN_RENT_HOURS, min(720, int(h))) for h in (body.get("durations") or [])})
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Сроки — целые часы от 1 до 720")
    if not hrs:
        raise _ApiError("bad_value", "Нужен хотя бы один срок")
    tpl["durations"] = hrs[:12]
    save_settings()
    return {"durations": tpl["durations"], "cleanup": len(_cleanup_mappings())}


def _api_ac_template(uid, body):
    tpl = _need_template()
    d = _default_ac_template()
    return {"template": {k: tpl.get(k) for k in ("durations", "price_mode", "markup_percent", "fixed_prices",
                                                  "summary_ru", "summary_en", "desc_ru", "desc_en")},
            "defaults": {k: d.get(k) for k in ("summary_ru", "summary_en", "desc_ru", "desc_en")},
            "placeholders": {"game": PH_GAME, "time": PH_TIME},
            "minPrice": SETTINGS.get("ac_min_price", 2),
            "productMarkups": SETTINGS.get("product_markup_percent") or {}}


def _api_ac_template_set(uid, body):
    tpl = _need_template()
    changed_price = False
    if "markupPercent" in body:
        try:
            pct = round(float(str(body["markupPercent"]).replace(",", ".")), 2)
        except (TypeError, ValueError):
            raise _ApiError("bad_value", "Наценка — число")
        if not -90 <= pct <= 1000:
            raise _ApiError("bad_value", "Наценка от -90 до 1000 %")
        tpl["price_mode"] = "markup"
        tpl["markup_percent"] = pct
        changed_price = True
    for src, dst in (("summaryRu", "summary_ru"), ("summaryEn", "summary_en"),
                     ("descRu", "desc_ru"), ("descEn", "desc_en")):
        if src in body:
            val = str(body[src] or "").strip()
            if dst in ("summary_ru", "desc_ru") and not val:
                raise _ApiError("bad_value", "Русский текст не может быть пустым")
            tpl[dst] = val
            if not val and dst == "summary_en":
                tpl[dst] = _ascii(tpl.get("summary_ru", ""), keep_formatting=True)
            if not val and dst == "desc_en":
                tpl[dst] = _ascii(tpl.get("desc_ru", ""))
    if body.get("useDefault") in ("title", "desc"):
        d = _default_ac_template()
        if body["useDefault"] == "title":
            tpl["summary_ru"], tpl["summary_en"] = d["summary_ru"], d["summary_en"]
        else:
            tpl["desc_ru"], tpl["desc_en"] = d["desc_ru"], d["desc_en"]
    save_settings()
    if changed_price:
        _price_cache.clear()
    return {"ok": True, "repriceHint": changed_price}


def _api_ac_reprice(uid, body):
    tpl = _need_template()
    _need_api()
    blocked = _currency_blocked_text()
    if blocked:
        raise _ApiError("blocked", blocked)
    _need_idle()
    n = len([m for m in MAPPINGS if m.get("auto_created") and m.get("lot_id")
             and m.get("product_id") is not None and m.get("hours") is not None])
    if n == 0:
        raise _ApiError("nothing", "Нет авто-созданных лотов для пересчёта")
    if "markupPercent" in body and body["markupPercent"] not in (None, ""):
        _api_ac_template_set(uid, {"markupPercent": body["markupPercent"]})
    _price_cache.clear()
    if not _ac_begin("Пересчёт цен…"):
        raise _ApiError("busy", "Уже идёт другой процесс")
    threading.Thread(target=_reprice_thread_entry, args=(uid, None, None), daemon=True).start()
    return {"started": True, "count": n}


def _api_ac_game_markup(uid, body):
    _need_template()
    _need_api()
    try:
        pid = str(int(body.get("productId")))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Нужен числовой ID товара")
    overrides = SETTINGS.setdefault("product_markup_percent", {})
    raw = body.get("percent")
    if raw in (None, "", "default"):
        overrides.pop(pid, None)
    else:
        try:
            pct = round(float(str(raw).replace(",", ".")), 2)
        except (TypeError, ValueError):
            raise _ApiError("bad_value", "Процент — число")
        if not -90 <= pct <= 1000:
            raise _ApiError("bad_value", "Процент от -90 до 1000")
        overrides[pid] = pct
    save_settings()
    _price_cache.clear()
    targets = [m for m in MAPPINGS if m.get("auto_created") and str(m.get("product_id")) == pid
               and m.get("lot_id") and m.get("hours") is not None]
    started = False
    if targets and not _ac_running.get("on") and _ac_begin("Пересчёт цен игры"):
        threading.Thread(target=_reprice_thread_entry, args=(uid, None, None, int(pid)), daemon=True).start()
        started = True
    return {"ok": True, "lots": len(targets), "started": started, "markups": overrides}


def _no_route_games(diag: list) -> list:
    """Игры каталога, для которых плагин не знает раздел на FunPay (лоты по ним создать нельзя)."""
    seen, out = set(), []
    for d in diag:
        if "нет маршрута" not in str(d.get("reason", "")):
            continue
        try:
            pid = int(d.get("product_id"))
        except (TypeError, ValueError):
            continue
        if pid in seen or _autolot_block_reason({"product_id": pid, "name": d.get("name")}):
            continue
        seen.add(pid)
        out.append({"id": pid, "name": str(d.get("name") or pid)})
    out.sort(key=lambda g: g["name"].lower())
    return out


def _api_ac_noroute(uid, body):
    _need_api()
    diag: list = []
    _all_sellable_games(_get_api(), diag)
    return {"games": _no_route_games(diag)}


def _plan_reasons(pid: int, durations, diag: list) -> "tuple[list, bool, str]":
    """Почему для игры нет лотов к созданию: (список причин, нужна ли ссылка на раздел FunPay, название игры)."""
    api = _get_api()
    prods = {int(p["id"]): p for p in (_products(api) or []) if re.fullmatch(r"-?\d+", str(p.get("id", "")))}
    prod = prods.get(pid)
    name = (prod or {}).get("name") or str(pid)
    reasons, need_route = [], False
    if not prod:
        reasons.append("Игра не найдена в каталоге TDUgame. Возможно, её сняли с продажи.")
        return reasons, need_route, name
    if _autolot_block_reason(prod):
        reasons.append("Игра в стоп-листе автосоздания: лоты по ней плагин не создаёт.")
    no_route = any(str(d.get("product_id")) == str(pid) and "нет маршрута" in str(d.get("reason", "")) for d in diag)
    if no_route:
        need_route = True
        reasons.append("Для игры не указан раздел на FunPay (плагин не знает, куда выставлять лоты). "
                       "Вставьте ссылку на раздел «Аккаунты …» этой игры на FunPay и нажмите «Привязать раздел».")
    if _is_excluded(pid):
        reasons.append("Игра в списке исключений. Для одной игры это не мешает, но при общем создании она будет пропущена.")
    mn, mx = int(prod.get("min_hours") or 1), int(prod.get("max_hours") or 720)
    if durations is not None:
        ok = [h for h in durations if mn <= h <= mx]
        bad = [h for h in durations if h not in ok]
        if bad:
            reasons.append(f"Сроки {', '.join(str(h) for h in bad)} ч вне допустимого диапазона игры ({mn}–{mx} ч), они пропущены.")
        if not ok:
            reasons.append(f"Ни один из указанных сроков не подходит: для этой игры можно от {mn} до {mx} ч.")
    return reasons, need_route, name


def _api_ac_game_plan(uid, body):
    _need_template()
    _need_api()
    try:
        pid = int(body.get("productId"))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Нужен числовой ID товара")
    durations = None
    if body.get("durations"):
        try:
            durations = sorted({int(h) for h in body["durations"]})
        except (TypeError, ValueError):
            raise _ApiError("bad_value", "Часы — целые числа")
        if any(h < 1 or h > 720 for h in durations):
            raise _ApiError("bad_value", "Часы от 1 до 720")
    diag: list = []
    plan = _build_plan_items(missing_only=True, bind_existing=False, diagnostics=diag, product_id=pid,
                             durations_override=durations)
    _ac_plan[uid] = {"items": plan, "product_id": pid, "durations": durations, "ts": time.time()}
    count = len(plan or [])
    out = {"count": count, "productId": pid, "hours": sorted({int(i["hours"]) for i in (plan or [])}),
           "reasons": [], "needRoute": False}
    if count == 0:
        reasons, need_route, name = _plan_reasons(pid, durations, diag)
        have = sorted({int(m.get("hours") or 0) for m in MAPPINGS if str(m.get("product_id")) == str(pid) and m.get("lot_id")})
        if not reasons and have:
            reasons.append("Для этой игры уже созданы лоты на все нужные сроки: " + ", ".join(f"{h} ч" for h in have) + ".")
        if not reasons:
            reasons.append("Плагин не нашёл подходящих сроков или раздела для этой игры. Откройте «Создание лотов → Диагностика».")
        out.update({"reasons": reasons, "needRoute": need_route, "name": name})
    return out


def _parse_funpay_section(url: str) -> dict:
    """По ссылке вида https://funpay.com/lots/895/ определяет раздел «Аккаунты …» игры: id раздела, id игры, название."""
    m = re.search(r"funpay\.com/lots/(\d+)", str(url or ""))
    if not m:
        raise _ApiError("bad_value", "Нужна ссылка вида https://funpay.com/lots/895/")
    sub = int(m.group(1))
    try:
        r = requests.get(f"https://funpay.com/lots/{sub}/", headers={"User-Agent": "Mozilla/5.0"}, timeout=20)
    except Exception as e:
        raise _ApiError("net", f"Не удалось открыть страницу FunPay: {_short_err(e)}")
    if r.status_code != 200:
        raise _ApiError("bad_value", f"FunPay вернул код {r.status_code}: проверьте ссылку")
    html = r.text
    h1 = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.S)
    title = re.sub(r"<[^>]+>", "", h1.group(1)).strip() if h1 else ""
    if "аккаунт" not in title.lower():
        raise _ApiError("bad_value", f"Это раздел «{title or '?'}», а не «Аккаунты …». Откройте раздел с аккаунтами игры.")
    cat = re.search(r'data-game="(\d+)"', html)
    if not cat:
        raise _ApiError("bad_value", "Не удалось определить игру на странице FunPay")
    return {"subcategory_id": sub, "category_id": int(cat.group(1)), "title": title}


def _api_ac_route_set(uid, body):
    _need_api()
    try:
        pid = int(body.get("productId"))
    except (TypeError, ValueError):
        raise _ApiError("bad_value", "Нужен числовой ID товара")
    info = _parse_funpay_section(body.get("url"))
    api = _get_api()
    prod = next((p for p in (_products(api) or []) if str(p.get("id")) == str(pid)), None)
    if not prod:
        raise _ApiError("not_found", "Игра не найдена в каталоге TDUgame")
    route = {"category_id": info["category_id"], "subcategory_id": info["subcategory_id"], "fields": {"fields[type]": "Аренда"}}
    _remember_map_override(pid, prod.get("name") or str(pid), route)
    _map_cache["ts"] = 0
    _fetch_map(force=True)
    _evt("system", "info", f"Игра привязана к разделу FunPay: {prod.get('name')}",
         detail=f"{info['title']} (раздел {info['subcategory_id']})", advice="")
    return {"ok": True, "title": info["title"], "subcategoryId": info["subcategory_id"], "name": prod.get("name")}


def _api_ac_excl(uid, body):
    _need_api()
    ex = _excluded_ids()
    names = _acx_names()
    games = [{"id": i, "name": n or f"ID {i}", "excluded": i in ex} for i, n in names.items()]
    games += [{"id": i, "name": f"ID {i}", "excluded": True} for i in sorted(ex - set(names))]
    games.sort(key=lambda g: g["name"].lower())
    return {"games": games, "excluded": len(ex)}


def _api_ac_excl_set(uid, body):
    ids = set()
    for x in body.get("ids") or []:
        try:
            ids.add(int(x))
        except (TypeError, ValueError):
            continue
    cur = _excluded_ids()
    cur = (cur | ids) if body.get("excluded") else (cur - ids)
    SETTINGS["ac_excluded_ids"] = sorted(cur)
    save_settings()
    return {"excluded": len(cur)}


def _api_ac_excl_all(uid, body):
    _need_api()
    if body.get("excluded"):
        SETTINGS["ac_excluded_ids"] = sorted(set(_acx_names().keys()) | _excluded_ids())
    else:
        SETTINGS["ac_excluded_ids"] = []
    save_settings()
    return {"excluded": len(SETTINGS["ac_excluded_ids"])}


def _api_ac_flag(uid, body):
    key = str(body.get("key") or "")
    if key == "newGames":
        on = not SETTINGS.get("auto_new_games", False)
        SETTINGS["auto_new_games"] = on
        if on and not isinstance(SETTINGS.get("ac_known_ids"), list):
            ids = []
            for p in (_products(_get_api()) or []):
                try:
                    ids.append(int(p["id"]))
                except (TypeError, ValueError, KeyError):
                    continue
            if ids:
                SETTINGS["ac_known_ids"] = sorted(ids)
        save_settings()
        return {"key": key, "value": on}
    if key == "debug":
        SETTINGS["ac_debug"] = not SETTINGS.get("ac_debug")
        save_settings()
        return {"key": key, "value": bool(SETTINGS["ac_debug"])}
    raise _ApiError("bad_key", "Неизвестный переключатель")


def _api_ac_refresh(uid, body):
    _fetch_map(force=True)
    _misc_cache.clear()
    _products_cache.update({"data": None, "ts": 0, "err": False})
    _pub_lots_cache.clear()
    _commission_cache.clear()
    _node_form_cache.clear()
    _price_cache.clear()
    _QUOTE_CACHE.clear()
    with _PRICE_TABLE_LOCK:
        _PRICE_TABLE.update({"ts": 0.0, "fail_ts": 0.0})
    _map_cache["warned"] = False
    _products(_get_api(), force=True)
    return {"ok": True}


def _api_ac_diag(uid, body):
    return {"html": _strip_ce(_ac_diagnostics_text())}


def _api_cleanup_info(uid, body):
    extra_d = _extra_duration_mappings()
    removed = _removed_game_mappings()
    by_h = Counter(int(m.get("hours")) for m in extra_d)
    names = Counter(str(m.get("product_name") or f"#{m.get('product_id')}") for m in removed)
    return {"total": len(_cleanup_mappings()),
            "durations": [{"hours": h, "label": _fmt_dur(h), "count": c} for h, c in sorted(by_h.items())],
            "wanted": [_fmt_dur(int(h)) for h in sorted((_ac_tpl().get("durations") or []))],
            "removedGames": [{"name": n, "count": c} for n, c in names.most_common(30)]}


def _start_delete(uid, only_extra=False, only_hidden=False, title="Удаление лотов…"):
    _need_idle()
    if not _ac_begin(title):
        raise _ApiError("busy", "Уже идёт другой процесс")
    threading.Thread(target=_delete_plugin_run, args=(uid, None, None, only_extra, only_hidden), daemon=True).start()


def _api_cleanup_go(uid, body):
    n = len(_cleanup_mappings())
    if not n:
        raise _ApiError("nothing", "Лишних лотов нет")
    _start_delete(uid, only_extra=True, title="Удаление лишних лотов…")
    return {"started": True, "count": n}


def _api_hidden_go(uid, body):
    n = len(_hidden_mappings())
    if not n:
        raise _ApiError("nothing", "Скрытых лотов нет")
    _start_delete(uid, only_hidden=True, title="Удаление скрытых лотов…")
    return {"started": True, "count": n}


def _api_delall_info(uid, body):
    return {"linked": len([m for m in MAPPINGS if m.get("lot_id")]), "mappings": len(MAPPINGS)}


def _api_delall_lots(uid, body):
    n = len([m for m in MAPPINGS if m.get("lot_id")])
    if not n:
        raise _ApiError("nothing", "Нет привязанных лотов")
    _start_delete(uid, title="Удаление лотов…")
    return {"started": True, "count": n}


def _api_delall_links(uid, body):
    _need_idle()
    n = len(MAPPINGS)
    MAPPINGS.clear()
    save_mappings()
    if HIDDEN:
        HIDDEN.clear()
        save_hidden()
    return {"ok": True, "count": n}



# ============================================================================
#  АВТООБНОВЛЕНИЕ ПЛАГИНА
#  Новая версия лежит на серверах TDUgame и отдаётся только по действующему API-ключу.
#  Плагин сам проверяет её, скачивает, проверяет целостность и синтаксис, делает копию текущего файла
#  и заменяет себя. Применяется после перезапуска Cardinal (кнопка в мини-приложении).
# ============================================================================
SELF_PATH = os.path.abspath(__file__)
UPDATE_CHECK_SEC = 6 * 3600
UPDATE_KEEP_BACKUPS = 3
_update_state: dict = {"latest": "", "notes": [], "released": "", "checked": 0.0, "err": "", "downloaded": "",
                       "pending_restart": False, "busy": False, "notified": ""}
_update_stop = threading.Event()
_update_thread: "threading.Thread | None" = None


def _ver_tuple(v) -> tuple:
    return tuple(int(x) for x in re.findall(r"\d+", str(v or ""))) or (0,)


def _update_auto() -> bool:
    return bool(SETTINGS.get("auto_update", True))


def _update_fetch(path: str, timeout: int = 30):
    key = str(SETTINGS.get("api_key") or "")
    if not key:
        raise RuntimeError("не указан API-ключ TDUgame")
    r = requests.post(f"{_relay_base()}/relay/update/{path}", headers={"X-API-Key": key}, timeout=timeout)
    if r.status_code == 401:
        raise RuntimeError("API-ключ TDUgame не принят")
    if r.status_code == 404:
        raise RuntimeError("обновлений на сервере пока нет")
    if r.status_code != 200:
        raise RuntimeError(f"сервер обновлений ответил {r.status_code}")
    return r


def _update_backups() -> list:
    folder, base = os.path.split(SELF_PATH)
    items = []
    for name in os.listdir(folder):
        if name.startswith(base + ".bak-"):
            full = os.path.join(folder, name)
            items.append((os.path.getmtime(full), full))
    return [p for _, p in sorted(items, reverse=True)]


def _update_download(manifest: dict) -> str:
    """Скачивает файл новой версии, проверяет и заменяет им текущий. Возвращает установленную версию."""
    import hashlib
    ver = str(manifest.get("version") or "")
    r = _update_fetch("file", timeout=90)
    data = r.content
    want = str(manifest.get("sha256") or "").lower()
    if not want or hashlib.sha256(data).hexdigest() != want:
        raise RuntimeError("файл обновления повреждён (контрольная сумма не совпала)")
    if len(data) < 100_000 or len(data) > 6_000_000:
        raise RuntimeError("файл обновления странного размера")
    src = data.decode("utf-8")
    try:
        compile(src, "TDUgameResellPlugin.py", "exec")
    except SyntaxError as e:
        raise RuntimeError(f"в файле обновления ошибка синтаксиса: {e}")
    m = re.search(r'^VERSION\s*=\s*"([^"]+)"', src, re.M)
    if not m or m.group(1) != ver:
        raise RuntimeError("версия в файле не совпадает с заявленной")
    for marker in ("BIND_TO_PRE_INIT", "UUID = ", "NAME = "):
        if marker not in src:
            raise RuntimeError("файл обновления не похож на плагин")
    uuid_new = re.search(r'^UUID\s*=\s*"([^"]+)"', src, re.M)
    if not uuid_new or uuid_new.group(1) != UUID:
        raise RuntimeError("файл обновления от другого плагина")
    bak = f"{SELF_PATH}.bak-{VERSION}"
    try:
        shutil.copy2(SELF_PATH, bak)
    except Exception as e:
        raise RuntimeError(f"не удалось сделать копию текущего плагина: {e}")
    tmp = SELF_PATH + ".new"
    with open(tmp, "wb") as f:
        f.write(data)
    try:
        shutil.copymode(SELF_PATH, tmp)
    except Exception:
        pass
    os.replace(tmp, SELF_PATH)
    for old in _update_backups()[UPDATE_KEEP_BACKUPS:]:
        try:
            os.remove(old)
        except OSError:
            pass
    return ver


def check_update(apply: "bool | None" = None) -> dict:
    """apply=None — по настройке «автообновление»; True — скачать и установить в любом случае."""
    if _update_state["busy"]:
        return dict(_update_state)
    _update_state["busy"] = True
    try:
        r = _update_fetch("manifest", timeout=20)
        mf = r.json()
        latest = str(mf.get("version") or "")
        _update_state.update(latest=latest, notes=[str(x) for x in (mf.get("notes") or [])][:12],
                             released=str(mf.get("released") or ""), checked=time.time(), err="")
        if _ver_tuple(latest) > _ver_tuple(VERSION) and _ver_tuple(latest) > _ver_tuple(_update_state["downloaded"]):
            if apply is True or (apply is None and _update_auto()):
                installed = _update_download(mf)
                _update_state.update(downloaded=installed, pending_restart=True)
                logger.info(f"{LP} [UPD] обновление {VERSION} → {installed} скачано, применится после перезапуска Cardinal")
                _evt("system", "ok", f"Плагин обновлён до {installed}",
                     detail="Новая версия записана в файл плагина и заработает после перезапуска Cardinal. Что нового: "
                            + "; ".join(_update_state["notes"][:4]), advice="")
                if _update_state["notified"] != installed:
                    _update_state["notified"] = installed
                    notify_admins_info(f"♻️ Плагин обновлён до <b>{_html.escape(installed)}</b> (был {_html.escape(VERSION)}). "
                                       f"Чтобы заработала новая версия, перезапустите Cardinal: «Мини-приложение → Настройки → Обновление плагина».")
    except Exception as e:
        _update_state.update(err=_short_err(e), checked=time.time())
        logger.warning(f"{LP} [UPD] {_short_err(e)}")
    finally:
        _update_state["busy"] = False
    return dict(_update_state)


def _update_rollback() -> str:
    backs = _update_backups()
    if not backs:
        raise _ApiError("no_backup", "Копий прошлых версий нет")
    src = backs[0]
    with open(src, "rb") as f:
        data = f.read()
    compile(data.decode("utf-8"), "TDUgameResellPlugin.py", "exec")
    tmp = SELF_PATH + ".new"
    with open(tmp, "wb") as f:
        f.write(data)
    os.replace(tmp, SELF_PATH)
    ver = src.rsplit(".bak-", 1)[-1]
    _update_state.update(pending_restart=True, downloaded=ver)
    return ver


def _update_loop() -> None:
    _update_stop.wait(45)
    while not _update_stop.is_set():
        try:
            if SETTINGS.get("api_key"):
                check_update()
        except Exception as e:
            logger.debug(f"{LP} [UPD] loop: {e}")
        _update_stop.wait(UPDATE_CHECK_SEC)


def _update_start() -> None:
    global _update_thread
    if _update_thread is not None and _update_thread.is_alive():
        return
    _update_stop.clear()
    _update_thread = threading.Thread(target=_update_loop, name="tdugame-update", daemon=True)
    _update_thread.start()


def _update_stop_all(*args, **kwargs) -> None:
    _update_stop.set()


def _card_text() -> str:
    """Красивый текст карточки плагина в Cardinal («Плагины → TDUgameResell»); без сетевых запросов."""
    e = _html.escape
    on = bool(SETTINGS.get("enabled"))
    has_key = bool(SETTINGS.get("api_key"))
    cnt = _events_counts()
    online = bool(_relay_state.get("online")) if _webapp_mode() == "relay" else None
    upd = _update_state
    new_ver = upd.get("latest") if _ver_tuple(upd.get("latest")) > _ver_tuple(VERSION) else ""
    lines = [
        f"<b>💜 TDUgameResell</b>  <code>v{e(VERSION)}</code>",
        "<i>Аренда Steam-аккаунтов с TDUgame на FunPay</i>",
        "",
        "<blockquote>🚀 Сам создаёт лоты на FunPay\n"
        "🔑 Выдаёт аккаунты сразу после оплаты\n"
        "🙈 Скрывает лоты, когда нет аккаунтов\n"
        "🧾 Ведёт журнал ошибок с советами\n"
        "♻️ Обновляется сам</blockquote>",
        "",
        f"{'🟢' if on else '🔴'} <b>{'Автовыдача включена' if on else 'Автовыдача выключена'}</b>",
        f"{'✅' if has_key else '⚠️'} API-ключ: <b>{'указан' if has_key else 'не указан'}</b>",
        f"📦 Лотов в работе: <b>{len(MAPPINGS)}</b>  ·  🙈 скрыто: <b>{len(HIDDEN)}</b>",
    ]
    if cnt.get("errors24h"):
        lines.append(f"⛔ Ошибок за сутки: <b>{cnt['errors24h']}</b>  (журнал в мини-приложении)")
    if online is not None:
        lines.append(f"{'📱' if online else '📴'} Мини-приложение: <b>{'на связи' if online else 'нет связи'}</b>")
    if upd.get("pending_restart"):
        lines.append(f"♻️ Обновление скачано, <b>перезапустите Cardinal</b>")
    elif new_ver:
        lines.append(f"♻️ Доступна версия <b>{e(str(new_ver))}</b>")
    lines += [
        "",
        "<b>Автор:</b> TDUgame  ·  <a href=\"https://tdugame.ru/plugin.html\">tdugame.ru/plugin.html</a>",
        f"<code>{e(UUID)}</code>",
    ]
    return "\n".join(lines)


def _patch_plugin_card() -> None:
    """В карточке плагина в Cardinal («Плагины → TDUgameResell») первой кнопкой добавляем «Мини-приложение»."""
    try:
        from tg_bot import keyboards as _kbmod
        orig = getattr(_kbmod, "edit_plugin", None)
        if orig is None or getattr(orig, "_tdu_patched", False):
            return

        def edit_plugin(c, uuid, offset, *args, **kwargs):
            kbd = orig(c, uuid, offset, *args, **kwargs)
            try:
                if uuid != UUID:
                    return kbd
                flat = [b for row in kbd.keyboard for b in row]

                def find(cbt):
                    return next((b for b in flat if str(b.callback_data or "").startswith(f"{cbt}:")), None)

                tog, pin = find(CBT.TOGGLE_PLUGIN), find(CBT.PIN_PLUGIN)
                sets, dele = find(CBT.PLUGIN_SETTINGS), find(CBT.DELETE_PLUGIN)
                yes, no = find(CBT.CONFIRM_DELETE_PLUGIN), find(CBT.CANCEL_DELETE_PLUGIN)
                back = find(CBT.PLUGINS_LIST)
                if not (tog and pin and back):
                    return kbd
                new = K()
                url = _webapp_open_url()
                if url.startswith("https://"):
                    new.row(B("📱 Открыть мини-приложение", web_app=WebAppInfo(url=url), style="primary"))
                is_on = "активир" in tog.text.lower() and not tog.text.lower().startswith("актив")
                new.row(B("🟢 Плагин включён · выключить" if is_on else "🔴 Плагин выключен · включить",
                          callback_data=tog.callback_data, style="success" if is_on else "danger"))
                row = [B("📍 Открепить" if "откреп" in pin.text.lower() else "📌 Закрепить", callback_data=pin.callback_data)]
                if sets:
                    row.append(B("⚙️ Настройки", callback_data=sets.callback_data))
                new.row(*row)
                if yes and no:
                    new.row(B("✅ Да, удалить", callback_data=yes.callback_data, style="danger"),
                            B("↩️ Отмена", callback_data=no.callback_data, style="success"))
                else:
                    if dele:
                        new.row(B("🗑 Удалить плагин", callback_data=dele.callback_data, style="danger"))
                    new.row(B("◀️ Назад", callback_data=back.callback_data))
                return new
            except Exception as e:
                logger.debug(f"{LP} plugin card patch: {e}")
            return kbd

        edit_plugin._tdu_patched = True
        _kbmod.edit_plugin = edit_plugin
        # текст карточки собирает сам Cardinal без украшений; подменяем его на наш для этого плагина
        if bot is not None and not getattr(bot.edit_message_text, "_tdu_patched", False):
            orig_edit = bot.edit_message_text

            def edit_message_text(text, *args, **kwargs):
                try:
                    if isinstance(text, str) and f"<code>{UUID}</code>" in text and "UUID" in text:
                        text = _card_text()
                        kwargs["parse_mode"] = "HTML"
                        kwargs.setdefault("disable_web_page_preview", True)
                except Exception as e:
                    logger.debug(f"{LP} card text: {e}")
                return orig_edit(text, *args, **kwargs)

            edit_message_text._tdu_patched = True
            bot.edit_message_text = edit_message_text
    except Exception as e:
        logger.debug(f"{LP} plugin card patch unavailable: {e}")


def _notify_started() -> None:
    """После перезапуска Cardinal сообщаем, какая версия плагина запущена (особенно если она сменилась)."""
    try:
        prev = str(SETTINGS.get("last_run_version") or "")
        if prev == VERSION:
            return
        SETTINGS["last_run_version"] = VERSION
        save_settings()
        was = f" (до этого {_html.escape(prev)})" if prev else ""
        notify_admins_info(f"✅ Cardinal перезапущен, плагин работает в версии <b>{_html.escape(VERSION)}</b>{was}.")
        _evt("system", "ok", f"Плагин запущен в версии {VERSION}", detail=(f"До перезапуска была версия {prev}." if prev else ""), advice="")
    except Exception as e:
        logger.debug(f"{LP} started-notify: {e}")


def _restart_cardinal() -> None:
    from Utils.cardinal_tools import restart_program
    restart_program()


def _api_update_status(uid, body):
    st = _update_state
    return {"version": VERSION, "latest": st["latest"], "notes": st["notes"], "released": st["released"],
            "available": _ver_tuple(st["latest"]) > _ver_tuple(VERSION), "downloaded": st["downloaded"],
            "pending": bool(st["pending_restart"]), "auto": _update_auto(), "err": st["err"], "checked": st["checked"],
            "busy": bool(st["busy"]), "hasBackup": bool(_update_backups())}


def _api_update_check(uid, body):
    check_update()
    return _api_update_status(uid, body)


def _api_update_apply(uid, body):
    check_update(apply=True)
    if _update_state["err"]:
        raise _ApiError("update_failed", "Не получилось обновиться: " + _update_state["err"])
    return _api_update_status(uid, body)


def _api_update_auto(uid, body):
    SETTINGS["auto_update"] = bool(body.get("value"))
    save_settings()
    return _api_update_status(uid, body)


def _api_update_rollback(uid, body):
    ver = _update_rollback()
    return {"ok": True, "version": ver}


def _api_update_restart(uid, body):
    if _ac_running.get("on"):
        raise _ApiError("busy", "Сейчас идёт создание или удаление лотов. Дождитесь окончания, потом перезапускайте.")
    threading.Timer(1.5, _restart_cardinal).start()
    return {"ok": True}



_WEB_API = {
    "status": _api_status, "run": _api_run, "stop": _api_stop, "set_enabled": _api_set_enabled,
    "settings": _api_settings, "toggle": _api_toggle, "param_set": _api_param_set,
    "apikey_set": _api_apikey_set, "stats": _api_stats,
    "events": _api_events, "events_clear": _api_events_clear,
    "update_status": _api_update_status, "update_check": _api_update_check, "update_apply": _api_update_apply,
    "update_auto": _api_update_auto, "update_rollback": _api_update_rollback, "update_restart": _api_update_restart, "ac_route_set": _api_ac_route_set, "ac_noroute": _api_ac_noroute,
    "texts": _api_texts, "text_set": _api_text_set, "text_reset": _api_text_reset, "help": _api_help,
    "maps": _api_maps, "map_hours": _api_map_hours, "map_delete": _api_map_delete, "map_lots": _api_map_lots,
    "map_add": _api_map_add, "products": _api_products,
    "ac_overview": _api_ac_overview, "ac_preview": _api_ac_preview, "ac_go": _api_ac_go,
    "ac_durations": _api_ac_durations, "ac_template": _api_ac_template, "ac_template_set": _api_ac_template_set,
    "ac_reprice": _api_ac_reprice, "ac_game_markup": _api_ac_game_markup, "ac_game_plan": _api_ac_game_plan,
    "ac_excl": _api_ac_excl, "ac_excl_set": _api_ac_excl_set, "ac_excl_all": _api_ac_excl_all,
    "ac_flag": _api_ac_flag, "ac_refresh": _api_ac_refresh, "ac_diag": _api_ac_diag,
    "cleanup_info": _api_cleanup_info, "cleanup_go": _api_cleanup_go, "hidden_go": _api_hidden_go,
    "delall_info": _api_delall_info, "delall_lots": _api_delall_lots, "delall_links": _api_delall_links,
}


class _WebHandler(BaseHTTPRequestHandler):
    server_version = "TDUgameResell"
    protocol_version = "HTTP/1.1"

    def log_message(self, fmt, *args):  # без шума в логе Cardinal
        pass

    def _send(self, status: int, body: bytes, ctype: str, extra: "dict | None" = None):
        self.send_response(status)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("X-Content-Type-Options", "nosniff")
        self.send_header("Referrer-Policy", "no-referrer")
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _json(self, status: int, payload: dict):
        self._send(status, json.dumps(payload, ensure_ascii=False).encode(), "application/json; charset=utf-8",
                   {"Cache-Control": "no-store"})

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        path = urllib.parse.urlsplit(self.path).path
        rel = path.lstrip("/") or "index.html"
        root = os.path.realpath(WEBAPP_DIR)
        full = os.path.realpath(os.path.join(root, rel))
        if not (full == root or full.startswith(root + os.sep)) or not os.path.isfile(full):
            full = os.path.join(root, "index.html")        # любой неизвестный путь — приложение
            if not os.path.isfile(full):
                return self._send(404, b"not found", "text/plain; charset=utf-8")
        ctype = mimetypes.guess_type(full)[0] or "application/octet-stream"
        if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
            ctype += "; charset=utf-8"
        is_index = os.path.basename(full) == "index.html"
        with open(full, "rb") as f:
            data = f.read()
        self._send(200, data, ctype, {
            "Cache-Control": "no-store" if is_index else "public, max-age=31536000, immutable",
            "Content-Security-Policy": "frame-ancestors 'self' https://*.telegram.org https://web.telegram.org",
        })

    def do_POST(self):
        path = urllib.parse.urlsplit(self.path).path
        name = path.rsplit("/", 1)[-1]
        if not path.startswith("/api/") or name not in _WEB_API:
            self.close_connection = True
            return self._json(404, {"error": "not_found"})
        # тело запроса читаем до любого ответа: иначе при keep-alive его остаток
        # будет принят за начало следующего запроса
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            length = 0
        if length > WEBAPP_MAX_BODY:
            self.close_connection = True
            return self._json(413, {"error": "too_large"})
        raw = self.rfile.read(length) if length > 0 else b""
        status, payload = _web_dispatch(name, self.headers.get("X-Init-Data", ""), raw)
        self._json(status, payload)


def _web_dispatch(name: str, init_data: str, raw: bytes) -> "tuple[int, dict]":
    """Общая точка входа для запросов мини-приложения: локальный сервер и ретранслятор."""
    if name not in _WEB_API:
        return 404, {"error": "not_found"}
    user = _webapp_user(init_data)
    if not user:
        return 401, {"error": "unauthorized", "message": "Откройте мини-приложение из меню бота"}
    try:
        body = json.loads(raw or b"{}")
        if not isinstance(body, dict):
            body = {}
    except Exception:
        return 400, {"error": "bad_json"}
    try:
        return 200, _WEB_API[name](int(user["id"]), body)
    except _ApiError as e:
        return e.status, {"error": e.code, "message": e.message}
    except Exception as e:
        logger.error(f"{LP} [WEB] {name}: {e}", exc_info=True)
        return 500, {"error": "internal", "message": _short_err(e)}


# ---------- режим «через TDUgame»: плагин сам подключается к ретранслятору ----------
RELAY_BASE_DEFAULT = "https://app.tdugame.ru/p"
_relay_state: dict = {"thread": None, "stop": threading.Event(), "online": False, "err": "", "ts": 0.0}


def _webapp_mode() -> str:
    """По умолчанию мини-приложение всегда работает через TDUgame. Свой адрес (webapp_url + webapp_mode=own)
    и отключение (webapp_mode=off) — только правкой settings.json, в меню этого нет."""
    mode = str(SETTINGS.get("webapp_mode") or "")
    if mode == "off":
        return ""
    if mode == "own" or (mode != "relay" and str(SETTINGS.get("webapp_url") or "").startswith("https://")):
        return "own"
    return "relay"


def _relay_base() -> str:
    return str(SETTINGS.get("relay_base") or RELAY_BASE_DEFAULT).rstrip("/")


def _relay_cid(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()[:24]


def _webapp_open_url() -> str:
    """Адрес, который открывает Telegram-кнопка."""
    mode = _webapp_mode()
    if mode == "relay":
        key = str(SETTINGS.get("api_key") or "")
        return f"{_relay_base()}/?c={_relay_cid(key)}" if key else ""
    if mode == "own" and _webapp_srv.get("server") is not None:
        return str(SETTINGS.get("webapp_url") or "")
    return ""


def _relay_handle(key: str, job: dict) -> None:
    try:
        status, payload = _web_dispatch(str(job.get("name") or ""), str(job.get("initData") or ""),
                                        str(job.get("body") or "").encode())
    except Exception as e:
        status, payload = 500, {"error": "internal", "message": _short_err(e)}
    try:
        requests.post(f"{_relay_base()}/relay/respond", headers={"X-API-Key": key},
                      json={"id": job.get("id"), "status": status, "body": payload}, timeout=30)
    except Exception as e:
        logger.debug(f"{LP} [WEB] relay respond failed: {_short_err(e)}")


def _relay_loop() -> None:
    stop = _relay_state["stop"]
    wait = 2.0
    last_log = 0.0
    while not stop.is_set():
        key = str(SETTINGS.get("api_key") or "")
        if _webapp_mode() != "relay" or not key:
            _relay_state.update(online=False, err="")
            stop.wait(3)
            continue
        try:
            r = requests.post(f"{_relay_base()}/relay/poll", headers={"X-API-Key": key}, timeout=40)
            if r.status_code == 200:
                _relay_state.update(online=True, err="", ts=time.time())
                wait = 2.0
                threading.Thread(target=_relay_handle, args=(key, r.json()), daemon=True).start()
                continue
            if r.status_code == 204:
                _relay_state.update(online=True, err="", ts=time.time())
                wait = 2.0
                continue
            err = "ключ TDUgame не принят" if r.status_code == 401 else f"сервер ответил {r.status_code}"
        except Exception as e:
            err = _short_err(e)
        _relay_state.update(online=False, err=err)
        if time.time() - last_log > 120:
            logger.warning(f"{LP} [WEB] нет связи с ретранслятором: {err}")
            last_log = time.time()
        stop.wait(wait)
        wait = min(wait * 2, 30.0)


def _relay_start() -> None:
    th = _relay_state.get("thread")
    if th is not None and th.is_alive():
        return
    _relay_state["stop"].clear()
    th = threading.Thread(target=_relay_loop, name="tdugame-relay", daemon=True)
    th.start()
    _relay_state["thread"] = th


def _relay_stop() -> None:
    _relay_state["stop"].set()
    _relay_state.update(online=False)


WEBAPP_DEFAULT_PORT = 8765
_WEBAPP_EMBED: dict = {
    'assets/index-B7AL2dig.css': ('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'
        'xckrEyor01Gz78T0zBo5DlAJf9Zpoo+v1rLXeFpNF/Ykrksp2SylXcOJgMwqMMsfjIIIXWXvz/pUPlk9ousjPeBXDRyQbsg4gXS5'
        't7aVy138kskAbvZWu/hh1TAU3baoz1+dhNA6CSQH6PT/BDeIYjdYLt1FrKNFQ7gBj8mMxDc/+ypuQCfBChvQO09HDbI10pXPSHrW'
        'MIiQ41VKA9FQozKGxV2FTNL2LcFY475bzZbq7JqapL141Rn3SczdyZMPBOSYOiHhVx2OfV+3vc3/dQH2wriuhi2wxr4WOejnM6Vp'
        'Ldrfdj7gvxoois9fMsy+Z4apofe2LCGDVnScFDpPcJaBg1xML6KaaPaUM1vMaO+JirWaPMqXYZ7B3DliXF8Itzbaoe6+ztUSx7fu'
        'ogjw5b+d5oikswtZ8MLE8CMJmQrkSD5vcZ0AEUqBxxSnoh1j4WSXUyHiNYcPkOwLOUOhnVxppVEscnn63XJ2hnTI0oQTJyfmHluO'
        'oLzNQQnCTZ+apejcgSXA1wifERZmArbQkrfnlIVoMmK+VljkyfjLkvOFttrv+7TirxzGNYEr2Pa9nBxhIIRGgBrpqQ6dxDa4FQqm'
        'Woq3JWODYCxGdsZIzMdoxgZ7OMFcpmWvsvL03J9vuYmjjmFT209wx8R62KWFu9IuMHXdpqHnvYZuOG6ddcjT09NXqhyubUo38yL7'
        'Rq6iHr3AcugUtN9MHKGJYTIEzZI7rmbQrR6D3aVRyYanHLDbqQO+fGQA1UENW2nkSIGbqUtL8Qx9Or8sH6Mu09/Is4/7I4fkEfCF'
        'IIPt8+b6Yo5+LtJkHyo7mrXP6pECcIOBCNwnH0M1dpa7mvbZZ0TqWrUK5Ypx6Y4GvXdySPzQ2T4i/Pdprq6FDeqX8j5/lR45eKCo'
        '/7hqy2SU9UFjkeCi3LNw/8W3cf8RFTMa9U5Jg5qMn4sBRXb/ZNcgCyWZcJydcVhfkGvN/GpLynT1MsoZaiNrCXSNjqoTgHG7Rcfy'
        'ygpY2vcdxDTh45mnb8hcdGk9DGVqLrfcRdXaXQ05d2lRA6Mf9sxIppurAjYd4mHI2XsQtC6cBUp246+1oIJoyorBQjn03zzKJVnQ'
        'NyLwxp+xdCKGbpryOH05bXxIkxEKmmiMtk+y5NUynKNL2W47eyuJ1WojOqoM7vuW16c8kCsjPfVhAiRvhHqELHqCkOr/TIqgEiGZ'
        'TBKjPlWdzUu4p7RPbAhpx/YULsZ2qmGw/pTAF5/tDgaVfT1o1Ro/Y+ocYnQO/UYimQfOJbW4Ub+Pt8Qj0J/ldK+uw/LeC0fVxnkw'
        '9PqavkGOmIy8s+FuLA8z85V+2qKC7h2QpXY7jPumRC6ULf5utbiLLy59e9RfYo/6W1mj/sxIYfkFImone3p6RgCP3rjqa+/u9uEt'
        'Rgq9VMbaNQOPk6qUxbHgA1LeYKS+zsRyNKs4mM/+Rq5w8BFOocF2SkL81GQzBiG6P5IlSLFUZzp+95I0X+8HMMF3uS+jT/yRx2vs'
        '70Ts5MzGVkx4JYweirmwHtE3gegEmJNY41aTtA/pSLFGTdMoxXpsw3I5AOiCBKuG5InxdjXTMay+XWupvvTv3vvsFuqxkkZke0iy'
        'CiP4QC3NLnSABZiNRGpM+5LEtE8OMY5mRoIjQVvNpKUw6YOb45DPGRhRTzgkiIdIQHsqTMJf7TMCow6609L1rnsfh5zBQZ/EUSMJ'
        'VFdx4EU06WVrIT5O8kjmufsjzXZZnj8tNh8GQcMLpSEs7zsX6cspaEXtLZcTJ/peMGpkicK3477Wo+pUi8DYOebQ2QiAETLVaNVr'
        'Rqu7uW3y66KUpiRIijFnS15bkA8eB6O2pDJo40QZRWiTfWVlSKK1PL9q2RC2hImhrDqtKVvPupdSmaamqQEHHOwMrT58pY35sZG0'
        '1xFwfP115VXVSBdWv8G3BZtE2P6wvbkaMNAm7rQ7J1hbLTHbs5zmbX/n5WlmBeZ6cC2mt/aOZO6isGq6kzyEDNBairfA/tnHwNhj'
        'PPR+P/vUlv/4N+jxxP4='),
    'assets/index-CBM3qIDD.js': ('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'),
    'index.html': ('eNptUctOwzAQvPMVxucm5nEAoTgSpUJCggtqP8C1t8kWxw7ebUv/HqehQAUn79jzkHeqcxct73sQLXe+PquGQ3gTGi3TRtZnQlQt'
        'GDcMeeyAjbCtSQSs5WL+WNxKoX4/BtOBlluEXR8TS2FjYAiZvEPHrXawRQvFAUwEBmQ0viBrPOjL8mIijspihaxt3EL6CWBkD/V8'
        'tmhyyCsQeF+p8XIkkE3Ys6BktWyZe7pTisFDk0xXxtSoNX3jYgfLwvR9uSZZV2qUnvoMe9Gyi27jIf8kRaKYsMEwJpTKUN4DKQwO'
        'PoqH6cv1+9Ns9p+hx/AmEngtifceqAXgU8c2weqP5fTm/vnKYVNaorELdSyjWka3/3J3uBXocmEx8hCd8UgeOVl06PYTDuyfvA=='),
}
_WEBAPP_EMBED_TAG = "06e291b8a40c"


def _webapp_unpack() -> None:
    """Раскладывает встроенное мини-приложение в папку webapp (если плагин собран одним файлом)."""
    if not _WEBAPP_EMBED:
        return
    import base64
    import zlib
    tag_file = os.path.join(WEBAPP_DIR, ".embed")
    try:
        if open(tag_file, encoding="utf-8").read().strip() == _WEBAPP_EMBED_TAG:
            return
    except OSError:
        pass
    shutil.rmtree(WEBAPP_DIR, ignore_errors=True)
    for rel, blob in _WEBAPP_EMBED.items():
        dst = os.path.join(WEBAPP_DIR, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        with open(dst, "wb") as f:
            f.write(zlib.decompress(base64.b64decode(blob)))
    with open(tag_file, "w", encoding="utf-8") as f:
        f.write(_WEBAPP_EMBED_TAG)


def _webapp_port() -> int:
    if _webapp_mode() != "own":
        return 0
    return int(SETTINGS.get("webapp_port") or 0) or WEBAPP_DEFAULT_PORT


def _webapp_start() -> None:
    port = _webapp_port()
    if not port or _webapp_srv["server"] is not None:
        return
    try:
        _webapp_unpack()
    except Exception as e:
        logger.error(f"{LP} [WEB] не удалось распаковать мини-приложение: {e}")
    if not os.path.isfile(os.path.join(WEBAPP_DIR, "index.html")):
        logger.warning(f"{LP} [WEB] нет {WEBAPP_DIR}/index.html — мини-приложение не запущено")
        return
    try:
        srv = ThreadingHTTPServer(("127.0.0.1", port), _WebHandler)
        srv.daemon_threads = True
    except OSError as e:
        logger.error(f"{LP} [WEB] порт {port} недоступен: {e}")
        return
    th = threading.Thread(target=srv.serve_forever, name="tdugame-webapp", daemon=True)
    th.start()
    _webapp_srv.update({"server": srv, "thread": th})
    logger.info(f"{LP} [WEB] мини-приложение слушает 127.0.0.1:{port}")


def _webapp_stop() -> None:
    srv = _webapp_srv.get("server")
    if srv is not None:
        try:
            srv.shutdown()
            srv.server_close()
        except Exception:
            pass
    _webapp_srv.update({"server": None, "thread": None})


def init(cardinal: "Cardinal"):
    global tg, bot, cardinal_instance
    tg = cardinal.telegram
    bot = cardinal.telegram.bot if cardinal.telegram else None
    cardinal_instance = cardinal

    load_all()
    _start_poller()
    _webapp_start()
    _relay_start()
    _update_start()
    threading.Timer(12.0, _notify_started).start()
    _patch_plugin_card()

    if tg and bot:
        handlers = [
            (tg_main, lambda c: f"{CBT.PLUGIN_SETTINGS}:{UUID}" in c.data),
            (tg_main, lambda c: c.data == f"{P}_main"),
            (tg_info, lambda c: c.data == f"{P}_info"),
            (tg_toggle, lambda c: c.data.startswith(f"{P}_toggle:")),
            (tg_basic, lambda c: c.data == f"{P}_basic"),
            (tg_apikey, lambda c: c.data == f"{P}_apikey"),
            (tg_stats, lambda c: c.data == f"{P}_stats"),
            (tg_maps, lambda c: c.data.startswith(f"{P}_maps:")),
            (tg_map_detail, lambda c: c.data.startswith(f"{P}_map:")),
            (tg_map_hours, lambda c: c.data.startswith(f"{P}_map_hours:")),
            (tg_map_del, lambda c: c.data.startswith(f"{P}_map_del:")),
            (tg_map_del_yes, lambda c: c.data.startswith(f"{P}_map_dely:")),
            (tg_addmap, lambda c: c.data.startswith(f"{P}_addmap:")),
            (tg_addmap_manual, lambda c: c.data == f"{P}_addmap_manual"),
            (tg_pick_lot, lambda c: c.data.startswith(f"{P}_picklot:")),
            (tg_pick_product, lambda c: c.data.startswith(f"{P}_pickprod:")),
            (tg_prodlist, lambda c: c.data.startswith(f"{P}_prodlist:")),
            (tg_prodsearch, lambda c: c.data == f"{P}_prodsearch"),
            (tg_prodmanual, lambda c: c.data == f"{P}_prodmanual"),
            (tg_noop, lambda c: c.data == f"{P}_noop"),
            (tg_params, lambda c: c.data == f"{P}_params"),
            (tg_p_stars, lambda c: c.data == f"{P}_p_stars"),
            (tg_p_set, lambda c: c.data.startswith(f"{P}_p_set:")),
            (tg_texts, lambda c: c.data.startswith(f"{P}_texts")),
            (tg_text_edit, lambda c: c.data.startswith(f"{P}_text:")),
            (tg_text_reset, lambda c: c.data.startswith(f"{P}_textreset:")),
            (tg_ac_menu, lambda c: c.data == f"{P}_ac"),
            (tg_ac_w_start, lambda c: c.data == f"{P}_ac_w_start"),
            (tg_ac_game_markup, lambda c: c.data == f"{P}_ac_game_markup"),
            (tg_ac_edit, lambda c: c.data == f"{P}_ac_edit"),
            (tg_ac_edit_field, lambda c: c.data.startswith(f"{P}_ac_e:")),
            (tg_ac_w_dpre, lambda c: c.data.startswith(f"{P}_ac_w_dpre:")),
            (tg_ac_w_dcustom, lambda c: c.data == f"{P}_ac_w_dcustom"),
            (tg_ac_w_price_mode, lambda c: c.data.startswith(f"{P}_ac_w_pmode:")),
            (tg_ac_refresh, lambda c: c.data == f"{P}_ac_refresh"),
            (tg_ac_dbg, lambda c: c.data == f"{P}_ac_dbg"),
            (tg_ac_help, lambda c: c.data.startswith(f"{P}_ac_help")),
            (tg_ac_newtoggle, lambda c: c.data == f"{P}_ac_newtoggle"),
            (tg_ac_params, lambda c: c.data == f"{P}_ac_params"),
            (tg_ac_dur, lambda c: c.data == f"{P}_ac_dur"),
            (tg_ac_dtog, lambda c: c.data.startswith(f"{P}_ac_dtog:")),
            (tg_ac_dcust, lambda c: c.data == f"{P}_ac_dcust"),
            (tg_ac_edef, lambda c: c.data.startswith(f"{P}_ac_edef:")),
            (tg_ac_hdel, lambda c: c.data == f"{P}_ac_hdel"),
            (tg_ac_hdel_yes, lambda c: c.data == f"{P}_ac_hdel_yes"),
            (tg_ac_xdel, lambda c: c.data == f"{P}_ac_xdel"),
            (tg_ac_xdel_yes, lambda c: c.data == f"{P}_ac_xdel_yes"),
            (tg_acx, lambda c: c.data == f"{P}_acx" or c.data.startswith(f"{P}_acx:")),
            (tg_acx_t, lambda c: c.data.startswith(f"{P}_acx_t:")),
            (tg_acx_all, lambda c: c.data == f"{P}_acx_all"),
            (tg_acx_add, lambda c: c.data == f"{P}_acx_add"),
            (tg_acx_pick, lambda c: c.data.startswith(f"{P}_acx_pick:")),
            (tg_acx_rm, lambda c: c.data.startswith(f"{P}_acx_rm:")),
            (tg_acx_clear, lambda c: c.data == f"{P}_acx_clear"),
            (tg_ac_diag, lambda c: c.data == f"{P}_ac_diag"),
            (tg_ac_miss, lambda c: c.data == f"{P}_ac_miss"),
            (tg_ac_game_miss, lambda c: c.data == f"{P}_ac_game_miss"),
            (tg_ac_preview, lambda c: c.data.startswith(f"{P}_ac_prev:")),
            (tg_ac_confirm, lambda c: c.data == f"{P}_ac_confirm"),
            (tg_ac_go, lambda c: c.data == f"{P}_ac_go"),
            (tg_ac_stop, lambda c: c.data == f"{P}_ac_stop"),
            (tg_ac_reprice, lambda c: c.data == f"{P}_ac_reprice"),
            (tg_ac_delall, lambda c: c.data == f"{P}_ac_delall"),
            (tg_ac_del_lots, lambda c: c.data == f"{P}_ac_del_lots"),
            (tg_ac_del_lots_yes, lambda c: c.data == f"{P}_ac_del_lots_yes"),
            (tg_ac_del_links, lambda c: c.data == f"{P}_ac_del_links"),
            (tg_ac_del_links_yes, lambda c: c.data == f"{P}_ac_del_links_yes"),
        ]

        def _safe(handler):
            def wrapper(call):
                try:
                    handler(call)
                except Exception as e:
                    logger.error(f"{LP} [TG] {handler.__name__}: {e}", exc_info=True)
                    try:
                        bot.answer_callback_query(call.id, "Ошибка, см. логи", show_alert=True)
                    except Exception:
                        pass
            return wrapper

        for handler, cond in handlers:
            tg.cbq_handler(_safe(handler), cond)

        def _msg_filter(m):
            sd = _get_tg_state(m.chat.id, m.from_user.id)
            if not sd: 
                return False
            st = sd.get("state", "") if isinstance(sd, dict) else ""
            return st.startswith(P)

        tg.msg_handler(tg_msg_dispatch, func=_msg_filter)

    on_new_order.plugin_uuid = UUID
    on_new_message.plugin_uuid = UUID
    on_last_chat_message_changed.plugin_uuid = UUID

    def _drop_stale_handlers(items: list, keep):
        items[:] = [
            h for h in items
            if h is keep or getattr(h, "plugin_uuid", None) != UUID
        ]

    _drop_stale_handlers(cardinal.new_order_handlers, on_new_order)
    _drop_stale_handlers(cardinal.new_message_handlers, on_new_message)
    _drop_stale_handlers(cardinal.last_chat_message_changed_handlers, on_last_chat_message_changed)

    if on_new_order not in cardinal.new_order_handlers:
        cardinal.new_order_handlers.append(on_new_order)
    if on_new_message not in cardinal.new_message_handlers:
        cardinal.new_message_handlers.append(on_new_message)
    if on_last_chat_message_changed not in cardinal.last_chat_message_changed_handlers:
        cardinal.last_chat_message_changed_handlers.append(on_last_chat_message_changed)

    logger.info(f"{LP} Initialized v{VERSION}, mappings={len(MAPPINGS)}")


BIND_TO_PRE_INIT = [init]
def _on_plugin_delete(*args, **kwargs):
    _stop_poller()
    _webapp_stop()
    _relay_stop()
    _update_stop_all()


BIND_TO_DELETE = _on_plugin_delete
