"""
reauth.py — модуль переавторизации Telegram-аккаунтов и конвертеры tdata ↔ session.

3 операции:
  1) Переавторизация — создание новой сессии с новыми параметрами (api_id, device, app_version, lang).
     Использует auth.ExportLoginToken + auth.AcceptLoginToken.
  2) tdata → session — через opentele.TDesktop
  3) session → tdata — через opentele (online/offline)
"""
import os
import json
import time
import random
import shutil
import asyncio
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from datetime import datetime

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


async def _drain_disconnect(client):
    """Telethon disconnect() триггерит cancel() для send/recv-loops, но не
    дожидается их завершения — отсюда «Task was destroyed but it is pending».
    Этот helper аналогичен core.client_builder.safe_disconnect, но локальный
    чтоб избежать циклического импорта (reauth → client_builder → reauth)."""
    if not client:
        return
    try:
        await client.disconnect()
        await asyncio.sleep(0.3)
        sender = getattr(client, "_sender", None)
        if sender:
            for tn in ("_send_loop_task", "_recv_loop_task", "_send_task", "_recv_task"):
                t = getattr(sender, tn, None)
                if t and not t.done():
                    try:
                        t.cancel()
                        await asyncio.wait_for(t, timeout=1.0)
                    except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
                        pass
            conn_obj = getattr(sender, "_connection", None)
            if conn_obj:
                for tn in ("_send_task", "_recv_task"):
                    t = getattr(conn_obj, tn, None)
                    if t and not t.done():
                        try:
                            t.cancel()
                            await asyncio.wait_for(t, timeout=0.5)
                        except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
                            pass
    except Exception:
        pass


# ──────────────────────────────────────────────────────────────────────────────
# Пути и утилиты
# ──────────────────────────────────────────────────────────────────────────────

def _reauthenticated_dir() -> Path:
    """Папка для результатов переавторизации (если keep_original=True)."""
    from config import ACCOUNTS_DIR
    d = ACCOUNTS_DIR / "После переавторизации"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _tdata_archive_dir() -> Path:
    from config import DATA_DIR
    d = DATA_DIR / "tdata_archive"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _tdata_out_dir() -> Path:
    """Корень куда складываем результаты session→tdata."""
    from config import ACCOUNTS_DIR
    d = ACCOUNTS_DIR / "Конвертер session \u2192 tdata"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _tdata_in_dir() -> Path:
    """Папка-дефолт откуда берём tdata для конверта в session."""
    from config import ACCOUNTS_DIR
    d = ACCOUNTS_DIR / "Конвертер tdata \u2192 session"
    d.mkdir(parents=True, exist_ok=True)
    return d


def _json_for_session(session_path: Path) -> Path:
    return session_path.with_suffix(".json")


def _save_account_json(json_path: Path, params: Dict, user_data: Optional[Dict] = None,
                       existing_2fa: Optional[str] = None):
    """Пишет .json файл с метаданными аккаунта."""
    user_data = user_data or {}
    data = {
        "app_id": params["api_id"],
        "app_hash": params["api_hash"],
        "sdk": params["system_version"],
        "device": params["device_model"],
        "app_version": params["app_version"],
        "lang_pack": params["lang_code"],
        "system_lang_pack": params["system_lang_code"],
        "twoFA": existing_2fa,
        "id": user_data.get("id", 0),
        "phone": user_data.get("phone", ""),
        "username": user_data.get("username", ""),
        "first_name": user_data.get("first_name", ""),
        "last_name": user_data.get("last_name", ""),
        "is_premium": user_data.get("is_premium", False),
        "session_created_date": datetime.now().isoformat(),
        "register_time": int(time.time()),
    }
    json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


# ──────────────────────────────────────────────────────────────────────────────
# Переавторизация
# ──────────────────────────────────────────────────────────────────────────────

async def reauthenticate_account(
    old_session_path: Path,
    phone: str,
    preset: str,
    custom_params: Optional[Dict] = None,
    new_2fa: str = "",
    close_others: bool = True,
    delete_dialog: bool = True,
    continue_on_limit: bool = False,
    keep_original: bool = True,
    proxy_tuple: Optional[Tuple] = None,
    existing_2fa: Optional[str] = None,
    log_fn=None,
) -> Tuple[bool, str]:
    """Переавторизует одну сессию с новыми параметрами.

    Returns: (success, message)
    """
    from telethon import TelegramClient
    from telethon.sessions import StringSession
    from telethon.errors import SessionPasswordNeededError, FloodWaitError
    from telethon.tl.functions.auth import (
        ExportLoginTokenRequest, AcceptLoginTokenRequest, ImportLoginTokenRequest,
        ResetAuthorizationsRequest,  # лежит именно в .auth, не в .account
    )
    from telethon.tl.functions.account import (
        UpdatePasswordSettingsRequest, GetPasswordRequest,
    )
    from telethon.tl.functions.messages import DeleteHistoryRequest
    from telethon.tl.types.auth import LoginToken, LoginTokenMigrateTo, LoginTokenSuccess

    from core.reauth_presets import get_preset_params, TDESKTOP_API_ID, TDESKTOP_API_HASH

    log = log_fn or (lambda m: logger.info("[reauth] %s", m))

    params = get_preset_params(preset, custom_params)

    # 1. Подключаем старый клиент
    old_client = TelegramClient(
        str(old_session_path.with_suffix("")),
        api_id=TDESKTOP_API_ID, api_hash=TDESKTOP_API_HASH,
        device_model="PC 64bit", system_version="Windows 10",
        app_version="6.2.2 x64", lang_code="en", system_lang_code="en-US",
        proxy=proxy_tuple,
    )
    try:
        await old_client.connect()
    except Exception as e:
        return False, f"Не подключается: {e}"
    if not await old_client.is_user_authorized():
        await _drain_disconnect(old_client)
        return False, "Сессия невалидна (unauthorized)"

    try:
        me = await old_client.get_me()
        user_data = {
            "id": me.id, "phone": me.phone or phone,
            "username": me.username or "",
            "first_name": me.first_name or "", "last_name": me.last_name or "",
            "is_premium": bool(getattr(me, "premium", False)),
        }
    except Exception as e:
        user_data = {"phone": phone}
        log(f"⚠ {phone}: get_me failed: {e}")

    # 2. Создаём новый клиент со StringSession и новыми параметрами
    new_client = TelegramClient(
        StringSession(),
        api_id=params["api_id"], api_hash=params["api_hash"],
        device_model=params["device_model"], system_version=params["system_version"],
        app_version=params["app_version"], lang_code=params["lang_code"],
        system_lang_code=params["system_lang_code"],
        proxy=proxy_tuple,
    )
    try:
        await new_client.connect()
    except Exception as e:
        await _drain_disconnect(old_client)
        return False, f"Новый клиент не подключается: {e}"

    # 3. ExportLoginToken / AcceptLoginToken / ImportLoginToken
    try:
        token_r = await new_client(ExportLoginTokenRequest(
            api_id=params["api_id"], api_hash=params["api_hash"], except_ids=[]
        ))
        if not isinstance(token_r, LoginToken):
            raise RuntimeError(f"Unexpected ExportLoginToken result: {type(token_r).__name__}")

        # Принимаем токен старой сессией
        auth_r = await old_client(AcceptLoginTokenRequest(token=token_r.token))
        log(f"✓ {phone}: токен принят старой сессией")

        # Получаем финальную авторизацию на новом клиенте
        final = await new_client(ImportLoginTokenRequest(token=token_r.token))
        if isinstance(final, LoginTokenMigrateTo):
            await new_client._switch_dc(final.dc_id)
            final = await new_client(ImportLoginTokenRequest(token=final.token))
        if not isinstance(final, LoginTokenSuccess):
            raise RuntimeError(f"Unexpected ImportLoginToken: {type(final).__name__}")
        log(f"✓ {phone}: новая авторизация получена")
    except FloodWaitError as e:
        await _drain_disconnect(new_client)
        await _drain_disconnect(old_client)
        return False, f"FloodWait {e.seconds}s"
    except Exception as e:
        await _drain_disconnect(new_client)
        await _drain_disconnect(old_client)
        return False, f"ExportLoginToken error: {e}"

    # 4. Опционально: новая 2FA
    if new_2fa:
        try:
            from telethon.tl.functions.account import UpdatePasswordSettingsRequest
            pwd = await new_client(GetPasswordRequest())
            from telethon.password import compute_new_password_hash, compute_check
            new_hash = compute_new_password_hash(pwd.new_algo, new_2fa)
            current_hash = None
            if existing_2fa and pwd.has_password:
                current_hash = compute_check(pwd, existing_2fa)
            await new_client(UpdatePasswordSettingsRequest(
                password=current_hash, new_settings=type('S', (), {
                    'new_algo': pwd.new_algo,
                    'new_password_hash': new_hash,
                    'hint': '',
                    'email': None,
                    'new_secure_settings': None,
                })()
            ))
            log(f"✓ {phone}: 2FA установлена")
            existing_2fa = new_2fa
        except Exception as e:
            log(f"⚠ {phone}: не удалось установить 2FA: {e}")

    # 5. Опционально: закрыть другие сессии
    if close_others:
        try:
            await new_client(ResetAuthorizationsRequest())
            log(f"✓ {phone}: остальные сессии закрыты")
        except Exception as e:
            msg = str(e).lower()
            if "freshly" in msg or "24 hours" in msg or "fresh_reset" in msg:
                if continue_on_limit:
                    log(f"⏳ {phone}: <24ч с момента создания, пропускаем закрытие")
                else:
                    await _drain_disconnect(new_client)
                    await _drain_disconnect(old_client)
                    return False, "<24ч с момента создания сессии — нельзя закрыть другие"
            else:
                log(f"⚠ {phone}: ResetAuthorizations: {e}")

    # 6. Опционально: удалить диалог с Telegram (peer=777000)
    if delete_dialog:
        try:
            await new_client(DeleteHistoryRequest(peer=777000, max_id=0, just_clear=False, revoke=False))
            log(f"✓ {phone}: диалог с Telegram очищен")
        except Exception as e:
            log(f"⚠ {phone}: не удалось удалить диалог: {e}")

    # 7. Сохраняем новую сессию + .json
    try:
        string_session = StringSession.save(new_client.session)
        # Выбираем целевой путь
        if keep_original:
            target_dir = _reauthenticated_dir()
            target_session = target_dir / old_session_path.name
        else:
            target_session = old_session_path

        # Чтобы записать .session файл — создаём SQLite-session
        from telethon.sessions import SQLiteSession
        await _drain_disconnect(new_client)
        # Пишем StringSession → SQLite файл
        target_path_no_ext = str(target_session.with_suffix(""))
        sqlite_session = SQLiteSession(target_path_no_ext)
        tmp_client = TelegramClient(
            sqlite_session,
            api_id=params["api_id"], api_hash=params["api_hash"],
            device_model=params["device_model"], system_version=params["system_version"],
            app_version=params["app_version"], lang_code=params["lang_code"],
            system_lang_code=params["system_lang_code"],
            proxy=proxy_tuple,
        )
        # Загружаем в новый файл содержимое строковой сессии
        str_sess = StringSession(string_session)
        tmp_client.session.set_dc(str_sess.dc_id, str_sess.server_address, str_sess.port)
        tmp_client.session.auth_key = str_sess.auth_key
        tmp_client.session.save()
        await _drain_disconnect(tmp_client) if tmp_client.is_connected() else None

        # .json
        _save_account_json(_json_for_session(target_session), params, user_data, existing_2fa)
        log(f"✅ {phone}: сохранено в {target_session.name}")
    except Exception as e:
        logger.warning("[reauth] save error %s: %s", phone, e)
        await _drain_disconnect(old_client)
        return False, f"Ошибка сохранения: {e}"
    finally:
        await _drain_disconnect(old_client)

    return True, f"OK — новые параметры: {params['preset_label']}"


# ──────────────────────────────────────────────────────────────────────────────
# tdata → session
# ──────────────────────────────────────────────────────────────────────────────

def _find_tdata_folders(root: Path, max_depth: int = 4) -> List[Path]:
    """Находит папки tdata (по маркеру D877F783D5D3EF8C) вглубь до max_depth."""
    result = []
    root = Path(root)
    if not root.exists():
        return result

    def _walk(p: Path, depth: int):
        if depth > max_depth:
            return
        try:
            children = list(p.iterdir())
        except Exception:
            return
        # Если текущая папка — tdata
        markers = [c for c in children if c.is_dir() and c.name.upper() == "D877F783D5D3EF8C"]
        if markers or any(c.name == "key_datas" for c in children):
            result.append(p)
            return
        for c in children:
            if c.is_dir():
                _walk(c, depth + 1)

    _walk(root, 0)
    return result


async def convert_tdata_to_session(
    folder: Path,
    move_tdata: bool = True,
    password_filename: str = "Password2FA.txt",
    depth: int = 4,
    threads: int = 1,
    log_fn=None,
) -> Tuple[int, int, List[str]]:
    """Конвертирует все tdata-папки в folder → .session + .json в USERS_DIR.

    Returns: (ok_count, fail_count, errors)
    """
    from config import USERS_DIR
    from core.reauth_presets import get_preset_params

    log = log_fn or (lambda m: logger.info("[reauth-tdata] %s", m))
    ok, fail = 0, 0
    errors = []

    try:
        from opentele.td import TDesktop
        from opentele.api import API, UseCurrentSession
    except Exception as e:
        return 0, 0, [f"opentele не установлен: {e}"]

    folders = _find_tdata_folders(Path(folder), max_depth=depth)
    log(f"Найдено tdata-папок: {len(folders)}, потоков={threads}")

    sem = asyncio.Semaphore(max(1, threads))
    counters = {"ok": 0, "fail": 0}
    lock = asyncio.Lock()

    async def _process_one(tdata_path):
        async with sem:
            try:
                # тело — то же что и раньше, используем замыкания
                await _convert_tdata_one(tdata_path, move_tdata, password_filename, log)
                async with lock:
                    counters["ok"] += 1
            except Exception as e:
                async with lock:
                    counters["fail"] += 1
                    errors.append(f"{tdata_path.name}: {e}")
                log(f"❌ {tdata_path.name}: {e}")

    await asyncio.gather(*[_process_one(f) for f in folders])
    return counters["ok"], counters["fail"], errors


async def _convert_tdata_one(tdata_path: Path, move_tdata: bool, password_filename: str, log) -> None:
    """Внутренняя: конвертирует одну tdata-папку. Кидает исключение при ошибке.

    Результат складывается в `Аккаунты/Конвертер tdata → session/Готово/`,
    НЕ в «В работе». Раньше результат шёл прямо в рабочую папку и autoload
    тут же подхватывал — клиент терял контроль над тем, какие акки
    активные. Теперь: конвертация → staging-папка → клиент сам перетаскивает
    готовые файлы в «В работе» когда ему удобно.
    """
    from core.reauth_presets import get_preset_params
    from opentele.td import TDesktop
    from opentele.api import API, UseCurrentSession
    from telethon.sessions import StringSession, SQLiteSession

    OUT_DIR = _tdata_in_dir() / "Готово"
    OUT_DIR.mkdir(parents=True, exist_ok=True)

    tdesk = TDesktop(tdata_path)
    if not tdesk.isLoaded():
        raise RuntimeError("TData не загружена")
    # account = tdesk.mainAccount — был вызов account.prepareToConvert(),
    # но этого метода нет в текущей opentele. TDesktop.ToTelethon() сам
    # делает всю подготовку внутри. Убрали, иначе все 10 файлов падали с
    # «'Account' object has no attribute 'prepareToConvert'».

    phone = ""
    telethon_client = await tdesk.ToTelethon(
        session=None, flag=UseCurrentSession, api=API.TelegramDesktop(),
    )
    await telethon_client.connect()
    me = await telethon_client.get_me()
    if me:
        phone = me.phone or ""
    name = phone or tdata_path.parent.name or tdata_path.name
    name = name.replace("+", "").strip() or "account"
    target_session = OUT_DIR / f"{name}.session"
    target_json = OUT_DIR / f"{name}.json"

    string_session = StringSession.save(telethon_client.session)
    await _drain_disconnect(telethon_client)
    str_sess = StringSession(string_session)
    sqlite_sess = SQLiteSession(str(target_session.with_suffix("")))
    sqlite_sess.set_dc(str_sess.dc_id, str_sess.server_address, str_sess.port)
    sqlite_sess.auth_key = str_sess.auth_key
    sqlite_sess.save()

    pwd_file = tdata_path.parent / password_filename
    twofa = None
    if pwd_file.exists():
        try:
            twofa = pwd_file.read_text(encoding="utf-8").strip()
        except Exception:
            pass

    params = get_preset_params("tdesktop")
    user_data = {"phone": phone, "id": me.id if me else 0,
                 "username": me.username if me else ""}
    _save_account_json(target_json, params, user_data, twofa)

    if move_tdata:
        try:
            dest = _tdata_archive_dir() / tdata_path.name
            shutil.move(str(tdata_path), str(dest))
        except Exception as me_exc:
            log(f"⚠ move tdata: {me_exc}")

    log(f"✅ {name} ← {tdata_path.name}")


# ──────────────────────────────────────────────────────────────────────────────
# session → tdata
# ──────────────────────────────────────────────────────────────────────────────

async def convert_session_to_tdata(
    sessions: List[Tuple[Path, Optional[Dict]]],
    pack_count: int = 1,
    offline: bool = False,
    save_2fa: bool = True,
    copy_original: bool = True,
    threads: int = 1,
    proxy_tuple: Optional[Tuple] = None,
    log_fn=None,
) -> Tuple[int, int, List[str]]:
    """Конвертирует список сессий в tdata-папки в DATA_DIR/tdata_out/.

    sessions: список (session_path, json_metadata_dict)
    pack_count: сколько аккаунтов в одну tdata
    """
    from telethon import TelegramClient
    from core.reauth_presets import TDESKTOP_API_ID, TDESKTOP_API_HASH

    log = log_fn or (lambda m: logger.info("[reauth-s2t] %s", m))
    ok, fail = 0, 0
    errors = []

    try:
        from opentele.td import TDesktop
        from opentele.api import API, CreateNewSession, UseCurrentSession
    except Exception as e:
        return 0, 0, [f"opentele не установлен: {e}"]

    out_root = _tdata_out_dir()
    batches: List[List[Tuple[Path, Optional[Dict]]]] = []
    for i in range(0, len(sessions), max(1, pack_count)):
        batches.append(sessions[i:i + max(1, pack_count)])

    sem = asyncio.Semaphore(max(1, threads))
    counters = {"ok": 0, "fail": 0}
    lock = asyncio.Lock()

    async def _process_batch(batch_i, batch):
        async with sem:
            try:
                await _s2t_batch(batch, out_root, offline, save_2fa, copy_original, proxy_tuple, log)
                async with lock:
                    counters["ok"] += 1
            except Exception as e:
                async with lock:
                    counters["fail"] += 1
                    errors.append(f"batch #{batch_i}: {e}")
                log(f"❌ batch #{batch_i}: {e}")

    # Техническая строка про батчи — только в debug-файл, не в UI
    logger.debug("[reauth-s2t] Батчей: %d, потоков=%d", len(batches), threads)
    await asyncio.gather(*[_process_batch(i, b) for i, b in enumerate(batches)])
    return counters["ok"], counters["fail"], errors


async def _s2t_batch(batch, out_root, offline, save_2fa, copy_original, proxy_tuple, log):
    """Обрабатывает один batch аккаунтов в одну tdata-папку."""
    from telethon import TelegramClient
    from core.reauth_presets import TDESKTOP_API_ID, TDESKTOP_API_HASH
    from opentele.td import TDesktop
    from opentele.api import API, CreateNewSession, UseCurrentSession
    primary_session, primary_meta = batch[0]
    phone = (primary_meta or {}).get("phone", "") or primary_session.stem
    phone_clean = phone.replace("+", "").strip()
    # Структура как у эталона Telegram Desktop:
    #   <номер>/              ← контейнер
    #     tdata/              ← ровно эту папку копируют в Telegram Desktop
    #       D877F783D5D3EF8C/
    #         maps
    #       D877F783D5D3EF8Cs
    #       key_datas
    #       Accounts.txt
    #       Password2FA.txt
    #       <номер>.session   ← оригинал рядом
    #       <номер>.json
    acc_root = out_root / phone_clean
    target_tdata = acc_root / "tdata"
    target_tdata.mkdir(parents=True, exist_ok=True)

    # Выбор режима:
    #   offline=True → не ходим в Telegram, просто упаковываем локальный auth_key в tdata.
    #                  Быстро (секунды), акк НЕ убивается, сессия остаётся та же.
    #                  Идентично тому что делает Raptor/Kraken по умолчанию.
    #   offline=False → UseCurrentSession: подключаемся к Telegram чтобы проверить что сессия жива,
    #                   но НЕ создаём новую (CreateNewSession убил бы старый auth_key).
    # Итог: оба режима сохраняют акк живым. CreateNewSession (убивающий) мы не используем.
    log(f"🔌 {phone}: подключение {'оффлайн' if offline else 'через прокси'}")

    first_client = TelegramClient(
        str(primary_session.with_suffix("")),
        api_id=(primary_meta or {}).get("app_id", TDESKTOP_API_ID),
        api_hash=(primary_meta or {}).get("app_hash", TDESKTOP_API_HASH),
        proxy=None if offline else proxy_tuple,
    )
    try:
        await asyncio.wait_for(first_client.connect(), timeout=30.0)
    except asyncio.TimeoutError:
        raise RuntimeError(f"{phone}: прокси не отвечает (30 сек)")
    except Exception as e:
        raise RuntimeError(f"{phone}: ошибка подключения ({type(e).__name__}: {e})")

    if not offline:
        try:
            authorized = await asyncio.wait_for(first_client.is_user_authorized(), timeout=10.0)
        except asyncio.TimeoutError:
            await _drain_disconnect(first_client)
            raise RuntimeError(f"{phone}: сессия не отвечает (10 сек)")
        if not authorized:
            await _drain_disconnect(first_client)
            raise RuntimeError(f"{phone}: сессия мёртвая — не авторизован")

    log(f"📦 {phone}: упаковываю в tdata")
    try:
        tdesk = await asyncio.wait_for(
            # UseCurrentSession — берёт существующий auth_key из .session и упаковывает
            # в tdata-формат БЕЗ создания новой сессии на сервере Telegram.
            # Акк остаётся живым, auth_key тот же, быстрее в 5-10 раз чем CreateNewSession.
            first_client.ToTDesktop(flag=UseCurrentSession, api=API.TelegramDesktop()),
            timeout=60.0,
        )
    except asyncio.TimeoutError:
        await _drain_disconnect(first_client)
        raise RuntimeError(f"{phone}: Telegram не ответил за 60 сек")
    except Exception as e:
        await _drain_disconnect(first_client)
        raise RuntimeError(f"{phone}: ошибка упаковки ({type(e).__name__}: {str(e)[:120]})")
    await _drain_disconnect(first_client)

    for extra_session, extra_meta in batch[1:]:
        try:
            ec = TelegramClient(
                str(extra_session.with_suffix("")),
                api_id=(extra_meta or {}).get("app_id", TDESKTOP_API_ID),
                api_hash=(extra_meta or {}).get("app_hash", TDESKTOP_API_HASH),
                proxy=None if offline else proxy_tuple,
            )
            await ec.connect()
            if not offline and not await ec.is_user_authorized():
                await _drain_disconnect(ec)
                continue
            await ec._add_to_tdesktop(tdesk) if hasattr(ec, '_add_to_tdesktop') else None
            await _drain_disconnect(ec)
        except Exception as e:
            log(f"⚠ добавить {extra_session.name}: {e}")

    # opentele.SaveTData создаёт внутри target_tdata:
    #   D877F783D5D3EF8C/  (папка с файлом maps внутри)
    #   D877F783D5D3EF8Cs  (файл)
    #   key_datas          (файл)
    # Это ядро tdata для Telegram Desktop.
    tdesk.SaveTData(target_tdata)
    log(f"  {phone}: SaveTData сохранил ядро (D877F783D5D3EF8C/, key_datas)")

    # Accounts.txt — список аккаунтов в этой tdata (читаемый). Не создаётся opentele по
    # умолчанию, создаём сами из meta primary+extras.
    try:
        accs_txt = []
        for _ps, _pm in batch:
            _mph = (_pm or {}).get("phone", "") or _ps.stem
            _usr = (_pm or {}).get("username", "")
            _nm = " ".join(x for x in [(_pm or {}).get("first_name"), (_pm or {}).get("last_name")] if x)
            accs_txt.append(" | ".join(x for x in [_mph, _usr, _nm] if x))
        (target_tdata / "Accounts.txt").write_text("\n".join(accs_txt) + "\n", encoding="utf-8")
    except Exception as e:
        log(f"⚠ Accounts.txt: {e}")

    # Password2FA.txt — рядом с D877F783D5D3EF8C (ВНУТРИ папки аккаунта, а не в
    # tdata_out/). Раньше клали в parent, поэтому пароль лежал не там где ожидается.
    if save_2fa and primary_meta and primary_meta.get("twoFA"):
        try:
            (target_tdata / "Password2FA.txt").write_text(
                primary_meta["twoFA"], encoding="utf-8"
            )
        except Exception:
            pass

    # Оригинальные .session + .json — ВНУТРЬ target_tdata (как у эталона),
    # а не в parent. Чтобы вся tdata-папка была самодостаточной и переносимой.
    if copy_original:
        try:
            shutil.copy2(str(primary_session), str(target_tdata / primary_session.name))
            jp = _json_for_session(primary_session)
            if jp.exists():
                shutil.copy2(str(jp), str(target_tdata / jp.name))
        except Exception as e:
            log(f"⚠ copy original: {e}")

    log(f"✅ {phone_clean} готов → {acc_root}")


# ──────────────────────────────────────────────────────────────────────────────
# Менеджер задач (прогресс)
# ──────────────────────────────────────────────────────────────────────────────

class ReauthJob:
    """Progress-трекер для переавторизации/конвертов."""
    def __init__(self, kind: str = "reauth"):
        self.kind = kind  # "reauth" | "t2s" | "s2t"
        self.status = "idle"  # idle | running | done | error
        self.total = 0
        self.done = 0
        self.ok = 0
        self.fail = 0
        self.started_at = 0.0
        self.finished_at = 0.0
        self.log_lines: List[str] = []
        self.stop_flag = False

    def log(self, msg: str):
        ts = datetime.now().strftime("%H:%M:%S")
        self.log_lines.append(f"[{ts}] {msg}")
        if len(self.log_lines) > 300:
            self.log_lines = self.log_lines[-200:]
        # В UI-консоль идёт без префикса [reauth-job] — клиент хотел чтобы
        # всё было по-человечески, а не техническими тегами.
        logger.info("%s", msg)

    def to_dict(self) -> dict:
        return {
            "kind": self.kind, "status": self.status,
            "total": self.total, "done": self.done, "ok": self.ok, "fail": self.fail,
            "started_at": self.started_at, "finished_at": self.finished_at,
            "log": self.log_lines[-100:],
        }
