import tkinter as tk
import os
import json
import threading
import queue
import asyncio
import time
import random
import re
import subprocess
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timedelta
from tkinter import ttk, messagebox, filedialog, simpledialog

DATA_FILE = 'accounts_v9.json'
PROXY_FILE = 'proxies.txt'
PROXY_DATA_FILE = 'proxies_v9.json'
IMPORT_FILE = 'import_accounts.txt'
REPORT_FILE = 'reports_v9.json'
GROUPS_FILE = 'joined_groups_v9.json'
NAMES_FILE = 'vietnamese_names.txt'
USAGE_FILE = 'daily_usage_v9.json'
SCENARIO_FILE = 'scenario_progress_v9.json'
SCENARIO_CFG_FILE = 'scenario_config_v9.json'

# Nhóm chất lượng nick (quản lý vận hành)
# moi = tài khoản mới | trau = tài khoản trâu (đã nuôi) | sieu = siêu chất lượng (cá nhân)
ACC_GROUP_MOI = 'moi'
ACC_GROUP_TRAU = 'trau'
ACC_GROUP_SIEU = 'sieu'
ACC_GROUP_LABELS = {
    ACC_GROUP_MOI: 'Mới',
    ACC_GROUP_TRAU: 'Trâu',
    ACC_GROUP_SIEU: 'Siêu chất lượng',
}
ACC_GROUP_ORDER = (ACC_GROUP_MOI, ACC_GROUP_TRAU, ACC_GROUP_SIEU)

# Giới hạn gợi ý / ngày / 1 nick (Meta không công bố số cố định; dùng mức an toàn thực tế)
# Nguồn tổng hợp: Meta Help (max 6000 groups/account), cộng đồng tool 2024-2026:
# - Join >20-30/ngày dễ bị "joining groups too fast"
# - Comment/reply >100/ngày dễ restricted
# - Post group 3-10/ngày tùy tuổi nick
ACTIVITY_PRESETS = {
    # mode: {join/day, comment/day, post/day, delay_sec_range, joins_per_keyword}
    'low': {
        'label': 'Ít (an toàn nick mới)',
        'join_day': 5,
        'comment_day': 12,
        'post_day': 3,
        'delay': (8, 15),
        'per_keyword': 2,
        'note': 'Nick mới 0-3 tháng: ~5-10 join/ngày',
    },
    'medium': {
        'label': 'Vừa (cân bằng)',
        'join_day': 12,
        'comment_day': 35,
        'post_day': 6,
        'delay': (5, 10),
        'per_keyword': 3,
        'note': 'Nick ổn định: ~10-20 join/ngày',
    },
    'high': {
        'label': 'Tối đa (cẩn trọng)',
        'join_day': 20,
        'comment_day': 60,
        'post_day': 12,
        'delay': (3, 7),
        'per_keyword': 5,
        'note': 'Sát ngưỡng rủi ro 20-30 join/ngày; >100 comment dễ block',
    },
}
# Trần tuyệt đối (không vượt dù chọn high)
ABSOLUTE_CAPS = {
    'join_day': 30,
    'comment_day': 80,
    'post_day': 20,
    'friend_day': 10,
    'groups_total': 6000,  # Meta official
}

# Delay an toàn kịch bản (người thật không click liên tục)
SAFE_ACTION_DELAY = (30, 120)       # Like / Join / kết bạn / hành động thường
SAFE_JOIN_DELAY = (60, 120)         # Join group cách nhau
SAFE_COMMENT_DELAY = (180, 300)     # Comment group 3–5 phút
SAFE_SCROLL_SEC = (180, 300)        # Lướt newsfeed 3–5 phút (warm-up silent)
SAFE_SCROLL_SHORT = (120, 180)      # Lướt 2–3 phút (daily)

# Trần kịch bản sống lâu (ưu tiên an toàn hơn preset high)
SCENARIO_CAPS = {
    'warmup_silent': {  # Ngày 1–3
        'scroll_min': (3, 5),
        'like': (2, 3),
        'read_noti': True,
        'friend': 0,
        'comment': 0,
        'join': 0,
        'post': 0,
        'forbid': ('friend', 'comment', 'join', 'post', 'avatar', 'password'),
    },
    'warmup_light': {  # Ngày 4–7
        'scroll_min': (3, 3),
        'like': (2, 3),
        'friend_send': (2, 3),
        'friend_accept': True,
        'post_wall': 1,
        'join': (1, 2),
        'comment': 0,
        'allow_link': False,
    },
    'daily_morning': {  # Giữ trust
        'scroll_min': (2, 2),
        'like': (3, 3),
        'comment_friend': (1, 2),
        'friend_accept': True,
        'join': 0,
        'comment_group': 0,
        'post': 0,
    },
    'daily_noon': {  # Group + tường
        'join': (2, 3),
        'post_wall': 1,
        'allow_link': True,
        'group_browse_like': (1, 2),
        'comment_group': 0,
    },
    'daily_evening': {  # Comment group
        'comment_group': (3, 5),
        'join': 0,
        'post': 0,
    },
    'day_limits': {
        'comment_group': (5, 10),
        'join': (3, 5),
        'friend': (5, 10),
    },
}

DEFAULT_SCENARIO_CFG = {
    'morning_start': 8,
    'morning_end': 10,
    'noon_start': 12,
    'noon_end': 14,
    'evening_start': 19,
    'evening_end': 22,
    'keywords': 'cộng đồng, mua bán, chia sẻ',
    'status_spin': '{Chào ngày mới mọi người|Hôm nay trời đẹp quá|Một ngày thật vui|Chúc mọi người vui vẻ nha}',
    'friend_comment_spin': '{Tuyệt vời|Đẹp quá|Chúc ngày mới vui vẻ nha|Hay quá bạn ơi|Like mạnh}',
    'group_comment_spin': (
        '{Sản phẩm này ổn không mọi người?|Mình thấy hay, ai dùng chưa ạ?|'
        'Tham khảo thêm tại đây nha|Ai cần inbox mình nhé|Bài hay quá, lưu lại học hỏi}'
    ),
    'promo_link': '',
    'auto_detect_shift': True,
}

TASK_LABELS = {
    'LOGIN': 'Đăng nhập / Session',
    'NURTURE': 'Nuôi nick',
    'JOIN_GROUP': 'Join nhóm',
    'COMMENT_GROUP': 'Comment group',
    'GROUP_BROWSE': 'Lướt / Like group',
    'GROUP_LEAVE': 'Rời group',
    'GROUP_INVITE': 'Mời bạn vào group',
    'FRIEND_ACCEPT': 'Accept kết bạn',
    'FRIEND_SUGGEST': 'Kết bạn gợi ý',
    'FRIEND_SEARCH': 'Kết bạn theo tên/UID',
    'FRIEND_BIRTHDAY': 'Chúc sinh nhật',
    'FRIEND_POKE': 'Poke bạn bè',
    'FRIEND_WALL': 'Like/Comment tường bạn',
    'PAGE_LIKE': 'Like / Follow fanpage',
    'PAGE_INTERACT': 'Tương tác fanpage',
    'POST': 'Đăng bài',
    'POST_AI': 'Đăng bài AI',
    'UPDATE_PROFILE': 'Đổi profile FB',
    'SCENARIO': 'Kịch bản tuần tự',
    'CHECK_LIVE': 'Check live Facebook',
    'CHECK_PROXY': 'Check live Proxy',
    'SCAN_ASSIGN_PROXY': 'Quét & gán proxy',
}

# Mẫu chúc sinh nhật (spin)
DEFAULT_BIRTHDAY_SPIN = (
    '{Chúc mừng sinh nhật|Happy birthday|Sinh nhật vui vẻ} '
    '{bạn nhé|bạn ơi|nha}! '
    '{Chúc bạn luôn vui vẻ|Chúc sức khỏe|Chúc thành công|Chúc may mắn} '
    '{và hạnh phúc|mọi điều tốt lành|trọn vẹn} 🎂🎉'
)

# Profile trình duyệt mobile — nhẹ máy, cửa sổ gọn, giả lập điện thoại
MOBILE_BROWSER_PROFILES = {
    'iphone_se': {
        'label': 'iPhone SE (375×667) — nhẹ nhất',
        'viewport': {'width': 375, 'height': 667},
        'window': (390, 720),
        'device_scale_factor': 2,
        'is_mobile': True,
        'has_touch': True,
        'user_agent': (
            'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) '
            'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 '
            'Mobile/15E148 Safari/604.1'
        ),
    },
    'iphone_12': {
        'label': 'iPhone 12 (390×844)',
        'viewport': {'width': 390, 'height': 844},
        'window': (410, 900),
        'device_scale_factor': 3,
        'is_mobile': True,
        'has_touch': True,
        'user_agent': (
            'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) '
            'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 '
            'Mobile/15E148 Safari/604.1'
        ),
    },
    'pixel_5': {
        'label': 'Pixel 5 (393×851)',
        'viewport': {'width': 393, 'height': 851},
        'window': (410, 900),
        'device_scale_factor': 2.75,
        'is_mobile': True,
        'has_touch': True,
        'user_agent': (
            'Mozilla/5.0 (Linux; Android 13; Pixel 5) AppleWebKit/537.36 '
            '(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
        ),
    },
    'galaxy_s8': {
        'label': 'Galaxy S8 (360×740) — gọn',
        'viewport': {'width': 360, 'height': 740},
        'window': (380, 800),
        'device_scale_factor': 3,
        'is_mobile': True,
        'has_touch': True,
        'user_agent': (
            'Mozilla/5.0 (Linux; Android 12; SM-G950F) AppleWebKit/537.36 '
            '(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
        ),
    },
    'mobile_mini': {
        'label': 'Mini 320×568 — tối giản',
        'viewport': {'width': 320, 'height': 568},
        'window': (340, 620),
        'device_scale_factor': 2,
        'is_mobile': True,
        'has_touch': True,
        'user_agent': (
            'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) '
            'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 '
            'Mobile/15E148 Safari/604.1'
        ),
    },
}


class JobStopped(Exception):
    """Ném khi user bấm Dừng toàn bộ — thoát vòng lặp automation an toàn."""
    pass


# ------------------------------------------------------------------
# UI Themes 2026 — dark glass + light clean
# ------------------------------------------------------------------
UI_FONTS = {
    'font_ui': ('Segoe UI', 10),
    'font_ui_bold': ('Segoe UI', 10, 'bold'),
    'font_title': ('Segoe UI', 16, 'bold'),
    'font_sub': ('Segoe UI', 9),
    'font_mono': ('Consolas', 9),
    'font_tab': ('Segoe UI', 9, 'bold'),
}

UI_DARK = {
    'bg': '#0f1419',
    'surface': '#161d27',
    'surface2': '#1c2430',
    'card': '#1a222e',
    'border': '#2a3544',
    'text': '#e8edf4',
    'muted': '#8b9bb4',
    'accent': '#6366f1',
    'accent_hover': '#818cf8',
    'accent2': '#22d3ee',
    'success': '#22c55e',
    'warning': '#f59e0b',
    'danger': '#ef4444',
    'danger_hover': '#dc2626',
    'input_bg': '#0c1017',
    'select': '#312e81',
    'tree_alt': '#131a24',
    'header_grad_top': '#121826',
    'log_fg': '#22c55e',
    'log_bg': '#0a0e14',
    **UI_FONTS,
}

UI_LIGHT = {
    'bg': '#eef2f7',
    'surface': '#ffffff',
    'surface2': '#e2e8f0',
    'card': '#ffffff',
    'border': '#94a3b8',
    'text': '#0f172a',          # chữ chính: gần đen
    'muted': '#334155',         # muted đậm hơn (không xám nhạt)
    'accent': '#3730a3',        # indigo đậm — chữ trắng dễ đọc
    'accent_hover': '#312e81',
    'accent2': '#1d4ed8',
    'success': '#15803d',
    'warning': '#b45309',
    'danger': '#b91c1c',
    'danger_hover': '#991b1b',
    'input_bg': '#ffffff',
    'input_fg': '#0f172a',
    'select': '#c7d2fe',
    'tree_alt': '#f1f5f9',
    'header_grad_top': '#f8fafc',
    'log_fg': '#bbf7d0',
    'log_bg': '#0f172a',
    'btn_secondary_bg': '#cbd5e1',
    'btn_secondary_fg': '#0f172a',
    **UI_FONTS,
}

# runtime palette (mutated when theme changes)
UI = dict(UI_DARK)

try:
    from auth_system_v10 import AuthSystem, PLANS, HOTLINE, DEFAULT_TRIAL_DAYS
except ImportError:
    AuthSystem = None  # type: ignore
    PLANS = {}
    HOTLINE = '0379376177'
    DEFAULT_TRIAL_DAYS = 3


class FacebookAutomationAppV9:
    def __init__(self, root):
        self.root = root
        self.root.title("AI Maketing Facebook")
        self.root.geometry("1420x1040")
        self.root.minsize(1180, 820)

        # Auth system (ONLINE-ONLY)
        self.auth = AuthSystem(os.path.dirname(os.path.abspath(__file__))) if AuthSystem else None
        self.theme_mode = 'light'  # mặc định tone sáng
        self._heartbeat_stop = threading.Event()
        self._heartbeat_thread = None
        if self.auth:
            restored = self.auth.try_restore_session()
            if restored:
                self.theme_mode = self.auth.get_theme() or 'light'
            else:
                self.auth.clear_session()
                # user mới / chưa session: dùng light
                if not self.auth.settings.get('theme'):
                    self.auth.settings['theme'] = 'light'
        self._set_ui_palette(self.theme_mode)

        try:
            self.root.configure(bg=UI['bg'])
        except Exception:
            pass

        self._apply_modern_theme()

        # UI/thread safety trước khi load data (tránh SEGV Tk từ worker)
        self._ui_q = queue.Queue()
        self._ui_poller_on = False
        self._data_lock = threading.RLock()
        self._job_lock = threading.Lock()
        self._stop_event = threading.Event()  # dừng nhanh (check live / sleep)
        self._jobs_dirty = False
        self._jobs_refresh_pending = False
        self._log_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'activity.log')

        self.accounts = self.load_accounts()
        self.proxies = self.load_proxies()
        self.reports = self.load_reports()
        self.vn_names = self.load_vietnamese_names()
        self.daily_usage = self.load_daily_usage()
        self.scenario_progress = self.load_scenario_progress()
        self.scenario_cfg = self.load_scenario_config()
        self.is_running = False
        self.stop_requested = False
        self._job_seq = 0
        self.current_job = None          # job đang chạy (dict)
        self.job_queue = []              # các job chờ (list dict)
        self.job_items = []              # dòng chi tiết UI: running/waiting/done/cancelled
        self._active_browser = None      # playwright browser (legacy 1 browser)
        self._active_browsers = set()    # nhiều browser khi parallel
        self._active_browsers_lock = threading.Lock()
        self._active_loop = None         # asyncio loop của job Playwright (đóng browser đúng thread)
        # Song song: user chọn 1–20; cùng proxy tối đa 2
        self.var_parallel_workers = tk.StringVar(value='1')
        # Theo dõi tiến độ realtime (log + nhãn)
        self._progress = {
            'active': False,
            'task_type': '',
            'label': '',
            'total': 0,
            'done': 0,
            'started': 0.0,
            'nick_started': 0.0,
            'current_uid': '',
            'current_task': '',
            'task_i': 0,
            'task_n': 0,
            'est_total_sec': 0.0,
            'est_remaining_sec': 0.0,
            'samples': [],  # thời gian thực tế / nick
        }
        self._main_built = False
        self.post_image_path = tk.StringVar(value='')
        # Chế độ chạy trình duyệt: headless | mobile (hiện, cửa sổ nhỏ)
        self.var_headless = tk.BooleanVar(value=True)  # tương thích cũ
        self.var_run_mode = tk.StringVar(value='headless')  # headless | mobile
        self.var_mobile_profile = tk.StringVar(value='iphone_se')
        self.var_tile_windows = tk.BooleanVar(value=True)  # xếp ô cửa sổ mobile
        self.var_browser = tk.StringVar(value='chromium')  # chromium | firefox
        self.var_require_proxy = tk.BooleanVar(value=True)  # Bắt buộc proxy mới chạy
        self.var_join_mode = tk.StringVar(value='medium')
        self.var_comment_mode = tk.StringVar(value='medium')
        self.var_post_mode = tk.StringVar(value='medium')
        self.var_scenario_scope = tk.StringVar(value='all')  # all | selected | pending
        self.var_scenario_force_shift = tk.StringVar(value='auto')  # auto|morning|noon|evening|full
        self.var_theme = tk.StringVar(value=self.theme_mode)
        self.var_server_url = tk.StringVar(
            value=(self.auth.server_url if self.auth else 'http://127.0.0.1:5055')
        )
        self._sync_proxy_assignments()
        self._ensure_lifecycle_all()
        # Đưa nick checkpoint/die sẵn có → tab Acc chết + cách ly proxy
        try:
            self._migrate_existing_dead_pool()
        except Exception:
            pass

        # Gate: đăng nhập ONLINE trước khi vào app
        if self.auth and self.auth.token and self.auth.current_user and self.auth.last_heartbeat_ok:
            self._enter_main_app()
            self._start_heartbeat_loop()
            self.root.after(400, self._sync_cloud_after_login)
        else:
            self._show_auth_gate()

    def _set_ui_palette(self, mode: str):
        global UI
        mode = 'light' if mode == 'light' else 'dark'
        self.theme_mode = mode
        UI.clear()
        UI.update(UI_LIGHT if mode == 'light' else UI_DARK)

    def _load_app_settings_file(self):
        """Đọc app_settings_v10.json (theme, geo wizard, …)."""
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app_settings_v10.json')
        try:
            if os.path.exists(path):
                with open(path, 'r', encoding='utf-8') as f:
                    return json.load(f) or {}
        except Exception:
            pass
        return {}

    def _save_app_settings_file(self, updates: dict):
        path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app_settings_v10.json')
        data = self._load_app_settings_file()
        data.update(updates or {})
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(data, f, indent=2, ensure_ascii=False)
        except Exception as ex:
            try:
                self.log(f"Lỗi lưu settings: {ex}")
            except Exception:
                pass
        return data

    def _enter_main_app(self):
        """Vào app chính ngay sau đăng nhập (gợi ý proxy nằm TRONG app)."""
        if self._main_built:
            return
        self._main_built = True
        try:
            for w in self.root.winfo_children():
                w.destroy()
        except Exception:
            pass
        try:
            self.root.configure(bg=UI.get('bg', '#e2e8f0'))
        except Exception:
            pass
        self.create_widgets()
        self._refresh_user_header()
        # Gợi ý nhẹ 1 lần nếu nhiều nick thiếu proxy (không chặn vào app)
        try:
            self.root.after(800, self._maybe_hint_geo_proxy)
        except Exception:
            pass

    def _maybe_hint_geo_proxy(self):
        """Gợi ý mở công cụ định vị (không bắt buộc)."""
        try:
            st = self._load_app_settings_file()
            if st.get('geo_hint_dismissed'):
                return
            live = [a for a in self.accounts if not self._is_dead_pool(a)]
            if not live:
                return
            no_px = sum(1 for a in live if not self.account_has_proxy(a))
            if no_px < max(3, len(live) // 3):
                return
            if messagebox.askyesno(
                "Giảm checkpoint",
                f"Có {no_px}/{len(live)} nick sống chưa có proxy.\n\n"
                "Mở «Định vị nick + Gợi ý mua Proxy» để nhận dạng nước "
                "và chọn nhà cung cấp proxy cùng khu vực?",
            ):
                self.open_geo_proxy_wizard()
            else:
                self._save_app_settings_file({'geo_hint_dismissed': True})
        except Exception:
            pass

    def open_geo_proxy_wizard(self):
        """
        Trong app: định vị quốc gia nick (từ list hiện có hoặc dán nick mới)
        → gợi ý mua proxy khu vực → giảm checkpoint.
        """
        try:
            from geo_proxy_guide import show_geo_proxy_wizard
        except ImportError as ex:
            messagebox.showerror("Lỗi", f"Thiếu geo_proxy_guide.py: {ex}")
            return

        def get_selected():
            try:
                return [self.accounts[i] for i in self._selected_account_indices()]
            except Exception:
                return []

        def on_save(payload):
            self._save_app_settings_file(payload)
            # Áp country vào nick app nếu có
            updates = payload.get('account_country_updates') or {}
            if updates:
                for acc in self.accounts:
                    uid = str(acc.get('uid') or '')
                    if uid in updates:
                        acc['country'] = updates[uid].get('code')
                        acc['country_name'] = updates[uid].get('name')
                        acc['country_confidence'] = updates[uid].get('confidence')
            # Thêm nick mới từ wizard
            new_list = payload.get('new_accounts') or []
            added = 0
            if new_list:
                known = {str(a.get('uid') or '') for a in self.accounts}
                for na in new_list:
                    uid = str(na.get('uid') or '')
                    if not uid or uid in known:
                        continue
                    self.accounts.append(na)
                    known.add(uid)
                    added += 1
            try:
                self.save_accounts()
            except Exception:
                pass
            try:
                self.refresh_table()
                self.refresh_dead_table()
            except Exception:
                pass
            if added:
                self.log(f"🌍 Đã thêm {added} nick mới từ công cụ định vị proxy")
            if payload.get('preferred_country'):
                self.log(f"🌍 Quốc gia gợi ý proxy: {payload.get('preferred_country')}")

        show_geo_proxy_wizard(
            parent=self.root,
            accounts=self.accounts,
            get_selected_accounts=get_selected,
            on_continue=lambda: None,
            on_save_settings=on_save,
            settings=self._load_app_settings_file(),
            in_app=True,
        )

    # ------------------------------------------------------------------
    # Cloud sync: nick FB + proxy theo tài khoản user
    # ------------------------------------------------------------------
    def _local_data_is_empty(self) -> bool:
        """Máy mới / chưa có nick & proxy."""
        has_acc = bool(self.accounts)
        has_px = bool(self.proxies)
        return (not has_acc) and (not has_px)

    def _merge_accounts_from_cloud(self, cloud_list):
        """Chỉ thêm/cập nhật theo uid — không xóa nick local."""
        if not isinstance(cloud_list, list):
            return 0, 0
        by_uid = {}
        for i, a in enumerate(self.accounts):
            uid = str(a.get('uid') or '')
            if uid:
                by_uid[uid] = i
        added = updated = 0
        for ca in cloud_list:
            if not isinstance(ca, dict):
                continue
            uid = str(ca.get('uid') or '').strip()
            if not uid:
                continue
            if uid in by_uid:
                # cập nhật field còn thiếu từ cloud, không ghi đè cookie/proxy local nếu local có
                idx = by_uid[uid]
                local = self.accounts[idx]
                for k, v in ca.items():
                    if k in ('uid',):
                        continue
                    if v in (None, '', [], {}):
                        continue
                    if k in ('cookie', 'proxy', 'password', 'two_fa') and local.get(k):
                        continue  # giữ secret local nếu đã có
                    if k == 'joined_groups' and local.get('joined_groups'):
                        # gộp group
                        exist = {g.get('id') for g in local['joined_groups'] if isinstance(g, dict)}
                        for g in v:
                            if isinstance(g, dict) and g.get('id') not in exist:
                                local['joined_groups'].append(g)
                        continue
                    if not local.get(k):
                        local[k] = v
                updated += 1
            else:
                self.accounts.append(ca)
                by_uid[uid] = len(self.accounts) - 1
                added += 1
        return added, updated

    def _merge_proxies_from_cloud(self, cloud_list):
        """Chỉ thêm proxy mới theo key — không xóa proxy local."""
        if not isinstance(cloud_list, list):
            return 0
        existing = set()
        for p in self.proxies:
            k = self._normalize_proxy_key(p.get('proxy', '')) if hasattr(self, '_normalize_proxy_key') else (p.get('proxy') or '')
            if k:
                existing.add(k)
        added = 0
        for cp in cloud_list:
            if not isinstance(cp, dict):
                continue
            raw = (cp.get('proxy') or '').strip()
            if not raw:
                continue
            k = self._normalize_proxy_key(raw) if hasattr(self, '_normalize_proxy_key') else raw
            if k in existing:
                continue
            item = {
                'proxy': raw,
                'status': cp.get('status') or 'Chưa check',
                'expire_date': cp.get('expire_date') or '',
                'note': cp.get('note') or '',
                'last_check': cp.get('last_check') or '',
            }
            self.proxies.append(item)
            existing.add(k)
            added += 1
        return added

    def _sync_cloud_after_login(self):
        """
        Sau login:
        - Máy trống → tự tải full cloud
        - Máy có data → hỏi user; nếu có thì merge (chỉ thêm/cập nhật, không xóa local)
        Sau đó push merge lên cloud để backup.
        """
        if not self.auth or not self.auth.token:
            return
        try:
            ok, data = self.auth.sync_pull()
        except Exception as e:
            self.log(f"[SYNC] Lỗi pull: {e}")
            return
        if not ok:
            err = (data or {}).get('error') or 'Pull thất bại'
            self.log(f"[SYNC] {err}")
            return

        cloud_acc = data.get('accounts') or []
        cloud_px = data.get('proxies') or []
        meta = data.get('meta') or {}
        n_cloud = int(meta.get('accounts_count') or len(cloud_acc)) + int(meta.get('proxies_count') or len(cloud_px))

        if n_cloud == 0:
            # Cloud trống — đẩy local lên (backup lần đầu)
            self._cloud_push_now(mode='replace', silent=True)
            self.log("[SYNC] Cloud trống — đã backup dữ liệu máy này lên tài khoản.")
            return

        do_merge = False
        if self._local_data_is_empty():
            do_merge = True
            self.log("[SYNC] Máy trống — tự tải nick/proxy từ cloud…")
        else:
            do_merge = messagebox.askyesno(
                "Đồng bộ dữ liệu cloud",
                "Tài khoản của bạn có dữ liệu nick/proxy trên cloud.\n\n"
                f"Cloud: ~{meta.get('accounts_count', 0)} nick, ~{meta.get('proxies_count', 0)} proxy.\n"
                f"Máy này: {len(self.accounts)} nick, {len(self.proxies)} proxy.\n\n"
                "Tải về và CHỈ THÊM cái mới (không xóa nick/proxy đang có trên máy)?",
            )

        if not do_merge:
            # vẫn backup local lên cloud (merge) để không mất data máy
            self._cloud_push_now(mode='merge', silent=True)
            self.log("[SYNC] Bỏ qua tải cloud — đã merge-push local lên server.")
            return

        a_add, a_upd = self._merge_accounts_from_cloud(cloud_acc)
        p_add = self._merge_proxies_from_cloud(cloud_px)
        # scenario progress: merge dict keys
        sc = data.get('scenario_progress')
        if isinstance(sc, list) and sc and isinstance(sc[0], dict):
            cloud_sc = sc[0]
            if not self.scenario_progress:
                self.scenario_progress = cloud_sc
            elif isinstance(self.scenario_progress, dict):
                for k, v in cloud_sc.items():
                    if k not in self.scenario_progress:
                        self.scenario_progress[k] = v
            try:
                self.save_scenario_progress()
            except Exception:
                pass

        self.save_accounts()
        self.save_proxies()
        self._sync_proxy_assignments()
        try:
            self.refresh_table()
            self.refresh_proxy_table()
        except Exception:
            pass

        # push full local (sau merge) lên cloud — replace snapshot hiện tại
        self._cloud_push_now(mode='replace', silent=True)
        msg = f"Đã đồng bộ: +{a_add} nick mới, cập nhật {a_upd} nick, +{p_add} proxy. Không xóa dữ liệu cũ trên máy."
        self.log(f"[SYNC] {msg}")
        try:
            messagebox.showinfo("Đồng bộ cloud", msg)
        except Exception:
            pass

    def _safe_after(self, ms, fn):
        """
        root.after an toàn đa luồng — CHỈ main thread gọi Tk.
        Worker → đẩy vào UI queue (tránh SIGBUS/SEGV Tkapp_Call).
        """
        if fn is None:
            return None
        try:
            if threading.current_thread() is threading.main_thread():
                return self.root.after(int(ms), fn)
        except Exception:
            pass
        # từ worker: schedule poller sẽ chạy; bọc delay bằng after trên main
        def _on_main():
            try:
                self.root.after(int(ms), fn)
            except Exception:
                try:
                    fn()
                except Exception:
                    pass
        try:
            self._ui_q.put(('call', _on_main, (), {}))
        except Exception:
            pass
        return None

    def _schedule_cloud_push(self):
        """Debounce push sau khi save local — thread-safe (worker login hay gọi save_accounts)."""
        if not self.auth or not getattr(self.auth, 'token', None):
            return
        # Worker không được đụng root.after / after_cancel
        if threading.current_thread() is not threading.main_thread():
            try:
                self._ui_q.put(('call', self._schedule_cloud_push, (), {}))
            except Exception:
                pass
            return
        if getattr(self, '_push_after_id', None):
            try:
                self.root.after_cancel(self._push_after_id)
            except Exception:
                pass
        try:
            self._push_after_id = self.root.after(2500, lambda: self._cloud_push_now(mode='replace', silent=True))
        except Exception:
            self._push_after_id = None

    def _cloud_push_now(self, mode='replace', silent=True):
        if not self.auth or not getattr(self.auth, 'token', None):
            return
        try:
            # scenario: store as dict
            sc = self.scenario_progress if isinstance(self.scenario_progress, dict) else {}
            ok, data = self.auth.sync_push(
                accounts=self.accounts,
                proxies=self.proxies,
                scenario_progress=sc,
                mode=mode,
            )
            if ok:
                if not silent:
                    self.log(f"[SYNC] Đã đẩy cloud ({mode}): {data.get('meta')}")
            else:
                self.log(f"[SYNC] Push lỗi: {(data or {}).get('error')}")
        except Exception as e:
            self.log(f"[SYNC] Push exception: {e}")

    # ------------------------------------------------------------------
    # Auth gate (login / register)
    # ------------------------------------------------------------------
    def _show_auth_gate(self):
        """Màn hình đăng nhập — tone sáng, tương phản cao (macOS-safe)."""
        self._stop_heartbeat_loop()
        for w in self.root.winfo_children():
            w.destroy()
        self._set_ui_palette('light')
        # Màu hardcode — không phụ thuộc system dark mode
        BG = '#e2e8f0'
        CARD = '#ffffff'
        TEXT = '#0f172a'
        MUTED = '#1e293b'
        BORDER = '#475569'
        ERR = '#b91c1c'
        OKC = '#15803d'

        self.root.configure(bg=BG)
        self._apply_modern_theme()

        wrap = tk.Frame(self.root, bg=BG)
        wrap.pack(expand=True, fill='both')

        card = tk.Frame(wrap, bg=CARD, highlightbackground=BORDER, highlightthickness=2)
        card.place(relx=0.5, rely=0.5, anchor='center', width=500, height=540)

        tk.Label(
            card, text="AI Maketing Facebook",
            font=('Helvetica', 20, 'bold'), bg=CARD, fg=TEXT,
        ).pack(pady=(32, 4))
        tk.Label(
            card, text="Online · 1 máy / 1 tài khoản · Zalo " + HOTLINE,
            font=('Helvetica', 11), bg=CARD, fg=MUTED,
        ).pack(pady=(0, 18))

        form = tk.Frame(card, bg=CARD)
        form.pack(fill='x', padx=40)

        def _field(label, show=None):
            tk.Label(form, text=label, bg=CARD, fg=MUTED, font=('Helvetica', 11, 'bold')).pack(
                anchor='w', pady=(10, 4),
            )
            e = self._mk_auth_entry(form, show=show)
            e.pack(fill='x', ipady=8)
            return e

        ent_user = _field("Tên đăng nhập hoặc Email")
        ent_pass = _field("Mật khẩu", show='•')

        msg = tk.Label(card, text="", bg=CARD, fg=ERR, font=('Helvetica', 11), wraplength=400, justify='left')
        msg.pack(pady=12, padx=24)

        def do_login():
            if not self.auth:
                msg.config(text="Thiếu auth_system_v10.py", fg=ERR)
                return
            try:
                from auth_system_v10 import DEFAULT_SERVER
                self.auth.set_server_url(DEFAULT_SERVER)
            except Exception:
                pass
            msg.config(text="Đang đăng nhập & đồng bộ dữ liệu…", fg=MUTED)
            self.root.update_idletasks()
            ok, text = self.auth.login(ent_user.get().strip(), ent_pass.get())
            if not ok:
                msg.config(text=text, fg=ERR)
                return
            self.theme_mode = self.auth.get_theme() or 'light'
            self.var_theme.set(self.theme_mode)
            self._set_ui_palette(self.theme_mode)
            msg.config(text=text, fg=OKC)
            for w in self.root.winfo_children():
                w.destroy()
            self._apply_modern_theme()
            self.root.configure(bg=UI['bg'])
            self._enter_main_app()
            self._start_heartbeat_loop()
            self.root.after(200, self._sync_cloud_after_login)

        def open_register():
            self._show_register_dialog(parent=self.root)

        btn_row = tk.Frame(card, bg=CARD)
        btn_row.pack(fill='x', padx=40, pady=6)
        self._mk_solid_btn(btn_row, "ĐĂNG NHẬP", do_login, kind='accent', fill=True, pady=14)
        self._mk_solid_btn(btn_row, "ĐĂNG KÝ TÀI KHOẢN MỚI  (dùng thử 3 ngày)", open_register, kind='secondary', fill=True, pady=12)

        tk.Label(
            card,
            text="Không kết nối server = không dùng được.\n1 tài khoản chỉ đăng nhập 1 máy tại 1 thời điểm.",
            bg=CARD, fg=MUTED, font=('Helvetica', 10), justify='center',
        ).pack(side='bottom', pady=16)

        ent_pass.bind('<Return>', lambda e: do_login())
        ent_user.focus_set()

    def _show_register_dialog(self, parent=None):
        """Form đăng ký tone sáng — nút Đăng ký luôn hiện ở đáy."""
        dlg = tk.Toplevel(parent or self.root)
        dlg.title("Đăng ký — AI Maketing Facebook")
        dlg.geometry("460x640")
        dlg.minsize(420, 560)
        # đảm bảo palette sáng khi mở form
        if self.theme_mode != 'light':
            self._set_ui_palette('light')
            self._apply_modern_theme()
        bg = UI['bg']
        card_bg = UI['surface']
        text = UI['text']
        muted = UI['muted']
        border = UI['border']
        dlg.configure(bg=bg)
        dlg.transient(self.root)
        dlg.grab_set()

        # --- Header ---
        head = tk.Frame(dlg, bg=bg)
        head.pack(fill='x', padx=24, pady=(16, 4))
        tk.Label(
            head, text="Đăng ký tài khoản",
            font=('Segoe UI', 16, 'bold'), bg=bg, fg=text,
        ).pack(anchor='w')
        tk.Label(
            head,
            text=f"Dùng thử {DEFAULT_TRIAL_DAYS} ngày · Online · Zalo {HOTLINE}",
            font=('Segoe UI', 9), bg=bg, fg=muted,
        ).pack(anchor='w', pady=(2, 0))

        # --- Footer trước (pack bottom) để nút luôn thấy ---
        foot = tk.Frame(dlg, bg=card_bg, highlightthickness=1, highlightbackground=border)
        foot.pack(side='bottom', fill='x')
        msg = tk.Label(
            foot, text="", bg=card_bg, fg=UI['danger'],
            font=('Segoe UI', 9), wraplength=400, justify='left',
        )
        msg.pack(fill='x', padx=20, pady=(10, 4))
        btn_box = tk.Frame(foot, bg=card_bg)
        btn_box.pack(fill='x', padx=20, pady=(0, 14))

        # --- Form giữa (có thể cuộn) ---
        mid = tk.Frame(dlg, bg=bg)
        mid.pack(fill='both', expand=True, padx=4, pady=4)

        canvas = tk.Canvas(mid, bg=bg, highlightthickness=0, bd=0)
        scroll = ttk.Scrollbar(mid, orient='vertical', command=canvas.yview)
        form = tk.Frame(canvas, bg=bg)
        form.bind(
            '<Configure>',
            lambda e: canvas.configure(scrollregion=canvas.bbox('all')),
        )
        win_id = canvas.create_window((0, 0), window=form, anchor='nw')
        canvas.configure(yscrollcommand=scroll.set)

        def _on_canvas_cfg(e):
            canvas.itemconfigure(win_id, width=e.width)

        canvas.bind('<Configure>', _on_canvas_cfg)
        canvas.pack(side='left', fill='both', expand=True, padx=(20, 0))
        scroll.pack(side='right', fill='y', padx=(0, 8))

        def _wheel(e):
            canvas.yview_scroll(int(-1 * (e.delta / 120)), 'units')

        canvas.bind_all('<MouseWheel>', _wheel)

        def field(label, show=None):
            tk.Label(form, text=label, bg=bg, fg='#1e293b', font=('Helvetica', 11, 'bold')).pack(
                anchor='w', pady=(10, 2), padx=8,
            )
            # tk.Entry hardcode — tránh chữ trắng xoá trên macOS
            e = tk.Entry(
                form,
                font=('Helvetica', 13),
                bg='#ffffff',
                fg='#000000',
                insertbackground='#000000',
                selectbackground='#312e81',
                selectforeground='#ffffff',
                relief='solid',
                borderwidth=1,
                highlightthickness=2,
                highlightbackground='#64748b',
                highlightcolor='#312e81',
            )
            if show:
                e.configure(show=show)
            e.pack(fill='x', ipady=8, padx=8)
            return e

        e_user = field("Tên đăng nhập *")
        e_pass = field("Mật khẩu * (tối thiểu 6 ký tự)", show='•')
        e_pass2 = field("Nhập lại mật khẩu *", show='•')
        e_name = field("Họ tên")
        e_email = field("Email (nhận chào mừng / xác thực)")
        e_phone = field("Số điện thoại / Zalo")

        tk.Label(
            form,
            text="Nick FB & proxy sẽ được lưu trên cloud theo tài khoản của bạn.",
            bg=bg, fg=muted, font=('Segoe UI', 8),
        ).pack(anchor='w', padx=8, pady=(12, 20))

        def do_reg_and_enter():
            ERR_FG = '#b91c1c'
            MUTED_FG = '#334155'
            if not (e_user.get() or '').strip():
                msg.config(text="Vui lòng nhập tên đăng nhập.", fg=ERR_FG)
                return
            if e_pass.get() != e_pass2.get():
                msg.config(text="Mật khẩu nhập lại không khớp.", fg=ERR_FG)
                return
            if len(e_pass.get() or '') < 6:
                msg.config(text="Mật khẩu tối thiểu 6 ký tự.", fg=ERR_FG)
                return
            if not self.auth:
                msg.config(text="Thiếu module auth.", fg=ERR_FG)
                return
            try:
                from auth_system_v10 import DEFAULT_SERVER
                self.auth.set_server_url(DEFAULT_SERVER)
            except Exception:
                pass
            msg.config(text="Đang đăng ký trên server…", fg=MUTED_FG)
            dlg.update_idletasks()
            ok, text = self.auth.register(
                username=e_user.get(),
                password=e_pass.get(),
                email=e_email.get(),
                phone=e_phone.get(),
                full_name=e_name.get(),
            )
            if not ok:
                msg.config(text=text, fg=ERR_FG)
                return
            ok2, text2 = self.auth.login(e_user.get().strip(), e_pass.get())
            if not ok2:
                msg.config(text=f"{text}\n{text2}", fg=ERR_FG)
                return
            try:
                canvas.unbind_all('<MouseWheel>')
            except Exception:
                pass
            self.theme_mode = self.auth.get_theme()
            self.var_theme.set(self.theme_mode)
            self._set_ui_palette(self.theme_mode)
            dlg.destroy()
            for w in self.root.winfo_children():
                w.destroy()
            self._apply_modern_theme()
            self.root.configure(bg=UI['bg'])
            self._enter_main_app()
            self._start_heartbeat_loop()
            self.root.after(200, self._sync_cloud_after_login)

        def do_cancel():
            try:
                canvas.unbind_all('<MouseWheel>')
            except Exception:
                pass
            dlg.destroy()

        # Nút ĐĂNG KÝ rõ ràng — luôn ở đáy
        self._mk_solid_btn(
            btn_box,
            "✓  ĐĂNG KÝ TÀI KHOẢN  (dùng thử 3 ngày)",
            do_reg_and_enter,
            kind='accent',
            fill=True,
            pady=12,
        )
        self._mk_solid_btn(
            btn_box,
            "Hủy",
            do_cancel,
            kind='secondary',
            fill=True,
            pady=8,
        )

        e_user.focus_set()
        dlg.protocol('WM_DELETE_WINDOW', do_cancel)

    def _start_heartbeat_loop(self):
        """Heartbeat online — mất mạng / bị đá máy khác → khóa app."""
        self._stop_heartbeat_loop()
        self._heartbeat_stop = threading.Event()

        def loop():
            while not self._heartbeat_stop.is_set():
                if not self.auth or not self.auth.token:
                    break
                ok, err = self.auth.heartbeat()
                if not ok:
                    def lockout(msg=err):
                        try:
                            messagebox.showerror(
                                "Mất kết nối / Session",
                                f"{msg}\n\nApp sẽ quay về màn đăng nhập.\n"
                                f"(1 tài khoản chỉ 1 máy · bắt buộc online)\nZalo: {HOTLINE}",
                            )
                        except Exception:
                            pass
                        self._main_built = False
                        self._stop_heartbeat_loop()
                        if self.auth:
                            self.auth.clear_session()
                        for w in self.root.winfo_children():
                            w.destroy()
                        self._show_auth_gate()
                    try:
                        self._ui_call(lockout)
                    except Exception:
                        try:
                            self._safe_after(0, lockout)
                        except Exception:
                            pass
                    break
                else:
                    try:
                        self._ui_call(self._refresh_user_header)
                        self._ui_call(self._refresh_license_label)
                    except Exception:
                        pass
                self._heartbeat_stop.wait(45)

        self._heartbeat_thread = threading.Thread(target=loop, daemon=True, name='license-heartbeat')
        self._heartbeat_thread.start()

    def _stop_heartbeat_loop(self):
        try:
            self._heartbeat_stop.set()
        except Exception:
            pass
        self._heartbeat_thread = None

    def _refresh_user_header(self):
        if not hasattr(self, 'lbl_user_bar'):
            return
        if not self.auth or not self.auth.current_user:
            self.lbl_user_bar.config(text="Chưa đăng nhập")
            return
        v = self.auth.public_user_view()
        st = "✓ Còn hạn" if v.get('active') else "✗ Hết hạn"
        self.lbl_user_bar.config(
            text=f"{v.get('full_name') or v.get('username')}  ·  {v.get('plan_label')}  ·  {st} ({v.get('days_left')} ngày)  ·  ☎ {HOTLINE}"
        )

    def logout_user(self):
        if self.is_running:
            messagebox.showwarning("Cảnh báo", "Hãy dừng job đang chạy trước khi đăng xuất.")
            return
        self._stop_heartbeat_loop()
        if self.auth:
            self.auth.logout()
        self._main_built = False
        for w in self.root.winfo_children():
            w.destroy()
        self._show_auth_gate()

    def toggle_theme(self):
        new_mode = 'light' if self.theme_mode != 'light' else 'dark'
        self.apply_theme(new_mode)

    def apply_theme(self, mode: str):
        mode = 'light' if mode == 'light' else 'dark'
        self._set_ui_palette(mode)
        self.var_theme.set(mode)
        if self.auth:
            self.auth.set_theme(mode)
        # rebuild UI to apply tk widget colors
        if self._main_built:
            geom = self.root.geometry()
            for w in self.root.winfo_children():
                w.destroy()
            self._main_built = False
            self._apply_modern_theme()
            self.root.configure(bg=UI['bg'])
            self._enter_main_app()
            try:
                self.root.geometry(geom)
            except Exception:
                pass
        else:
            self._apply_modern_theme()
            self.root.configure(bg=UI['bg'])

    def _require_license(self, action_label='thao tác'):
        """Chặn khi offline / hết hạn / session bị đá / chữ ký sai (chống crack)."""
        if not self.auth:
            messagebox.showerror("License", "Thiếu module auth.")
            return False
        if not self.auth.token or not self.auth.current_user:
            messagebox.showwarning("Đăng nhập", "Vui lòng đăng nhập online để sử dụng.")
            return False
        # luôn kiểm tra server realtime + HMAC chữ ký
        ok, err = self.auth.heartbeat()
        if not ok:
            messagebox.showerror(
                "Online bắt buộc",
                f"Không thể {action_label}.\n\n{err}\n\n"
                f"App yêu cầu kết nối server license chính thức.\n"
                f"1 tài khoản chỉ 1 máy. Zalo: {HOTLINE}",
            )
            return False
        # lớp 2: không tin cậy chỉ field active local
        if not getattr(self.auth, 'last_sig_ok', False):
            messagebox.showerror(
                "Bảo vệ bản quyền",
                f"Không thể {action_label}.\nChữ ký license không hợp lệ (chống crack).\nZalo: {HOTLINE}",
            )
            return False
        if self.auth.current_user.get('active') and getattr(self.auth, 'last_heartbeat_ok', False):
            return True
        messagebox.showwarning(
            "Gói hết hạn",
            f"Không thể {action_label} — gói đã hết hạn hoặc bị khóa.\n"
            f"Admin web gia hạn hoặc Zalo {HOTLINE}",
        )
        return False

    def _license_gate_ok(self) -> bool:
        """Gate im lặng cho job runner — tránh bypass chỉ 1 hàm UI."""
        try:
            if not self.auth or not self.auth.token:
                return False
            if getattr(self.auth, 'quick_license_ok', None) and self.auth.quick_license_ok():
                return True
            ok, _ = self.auth.heartbeat()
            return bool(ok and getattr(self.auth, 'last_sig_ok', False) and (self.auth.current_user or {}).get('active'))
        except Exception:
            return False

    # ------------------------------------------------------------------
    # Modern UI theme (2026)
    # ------------------------------------------------------------------
    def _apply_modern_theme(self):
        """Áp dụng giao diện tối hiện đại cho ttk + root."""
        style = ttk.Style()
        try:
            style.theme_use('clam')
        except Exception:
            pass

        bg = UI['bg']
        surface = UI['surface']
        surface2 = UI['surface2']
        card = UI['card']
        border = UI['border']
        text = UI['text']
        muted = UI['muted']
        accent = UI['accent']
        select = UI['select']
        danger = UI['danger']

        style.configure('.', background=bg, foreground=text, fieldbackground=UI['input_bg'],
                        bordercolor=border, darkcolor=surface, lightcolor=surface2,
                        troughcolor=surface2, focuscolor=accent,
                        font=UI['font_ui'])
        style.configure('TFrame', background=bg)
        style.configure('Card.TFrame', background=card)
        style.configure('Surface.TFrame', background=surface)
        style.configure('TLabel', background=bg, foreground=text, font=UI['font_ui'])
        style.configure('Muted.TLabel', background=bg, foreground=muted, font=UI['font_sub'])
        style.configure('Title.TLabel', background=bg, foreground=text, font=UI['font_title'])
        style.configure('Accent.TLabel', background=bg, foreground=accent, font=UI['font_ui_bold'])
        style.configure('Success.TLabel', background=bg, foreground=UI['success'], font=UI['font_ui_bold'])
        style.configure('Warning.TLabel', background=bg, foreground=UI['warning'], font=UI['font_ui_bold'])
        style.configure('Danger.TLabel', background=bg, foreground=danger, font=UI['font_ui_bold'])
        # Status: light mode dùng chữ đậm trên nền xám nhạt
        status_fg = '#1e3a8a' if self.theme_mode == 'light' else UI['accent2']
        style.configure('Status.TLabel', background=surface2 if self.theme_mode == 'light' else surface,
                        foreground=status_fg, font=UI['font_ui_bold'])

        # ---- Buttons: ép tương phản (tránh chữ trắng / nền trắng trên macOS clam) ----
        is_light = self.theme_mode == 'light'
        # Nút thường: nền xám RÕ + chữ ĐEN (light)
        btn_bg = '#94a3b8' if is_light else '#2a3548'
        btn_fg = '#0f172a' if is_light else '#f1f5f9'
        btn_bg_hover = '#64748b' if is_light else '#3b4a63'
        btn_border = '#475569' if is_light else '#3d4f66'
        white = '#ffffff'
        accent_hover = UI['accent_hover']
        danger_hover = UI['danger_hover']

        # Nút mặc định: chữ luôn tối/sáng tương phản, KHÔNG để trắng trên nền sáng
        style.configure(
            'TButton',
            background=btn_bg,
            foreground=btn_fg,
            bordercolor=btn_border,
            darkcolor=btn_bg,
            lightcolor=btn_bg,
            focuscolor=accent,
            borderwidth=1,
            relief='raised',
            padding=(12, 8),
            font=UI['font_ui'],
        )
        style.map(
            'TButton',
            background=[
                ('disabled', surface2),
                ('pressed', accent),
                ('active', btn_bg_hover),
                ('!disabled', btn_bg),
            ],
            foreground=[
                ('disabled', muted),
                ('pressed', white),           # khi nhấn: nền accent + chữ trắng
                ('active', btn_fg),           # hover: giữ chữ tối/sáng rõ
                ('!disabled', btn_fg),
            ],
            bordercolor=[
                ('disabled', border),
                ('pressed', accent),
                ('active', btn_border),
                ('!disabled', btn_border),
            ],
            lightcolor=[('!disabled', btn_bg), ('active', btn_bg_hover), ('pressed', accent)],
            darkcolor=[('!disabled', btn_bg), ('active', btn_bg_hover), ('pressed', accent)],
            relief=[('pressed', 'sunken'), ('!pressed', 'raised')],
        )

        # Nút chính — nền TÍM/XANH ĐẬM + chữ TRẮNG cứng (tránh macOS trắng xoá)
        accent_solid = '#1e3a8a' if is_light else accent
        accent_press = '#172554' if is_light else '#4338ca'
        white_hard = '#ffffff'
        style.configure(
            'Accent.TButton',
            background=accent_solid,
            foreground=white_hard,
            bordercolor=accent_press,
            darkcolor=accent_solid,
            lightcolor=accent_solid,
            focuscolor=accent_press,
            borderwidth=2,
            relief='raised',
            padding=(14, 9),
            font=UI['font_ui_bold'],
        )
        style.map(
            'Accent.TButton',
            background=[
                ('disabled', '#64748b'),
                ('pressed', accent_press),
                ('active', accent_press),
                ('!disabled', accent_solid),
            ],
            # Ép MỌI state chữ trắng — không để system đổi thành trắng trên trắng
            foreground=[
                ('disabled', '#f1f5f9'),
                ('pressed', white_hard),
                ('active', white_hard),
                ('focus', white_hard),
                ('!disabled', white_hard),
            ],
            bordercolor=[
                ('disabled', border),
                ('!disabled', accent_press),
            ],
            lightcolor=[('!disabled', accent_solid), ('active', accent_press), ('pressed', accent_press)],
            darkcolor=[('!disabled', accent_solid), ('active', accent_press), ('pressed', accent_press)],
            relief=[('pressed', 'sunken'), ('!pressed', 'raised')],
        )
        # Green primary (nếu dùng sau)
        style.configure(
            'Success.TButton',
            background='#15803d',
            foreground=white_hard,
            bordercolor='#14532d',
            darkcolor='#15803d',
            lightcolor='#15803d',
            padding=(12, 8),
            font=UI['font_ui_bold'],
        )
        style.map(
            'Success.TButton',
            background=[('disabled', '#94a3b8'), ('active', '#166534'), ('!disabled', '#15803d')],
            foreground=[('disabled', '#f1f5f9'), ('!disabled', white_hard), ('active', white_hard)],
        )

        # Nút nguy hiểm (đỏ) — chữ trắng cố định
        style.configure(
            'Danger.TButton',
            background=danger,
            foreground=white,
            bordercolor=danger,
            darkcolor=danger,
            lightcolor=danger,
            focuscolor=danger_hover,
            borderwidth=1,
            relief='raised',
            padding=(14, 9),
            font=UI['font_ui_bold'],
        )
        style.map(
            'Danger.TButton',
            background=[
                ('disabled', surface2),
                ('pressed', '#b91c1c'),
                ('active', danger_hover),
                ('!disabled', danger),
            ],
            foreground=[
                ('disabled', muted),
                ('pressed', white),
                ('active', white),
                ('!disabled', white),
            ],
            bordercolor=[
                ('disabled', border),
                ('!disabled', danger),
            ],
            lightcolor=[('!disabled', danger), ('active', danger_hover), ('pressed', '#b91c1c')],
            darkcolor=[('!disabled', danger), ('active', danger_hover), ('pressed', '#b91c1c')],
            relief=[('pressed', 'sunken'), ('!pressed', 'raised')],
        )

        style.configure(
            'Ghost.TButton',
            background=surface,
            foreground=btn_fg,
            bordercolor=btn_border,
            darkcolor=surface,
            lightcolor=surface,
            borderwidth=1,
            relief='raised',
            padding=(10, 7),
            font=UI['font_ui'],
        )
        style.map(
            'Ghost.TButton',
            background=[('disabled', surface2), ('active', btn_bg), ('pressed', btn_bg_hover), ('!disabled', surface)],
            foreground=[('disabled', muted), ('!disabled', btn_fg), ('active', btn_fg), ('pressed', btn_fg)],
            bordercolor=[('!disabled', btn_border)],
        )

        style.configure('TCheckbutton', background=bg, foreground=text, font=UI['font_ui'],
                        focuscolor=accent, indicatorcolor=surface2)
        style.map('TCheckbutton',
                  background=[('active', bg)],
                  foreground=[('active', text), ('!disabled', text)],
                  indicatorcolor=[('selected', accent), ('!selected', surface2)])

        style.configure('TRadiobutton', background=bg, foreground=text, font=UI['font_ui'],
                        focuscolor=accent, indicatorcolor=surface2)
        style.map('TRadiobutton',
                  background=[('active', bg)],
                  foreground=[('active', text), ('!disabled', text)],
                  indicatorcolor=[('selected', accent), ('!selected', surface2)])

        # Ô nhập: light = chữ ĐEN / nền TRẮNG (macOS hay đảo màu → trắng xoá)
        if is_light:
            entry_bg, entry_fg, entry_ins = '#ffffff', '#000000', '#000000'
            entry_focus_bg = '#ffffff'
        else:
            entry_bg, entry_fg, entry_ins = UI['input_bg'], text, text
            entry_focus_bg = '#0a0e14'
        style.configure(
            'TEntry',
            fieldbackground=entry_bg,
            foreground=entry_fg,
            insertcolor=entry_ins,
            bordercolor=border,
            lightcolor=border,
            darkcolor=border,
            padding=6,
        )
        style.map(
            'TEntry',
            fieldbackground=[('focus', entry_focus_bg), ('!disabled', entry_bg), ('disabled', surface2)],
            foreground=[('!disabled', entry_fg), ('disabled', muted), ('focus', entry_fg)],
            bordercolor=[('focus', accent), ('!focus', border)],
            insertcolor=[('!disabled', entry_ins)],
        )

        style.configure('TLabelframe', background=card, foreground=text, bordercolor=border,
                        relief='solid', borderwidth=1)
        style.configure('TLabelframe.Label', background=card, foreground=accent, font=UI['font_ui_bold'])

        style.configure('TNotebook', background=bg, borderwidth=0, tabmargins=(6, 6, 6, 0))
        style.configure('TNotebook.Tab', background=surface, foreground=muted,
                        padding=(14, 9), font=UI['font_tab'], borderwidth=0)
        style.map('TNotebook.Tab',
                  background=[('selected', surface2), ('active', card)],
                  foreground=[('selected', text), ('active', accent)])

        tree_bg = '#ffffff' if is_light else UI['input_bg']
        tree_fg = '#000000' if is_light else text
        style.configure('Treeview',
                        background=tree_bg,
                        foreground=tree_fg,
                        fieldbackground=tree_bg,
                        bordercolor=border,
                        rowheight=28,
                        font=UI['font_ui'])
        style.configure('Treeview.Heading',
                        background=surface2,
                        foreground='#1e3a8a' if is_light else UI['accent2'],
                        relief='flat',
                        font=UI['font_ui_bold'],
                        borderwidth=0,
                        padding=6)
        # Selected row: chữ tối trên nền select (light) / chữ trắng (dark)
        sel_fg = '#0f172a' if is_light else '#ffffff'
        style.map('Treeview',
                  background=[('selected', select)],
                  foreground=[('selected', sel_fg), ('!selected', tree_fg)])
        style.map('Treeview.Heading',
                  background=[('active', accent), ('!active', surface2)],
                  foreground=[('active', '#ffffff'), ('!active', '#1e3a8a' if is_light else UI['accent2'])])

        style.configure('TSeparator', background=border)
        style.configure('Horizontal.TScrollbar', background=surface2, troughcolor=bg,
                        bordercolor=border, arrowcolor=muted)
        style.configure('Vertical.TScrollbar', background=surface2, troughcolor=bg,
                        bordercolor=border, arrowcolor=muted)

        style.configure('Header.TFrame', background=UI['header_grad_top'])
        style.configure('Header.TLabel', background=UI['header_grad_top'], foreground=text,
                        font=UI['font_title'])
        style.configure('HeaderSub.TLabel', background=UI['header_grad_top'], foreground=muted,
                        font=UI['font_sub'])
        style.configure('Pill.TLabel', background=surface2, foreground=UI['accent2'],
                        font=UI['font_sub'], padding=(8, 3))

        self._ttk_style = style

    def _style_text(self, widget, mono=False):
        """Áp theme dark cho tk.Text."""
        try:
            widget.configure(
                bg=UI['input_bg'],
                fg=UI['text'],
                insertbackground=UI['accent2'],
                selectbackground=UI['select'],
                selectforeground='#ffffff',
                relief='flat',
                borderwidth=0,
                highlightthickness=1,
                highlightbackground=UI['border'],
                highlightcolor=UI['accent'],
                font=UI['font_mono'] if mono else UI['font_ui'],
                padx=8,
                pady=6,
            )
        except Exception:
            pass

    def _mk_solid_btn(self, parent, text, command, kind='accent', fill=True, pady=10):
        """
        Nút vẽ bằng Canvas — macOS không thể ghi đè màu (tránh trắng xoá).
        accent: nền tím đậm + chữ trắng
        secondary: nền xám đậm + chữ đen
        """
        parent_bg = parent.cget('bg') if str(parent.cget('bg')) else UI.get('card', '#ffffff')
        if kind == 'danger':
            bg, hover, fg, border = '#b91c1c', '#991b1b', '#ffffff', '#7f1d1d'
        elif kind == 'secondary':
            # chữ ĐEN trên nền xám rõ — tương phản cao
            bg, hover, fg, border = '#94a3b8', '#64748b', '#0f172a', '#475569'
        else:  # accent — tím đậm + chữ trắng
            bg, hover, fg, border = '#312e81', '#1e1b4b', '#ffffff', '#1e1b4b'

        # đo chiều cao
        h = 20 + pady * 2
        wrap = tk.Frame(parent, bg=parent_bg, highlightthickness=0)
        canvas = tk.Canvas(
            wrap, height=h, bg=parent_bg, highlightthickness=0, bd=0, cursor='hand2',
        )
        canvas.pack(fill='x', expand=True)

        state = {'bg': bg}

        def _draw(fill_c=None):
            fill_c = fill_c or state['bg']
            canvas.delete('all')
            w = max(canvas.winfo_width(), 200)
            canvas.create_rectangle(1, 1, w - 2, h - 2, fill=fill_c, outline=border, width=2)
            canvas.create_text(
                w // 2, h // 2, text=text, fill=fg,
                font=('Helvetica', 12, 'bold'), anchor='center',
            )

        def _on_cfg(_e=None):
            _draw()

        def _go(_e=None):
            if command:
                command()

        def _in(_e=None):
            state['bg'] = hover
            _draw(hover)

        def _out(_e=None):
            state['bg'] = bg
            _draw(bg)

        canvas.bind('<Configure>', _on_cfg)
        canvas.bind('<Button-1>', _go)
        canvas.bind('<Enter>', _in)
        canvas.bind('<Leave>', _out)
        wrap.after(10, _draw)

        if fill:
            wrap.pack(fill='x', pady=(0, 10))
        return wrap

    def _mk_entry(self, parent, width=None, textvariable=None, show=None, **kwargs):
        """
        Ô nhập macOS-safe: luôn dùng tk.Entry (ttk.Entry hay chữ trắng xoá).
        Light: nền trắng + chữ đen. Dark: theo palette.
        """
        is_light = getattr(self, 'theme_mode', 'light') == 'light'
        if is_light:
            bg, fg, ins = '#ffffff', '#000000', '#000000'
            hl, hlc, sel = '#64748b', '#312e81', '#312e81'
        else:
            bg = UI.get('input_bg', '#0f172a')
            fg = UI.get('text', '#f1f5f9')
            ins = UI.get('accent2', '#93c5fd')
            hl = UI.get('border', '#334155')
            hlc = UI.get('accent', '#6366f1')
            sel = UI.get('select', '#3730a3')
        opts = {
            'font': kwargs.pop('font', ('Helvetica', 12)),
            'bg': bg,
            'fg': fg,
            'insertbackground': ins,
            'disabledbackground': '#e2e8f0' if is_light else '#1e293b',
            'disabledforeground': '#64748b',
            'selectbackground': sel,
            'selectforeground': '#ffffff',
            'relief': 'solid',
            'borderwidth': 1,
            'highlightthickness': 1,
            'highlightbackground': hl,
            'highlightcolor': hlc,
        }
        if width is not None:
            opts['width'] = width
        if textvariable is not None:
            opts['textvariable'] = textvariable
        if show is not None:
            opts['show'] = show
        opts.update(kwargs)
        return tk.Entry(parent, **opts)

    def _mk_auth_entry(self, parent, show=None):
        """Ô nhập login — nền trắng / chữ đen cứng."""
        return self._mk_entry(
            parent,
            show=show,
            font=('Helvetica', 13),
            highlightthickness=2,
        )

    def _mk_header_btn(self, parent, text, command, kind='ghost'):
        """Nút header — Canvas (tương phản cố định). Hỗ trợ config(state=...)."""
        is_light = self.theme_mode == 'light'
        parent_bg = parent.cget('bg') if parent else UI.get('header_grad_top', '#f8fafc')
        if kind == 'danger':
            bg, hover, fg, border = '#b91c1c', '#991b1b', '#ffffff', '#7f1d1d'
            dis_bg, dis_fg = '#fca5a5', '#7f1d1d'
        elif kind == 'accent':
            bg, hover, fg, border = '#312e81', '#1e1b4b', '#ffffff', '#1e1b4b'
            dis_bg, dis_fg = '#a5b4fc', '#312e81'
        else:
            if is_light:
                bg, hover, fg, border = '#cbd5e1', '#94a3b8', '#0f172a', '#64748b'
            else:
                bg, hover, fg, border = '#334155', '#475569', '#f8fafc', '#64748b'
            dis_bg, dis_fg = '#e2e8f0', '#94a3b8'

        h = 34
        wrap = tk.Frame(parent, bg=parent_bg, highlightthickness=0)
        canvas = tk.Canvas(wrap, height=h, width=120, bg=parent_bg, highlightthickness=0, bd=0, cursor='hand2')
        canvas.pack()
        st = {'bg': bg, 'enabled': True}

        def _draw(fill_c=None):
            canvas.delete('all')
            tw = max(len(text) * 8 + 24, 90)
            canvas.configure(width=tw)
            if st['enabled']:
                fill_c = fill_c or st['bg']
                tfg = fg
                bdr = border
            else:
                fill_c = dis_bg
                tfg = dis_fg
                bdr = '#94a3b8'
            canvas.create_rectangle(1, 1, tw - 2, h - 2, fill=fill_c, outline=bdr, width=1)
            canvas.create_text(tw // 2, h // 2, text=text, fill=tfg, font=('Helvetica', 10, 'bold'))

        def _go(_e=None):
            if st['enabled'] and command:
                command()

        def _in(_e=None):
            if not st['enabled']:
                return
            st['bg'] = hover
            _draw(hover)

        def _out(_e=None):
            st['bg'] = bg
            _draw(bg if st['enabled'] else dis_bg)

        def _set_state(value):
            st['enabled'] = str(value) not in ('disabled', '0')
            canvas.configure(cursor='hand2' if st['enabled'] else 'arrow')
            st['bg'] = bg
            _draw()

        def _config(*args, **kwargs):
            # hỗ trợ .config(state='disabled'|'normal') như ttk.Button
            if args and isinstance(args[0], dict):
                kwargs = {**args[0], **kwargs}
            if 'state' in kwargs:
                _set_state(kwargs.pop('state'))
            if kwargs:
                return tk.Frame.configure(wrap, **kwargs)
            return None

        canvas.bind('<Configure>', lambda e: _draw())
        canvas.bind('<Button-1>', _go)
        canvas.bind('<Enter>', _in)
        canvas.bind('<Leave>', _out)
        wrap.configure = _config  # type: ignore
        wrap.config = _config  # type: ignore
        wrap.set_state = _set_state  # type: ignore
        wrap.after(10, _draw)
        return wrap

    # ------------------------------------------------------------------
    # Data load / save
    # ------------------------------------------------------------------
    def load_accounts(self):
        try:
            with open(DATA_FILE, 'r', encoding='utf-8') as f:
                data = json.load(f)
            for acc in data:
                acc.setdefault('status', 'Chưa check')
                acc.setdefault('proxy', '')
                acc.setdefault('last_check', '')
                acc.setdefault('joined_groups', [])
                acc.setdefault('posts', [])
                acc.setdefault('login_status', 'Chưa đăng nhập')
                acc.setdefault('planned_name', '')
                acc.setdefault('is_new', False)
                acc.setdefault('name_changed', False)
                acc.setdefault('lifecycle_start', '')
                acc.setdefault('proxy_fingerprint', '')
                acc.setdefault('scenario_note', '')
                # Nhóm chất lượng: moi | trau | sieu
                if not acc.get('acc_group'):
                    if acc.get('is_new'):
                        acc['acc_group'] = ACC_GROUP_MOI
                    else:
                        acc['acc_group'] = ACC_GROUP_TRAU
                else:
                    acc['acc_group'] = self._normalize_acc_group(acc.get('acc_group'))
            # Xóa trạng thái tạm còn sót khi app tắt giữa chừng
            n_fix = self._sanitize_transient_account_statuses(data)
            if n_fix:
                try:
                    with open(DATA_FILE, 'w', encoding='utf-8') as f:
                        json.dump(data, f, indent=4, ensure_ascii=False)
                except Exception:
                    pass
            return data
        except FileNotFoundError:
            return []
        except Exception:
            return []

    def _is_transient_status(self, status):
        """Trạng thái tạm khi đang chạy job — không được lưu bền."""
        st = str(status or '').strip().lower()
        if not st:
            return False
        transient = (
            'đang check',
            'dang check',
            'đang chạy',
            'dang chay',
            'đang login',
            'dang login',
            'checking',
            'running',
            'in progress',
        )
        return any(t in st for t in transient) or st.endswith('...')

    def _sanitize_transient_account_statuses(self, accounts=None):
        """
        Reset status kiểu «Đang check...» còn sót (app crash / stop giữa chừng).
        Trả số nick đã sửa.
        """
        accounts = accounts if accounts is not None else getattr(self, 'accounts', None) or []
        fixed = 0
        for acc in accounts:
            st = str(acc.get('status') or '')
            if not self._is_transient_status(st):
                continue
            # Ưu tiên khôi phục kết quả check gần nhất nếu có
            raw = str(acc.get('live_check_raw') or '').strip()
            raw_u = raw.upper()
            if raw_u in ('LIVE', 'DIE', 'CHECKPOINT', 'NO COOKIE') or raw_u.startswith('DIE'):
                acc['status'] = raw.split()[0] if raw else 'Chưa check'
            elif acc.get('last_check') and acc.get('cookie'):
                acc['status'] = 'Chưa check (check dở)'
            else:
                acc['status'] = 'Chưa check'
            if not acc.get('login_status') or self._is_transient_status(acc.get('login_status')):
                acc['login_status'] = 'Chưa đăng nhập'
            fixed += 1
        return fixed

    def save_accounts(self):
        """Ghi file an toàn từ mọi thread; cloud push luôn schedule trên main."""
        with self._data_lock:
            data = json.loads(json.dumps(self.accounts, ensure_ascii=False))
        with open(DATA_FILE, 'w', encoding='utf-8') as f:
            json.dump(data, f, indent=4, ensure_ascii=False)
        try:
            self._schedule_cloud_push()
        except Exception:
            pass

    def load_vietnamese_names(self):
        """Nạp danh sách tên VN từ file trong thư mục app."""
        paths = [
            os.path.join(os.path.dirname(os.path.abspath(__file__)), NAMES_FILE),
            NAMES_FILE,
        ]
        names = []
        for path in paths:
            if not os.path.exists(path):
                continue
            try:
                with open(path, 'r', encoding='utf-8') as f:
                    for line in f:
                        line = line.strip()
                        if not line or line.startswith('#'):
                            continue
                        names.append(line)
                break
            except Exception:
                continue
        # unique giữ thứ tự
        seen = set()
        uniq = []
        for n in names:
            if n not in seen:
                seen.add(n)
                uniq.append(n)
        return uniq

    def pick_random_names(self, count=1, exclude=None):
        """Lấy tên ngẫu nhiên từ kho, tránh trùng exclude nếu có thể."""
        pool = list(self.vn_names) if self.vn_names else [
            'Nguyễn Văn An', 'Trần Thị Bình', 'Lê Hoàng Cường', 'Phạm Thu Hà', 'Hoàng Minh Khoa'
        ]
        exclude = set(exclude or [])
        available = [n for n in pool if n not in exclude] or pool
        if count <= 1:
            return random.choice(available)
        if count >= len(available):
            # lặp có shuffle nếu thiếu
            result = []
            while len(result) < count:
                batch = available[:]
                random.shuffle(batch)
                result.extend(batch)
            return result[:count]
        return random.sample(available, count)

    def _normalize_acc_group(self, value):
        """Chuẩn hoá mã nhóm: moi | trau | sieu."""
        raw = str(value or '').strip().lower()
        aliases = {
            'moi': ACC_GROUP_MOI,
            'new': ACC_GROUP_MOI,
            'mới': ACC_GROUP_MOI,
            'mới nạp': ACC_GROUP_MOI,
            'trau': ACC_GROUP_TRAU,
            'trâu': ACC_GROUP_TRAU,
            'farm': ACC_GROUP_TRAU,
            'aged': ACC_GROUP_TRAU,
            'sieu': ACC_GROUP_SIEU,
            'siêu': ACC_GROUP_SIEU,
            'sieu chat luong': ACC_GROUP_SIEU,
            'siêu chất lượng': ACC_GROUP_SIEU,
            'personal': ACC_GROUP_SIEU,
            'ca nhan': ACC_GROUP_SIEU,
            'cá nhân': ACC_GROUP_SIEU,
            'hq': ACC_GROUP_SIEU,
            'vip': ACC_GROUP_SIEU,
        }
        if raw in ACC_GROUP_LABELS:
            return raw
        return aliases.get(raw, ACC_GROUP_MOI)

    def _acc_group_label(self, value):
        key = self._normalize_acc_group(value)
        return ACC_GROUP_LABELS.get(key, ACC_GROUP_LABELS[ACC_GROUP_MOI])

    def _ensure_acc_group(self, acc):
        if not isinstance(acc, dict):
            return ACC_GROUP_MOI
        if not acc.get('acc_group'):
            acc['acc_group'] = ACC_GROUP_MOI if acc.get('is_new') else ACC_GROUP_TRAU
        else:
            acc['acc_group'] = self._normalize_acc_group(acc.get('acc_group'))
        # Đồng bộ cờ is_new với nhóm Mới (giữ tương thích code cũ)
        acc['is_new'] = acc['acc_group'] == ACC_GROUP_MOI
        return acc['acc_group']

    def _set_acc_group(self, acc, group_key, note=None):
        g = self._normalize_acc_group(group_key)
        acc['acc_group'] = g
        acc['is_new'] = g == ACC_GROUP_MOI
        if note:
            acc['scenario_note'] = note
        elif g == ACC_GROUP_MOI and not acc.get('scenario_note'):
            acc['scenario_note'] = 'Nhóm Mới — warm-up / profile'
        elif g == ACC_GROUP_TRAU and not acc.get('scenario_note'):
            acc['scenario_note'] = 'Nhóm Trâu — nuôi / tương tác / group'
        elif g == ACC_GROUP_SIEU and not acc.get('scenario_note'):
            acc['scenario_note'] = 'Nhóm Siêu chất lượng (cá nhân) — hạn chế spam'
        return g

    def _new_account_dict(self, uid, password='', two_fa='', email='', cookie='', proxy='', acc_group=None):
        g = self._normalize_acc_group(acc_group or ACC_GROUP_MOI)
        return {
            "uid": uid,
            "password": password,
            "two_fa": two_fa,
            "email": email or uid,
            "cookie": cookie,
            "proxy": proxy,
            "status": "Chưa check",
            "last_check": "",
            "joined_groups": [],
            "posts": [],
            "login_status": "Chưa đăng nhập",
            "planned_name": "",
            "is_new": g == ACC_GROUP_MOI,
            "acc_group": g,
            "name_changed": False,
            "lifecycle_start": datetime.now().strftime('%Y-%m-%d'),
            "proxy_fingerprint": self._ip_port_key(proxy) if proxy else '',
            "scenario_note": (
                "Mới nạp — bắt đầu warm-up" if g == ACC_GROUP_MOI
                else ("Nick trâu — đã nuôi" if g == ACC_GROUP_TRAU else "Siêu chất lượng (cá nhân)")
            ),
        }

    def load_scenario_progress(self):
        try:
            with open(SCENARIO_FILE, 'r', encoding='utf-8') as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def save_scenario_progress(self):
        with open(SCENARIO_FILE, 'w', encoding='utf-8') as f:
            json.dump(self.scenario_progress, f, indent=4, ensure_ascii=False)

    def load_scenario_config(self):
        cfg = dict(DEFAULT_SCENARIO_CFG)
        try:
            with open(SCENARIO_CFG_FILE, 'r', encoding='utf-8') as f:
                saved = json.load(f)
            if isinstance(saved, dict):
                cfg.update(saved)
        except Exception:
            pass
        return cfg

    def save_scenario_config(self):
        with open(SCENARIO_CFG_FILE, 'w', encoding='utf-8') as f:
            json.dump(self.scenario_cfg, f, indent=4, ensure_ascii=False)

    def load_reports(self):
        try:
            with open(REPORT_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception:
            return []

    def save_reports(self):
        with open(REPORT_FILE, 'w', encoding='utf-8') as f:
            json.dump(self.reports[-500:], f, indent=4, ensure_ascii=False)

    def load_daily_usage(self):
        try:
            with open(USAGE_FILE, 'r', encoding='utf-8') as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def save_daily_usage(self):
        with open(USAGE_FILE, 'w', encoding='utf-8') as f:
            json.dump(self.daily_usage, f, indent=4, ensure_ascii=False)

    def _today(self):
        return datetime.now().strftime('%Y-%m-%d')

    def _usage_for(self, uid):
        """Lấy counter hôm nay cho 1 uid (reset nếu sang ngày mới)."""
        uid = str(uid)
        today = self._today()
        rec = self.daily_usage.get(uid) or {}
        if rec.get('date') != today:
            rec = {
                'date': today,
                'joins': 0,
                'comments': 0,
                'posts': 0,
                'friends': 0,
                'likes': 0,
                'scenario_done': False,
            }
            self.daily_usage[uid] = rec
        for k in ('joins', 'comments', 'posts', 'friends', 'likes'):
            rec.setdefault(k, 0)
        rec.setdefault('scenario_done', False)
        return rec

    def get_preset(self, mode):
        return ACTIVITY_PRESETS.get(mode) or ACTIVITY_PRESETS['medium']

    def remaining_quota(self, uid, action, mode):
        """Số lượt còn lại trong ngày cho join|comment|post|friend theo mode."""
        preset = self.get_preset(mode)
        key_map = {
            'join': 'joins',
            'comment': 'comments',
            'post': 'posts',
            'friend': 'friends',
        }
        day_map = {
            'join': 'join_day',
            'comment': 'comment_day',
            'post': 'post_day',
            'friend': 'friend_day',
        }
        if action == 'friend':
            # Kịch bản sống lâu: 5–10 friend/ngày
            limit = ABSOLUTE_CAPS.get('friend_day', 10)
        else:
            cap_key = day_map[action]
            limit = min(preset[day_map[action]], ABSOLUTE_CAPS[cap_key])
        used = int(self._usage_for(uid).get(key_map[action], 0))
        return max(0, limit - used), limit, used

    def remaining_scenario_quota(self, uid, action):
        """Quota theo chốt chặn kịch bản (an toàn hơn preset thường)."""
        limits = SCENARIO_CAPS['day_limits']
        key_map = {
            'join': ('joins', 'join'),
            'comment': ('comments', 'comment_group'),
            'comment_group': ('comments', 'comment_group'),
            'friend': ('friends', 'friend'),
            'post': ('posts', None),
        }
        if action not in key_map:
            return 0, 0, 0
        usage_key, limit_key = key_map[action]
        used = int(self._usage_for(uid).get(usage_key, 0))
        if limit_key and limit_key in limits:
            _lo, hi = limits[limit_key]
            limit = hi  # trần trên
        elif action == 'post':
            limit = 2
        else:
            limit = 5
        return max(0, limit - used), limit, used

    def inc_usage(self, uid, action, n=1):
        rec = self._usage_for(uid)
        key = {
            'join': 'joins',
            'comment': 'comments',
            'post': 'posts',
            'friend': 'friends',
            'like': 'likes',
        }.get(action)
        if not key:
            return
        rec[key] = int(rec.get(key, 0)) + n
        self.daily_usage[str(uid)] = rec
        self.save_daily_usage()

    async def _safe_sleep(self, delay_range=None, label=''):
        """Sleep ngẫu nhiên giữa các hành động; có thể bị DỪNG giữa chừng."""
        d0, d1 = delay_range or SAFE_ACTION_DELAY
        sec = random.uniform(float(d0), float(d1))
        if label:
            self.log(f"  ⏳ Nghỉ {sec:.0f}s ({label})...")
        await self._interruptible_async_sleep(sec)

    def _should_stop(self):
        return bool(self.stop_requested) or (
            hasattr(self, '_stop_event') and self._stop_event.is_set()
        )

    def _raise_if_stopped(self):
        if self._should_stop():
            raise JobStopped('Người dùng đã dừng lệnh đang chạy')

    async def _interruptible_async_sleep(self, sec):
        """Sleep async, kiểm tra stop mỗi 0.25s."""
        end = time.time() + max(0.0, float(sec))
        while time.time() < end:
            if self._should_stop():
                raise JobStopped('Dừng trong lúc nghỉ')
            await asyncio.sleep(min(0.25, max(0.05, end - time.time())))

    def _interruptible_sleep(self, sec):
        """Sleep sync (check proxy / live), có thể dừng."""
        end = time.time() + max(0.0, float(sec))
        while time.time() < end:
            if self._should_stop():
                return False
            time.sleep(min(0.2, max(0.05, end - time.time())))
        return True

    # ------------------------------------------------------------------
    # Job control: chạy / chờ / dừng
    # ------------------------------------------------------------------
    def _next_job_id(self):
        self._job_seq += 1
        return f'J{self._job_seq}'

    def _task_label(self, task_type):
        return TASK_LABELS.get(task_type, task_type or 'TASK')

    def _uids_from_indices(self, indices):
        uids = []
        for i in indices or []:
            if 0 <= i < len(self.accounts):
                uids.append(str(self.accounts[i].get('uid', i)))
            else:
                uids.append(str(i))
        return uids

    def _make_job(self, task_type, config=None, indices=None, label=None, kind='playwright'):
        indices = list(indices or [])
        uids = self._uids_from_indices(indices)
        jid = self._next_job_id()
        return {
            'id': jid,
            'task_type': task_type,
            'label': label or self._task_label(task_type),
            'kind': kind,  # playwright | proxy | fb_live | scan
            'config': config if isinstance(config, dict) else {},
            'indices': indices,
            'uids': uids,
            'status': 'waiting',  # waiting | running | done | stopped | fail
            'current_uid': '',
            'done_uids': [],
            'failed_uids': [],
            'cancelled_uids': [],
            'waiting_uids': list(uids),
            'created_at': datetime.now().strftime('%H:%M:%S'),
            'started_at': '',
            'finished_at': '',
            'message': '',
        }

    def _rebuild_job_items(self):
        """
        Job Queue gọn (1–vài dòng/job). Tránh render hàng trăm UID → đơ UI / log không kịp.
        """
        items = []
        jobs = []
        if self.current_job:
            jobs.append(self.current_job)
        jobs.extend(self.job_queue)
        for job in jobs:
            jstatus = job.get('status', 'waiting')
            label = job.get('label', '') or job.get('task_type', '')
            jid = job.get('id', '')
            uids = list(job.get('uids') or [])
            waiting = list(job.get('waiting_uids') or [])
            done = list(job.get('done_uids') or [])
            failed = list(job.get('failed_uids') or [])
            cancelled = list(job.get('cancelled_uids') or [])
            cur = job.get('current_uid') or ''
            msg = job.get('message') or ''
            total = len(uids) or (
                len(waiting) + len(done) + len(failed) + len(cancelled) + (1 if cur else 0)
            )
            if jstatus == 'running':
                items.append({
                    'job_id': jid,
                    'task': label,
                    'target': cur or f'{len(done) + len(failed)}/{total or "?"}',
                    'status': '▶ ĐANG CHẠY',
                    'detail': msg or (
                        f"Xong {len(done)} · Lỗi {len(failed)} · Chờ {len(waiting)} · "
                        f"Hủy {len(cancelled)} / Tổng {total}"
                    ),
                })
                for uid in (done[-4:] + failed[-2:]):
                    st = '✓ XONG' if uid in done else '✗ LỖI'
                    items.append({
                        'job_id': jid, 'task': label, 'target': uid,
                        'status': st, 'detail': 'gần nhất',
                    })
            elif jstatus == 'waiting':
                items.append({
                    'job_id': jid,
                    'task': label,
                    'target': f'{len(waiting) or len(uids)} nick',
                    'status': '⏳ CHỜ JOB',
                    'detail': msg or f"Xếp hàng lúc {job.get('created_at', '')}",
                })
            else:
                items.append({
                    'job_id': jid, 'task': label, 'target': '-',
                    'status': str(jstatus).upper(), 'detail': msg or '',
                })
        self.job_items = items
        return items

    def refresh_job_table(self):
        if not hasattr(self, 'job_tree'):
            return
        self._rebuild_job_items()
        for row in self.job_tree.get_children():
            self.job_tree.delete(row)
        running_n = waiting_n = done_n = cancel_n = 0
        cur = self.current_job
        if cur and cur.get('status') == 'running':
            running_n = 1
            waiting_n = len(cur.get('waiting_uids') or [])
            done_n = len(cur.get('done_uids') or [])
            cancel_n = len(cur.get('cancelled_uids') or []) + len(cur.get('failed_uids') or [])
        for j in self.job_queue:
            waiting_n += max(1, len(j.get('waiting_uids') or j.get('uids') or [1]))
        for it in self.job_items:
            self.job_tree.insert(
                '', 'end',
                values=(
                    it.get('job_id', ''),
                    it.get('task', ''),
                    it.get('target', ''),
                    it.get('status', ''),
                    it.get('detail', ''),
                ),
            )
        qn = len(self.job_queue)
        if cur and cur.get('status') == 'running':
            head = (
                f"▶ LỆNH: {cur.get('label')} [{cur.get('id')}] · "
                f"nick={cur.get('current_uid') or '…'} · {cur.get('message') or ''}"
            )
        elif self.is_running:
            head = '▶ Đang chạy…'
        else:
            head = '○ Không có lệnh đang chạy'
        head_txt = (
            f"{head}  |  Xong: {done_n}  |  Chờ: {waiting_n}  |  "
            f"Hủy/Lỗi: {cancel_n}  |  Hàng đợi job: {qn}"
            + ('  |  ⏹ ĐANG DỪNG…' if self.stop_requested else '')
        )
        if hasattr(self, 'job_status_label'):
            self.job_status_label.config(text=head_txt)
        if hasattr(self, 'cmd_banner_label'):
            try:
                if cur and cur.get('status') == 'running':
                    self.cmd_banner_label.config(
                        text=(
                            f"▶ ĐANG CHẠY: {cur.get('label')} [{cur.get('id')}]  ·  "
                            f"{cur.get('message') or ('UID ' + str(cur.get('current_uid') or '…'))}"
                        )
                    )
                elif qn:
                    self.cmd_banner_label.config(
                        text=f"⏳ Có {qn} job trong hàng chờ — bấm «Chi tiết» để xem"
                    )
                else:
                    self.cmd_banner_label.config(text="○ Hệ thống rảnh — chưa có lệnh đang chạy")
            except Exception:
                pass
        busy = bool(self.is_running or self.job_queue or self.stop_requested or self.current_job)
        for btn_name in ('btn_stop_all', 'btn_stop_dock'):
            btn = getattr(self, btn_name, None)
            if btn is None:
                continue
            try:
                btn.config(state='normal' if busy else 'disabled')
            except Exception:
                pass
        self._jobs_dirty = False

    def _ui_refresh_jobs(self):
        """Debounce — tránh đơ UI khi check live cập nhật từng nick."""
        self._jobs_dirty = True
        if threading.current_thread() is threading.main_thread():
            self._schedule_jobs_refresh()
        else:
            self._ui_q.put(('call', self._schedule_jobs_refresh, (), {}))

    def _schedule_jobs_refresh(self):
        if getattr(self, '_jobs_refresh_pending', False):
            return
        # Chỉ schedule từ main thread
        if threading.current_thread() is not threading.main_thread():
            try:
                self._ui_q.put(('call', self._schedule_jobs_refresh, (), {}))
            except Exception:
                pass
            return
        self._jobs_refresh_pending = True

        def _run():
            self._jobs_refresh_pending = False
            try:
                self.refresh_job_table()
            except Exception:
                pass

        try:
            self.root.after(120, _run)
        except Exception:
            self._jobs_refresh_pending = False
            try:
                self.refresh_job_table()
            except Exception:
                pass

    def _job_mark_account_start(self, uid):
        job = self.current_job
        if not job:
            return
        uid = str(uid)
        with self._data_lock:
            job['current_uid'] = uid
            job['message'] = f'Đang xử lý {uid}'
            w = job.get('waiting_uids') or []
            if uid in w:
                w.remove(uid)
            job['waiting_uids'] = w
        self._ui_refresh_jobs()

    def _job_mark_account_done(self, uid, ok=True):
        job = self.current_job
        if not job:
            return
        uid = str(uid)
        with self._data_lock:
            if job.get('current_uid') == uid:
                job['current_uid'] = ''
            if ok:
                if uid not in job['done_uids']:
                    job['done_uids'].append(uid)
            else:
                if uid not in job['failed_uids']:
                    job['failed_uids'].append(uid)
        self._ui_refresh_jobs()

    def _job_cancel_remaining(self, job=None, reason='Dừng bởi người dùng'):
        job = job or self.current_job
        if not job:
            return
        left = list(job.get('waiting_uids') or [])
        cur = job.get('current_uid')
        if cur and cur not in left:
            left.insert(0, cur)
        for uid in left:
            if uid and uid not in job['cancelled_uids']:
                job['cancelled_uids'].append(uid)
        job['waiting_uids'] = []
        job['current_uid'] = ''
        job['message'] = reason
        self._ui_refresh_jobs()

    def stop_all_jobs(self):
        """
        Dừng lệnh đang chạy + hủy hàng chờ.
        - Check Live: hủy future + shutdown pool (≤ ~5s / request đang mở)
        - Proxy check: dừng giữa 2 proxy
        - Playwright: set cờ + đóng browser trên đúng asyncio loop (thoát goto/action)
        """
        had_work = False
        with self._job_lock:
            n_queue = len(self.job_queue)
            cur = self.current_job
            running = bool(self.is_running or (cur and cur.get('status') == 'running'))
            had_work = running or n_queue > 0

            # Cờ dừng toàn cục (mọi worker đọc)
            self.stop_requested = True
            try:
                self._stop_event.set()
            except Exception:
                pass

            for job in self.job_queue:
                job['status'] = 'stopped'
                job['message'] = 'Hủy khi còn trong hàng chờ'
                for uid in list(job.get('waiting_uids') or job.get('uids') or []):
                    if uid not in job['cancelled_uids']:
                        job['cancelled_uids'].append(uid)
                job['waiting_uids'] = []
            self.job_queue.clear()

            if cur and cur.get('status') == 'running':
                cur['message'] = 'Đang dừng…'
                self._job_cancel_remaining(cur, 'Dừng bởi người dùng')
                self.log(
                    f"⏹ DỪNG LỆNH ĐANG CHẠY: {cur.get('label')} [{cur.get('id')}] — "
                    f"hủy {n_queue} job chờ. Đang thoát an toàn…"
                )
            elif running:
                self.log(f"⏹ DỪNG LỆNH — hủy {n_queue} job chờ; đang thoát tác vụ nền…")
            elif n_queue:
                self.log(f"⏹ Đã hủy {n_queue} job trong hàng chờ.")
            else:
                self.stop_requested = False
                try:
                    self._stop_event.clear()
                except Exception:
                    pass
                self.log("⏹ Không có lệnh đang chạy.")

        try:
            self._set_progress_label('⏹ Đang dừng lệnh… vui lòng chờ vài giây')
        except Exception:
            pass
        try:
            self.scroll_to_processing_area(expand_dock=True)
        except Exception:
            pass

        # Đóng browser Playwright trên đúng event loop (hủy page.goto đang treo)
        if had_work:
            self._force_close_active_browser()

        self._ui_refresh_jobs()
        for btn_name in ('btn_stop_all', 'btn_stop_dock'):
            btn = getattr(self, btn_name, None)
            if btn is not None:
                try:
                    btn.config(state='normal')
                except Exception:
                    pass

    def _force_close_active_browser(self):
        """Best-effort: đóng mọi browser Playwright đang mở (hỗ trợ parallel)."""
        browsers = []
        try:
            with self._active_browsers_lock:
                browsers = list(self._active_browsers)
        except Exception:
            pass
        br0 = getattr(self, '_active_browser', None)
        if br0 is not None and br0 not in browsers:
            browsers.append(br0)
        loop = getattr(self, '_active_loop', None)
        if not browsers:
            return

        def _close_one(br):
            try:
                close = getattr(br, 'close', None)
                if close is None:
                    return
                if loop is not None and getattr(loop, 'is_running', lambda: False)():
                    try:
                        fut = asyncio.run_coroutine_threadsafe(close(), loop)
                        fut.result(timeout=2.5)
                        return
                    except Exception:
                        pass
                try:
                    tmp = asyncio.new_event_loop()
                    try:
                        tmp.run_until_complete(close())
                    finally:
                        tmp.close()
                except Exception:
                    try:
                        close()
                    except Exception:
                        pass
            except Exception:
                pass
            finally:
                self._unregister_active_browser(br)

        def _close_all():
            for br in browsers:
                _close_one(br)

        threading.Thread(target=_close_all, daemon=True, name='force-stop-browsers').start()

    def show_job_list_dialog(self):
        """Popup liệt kê lệnh đang chạy / đang chờ."""
        self._rebuild_job_items()
        dlg = tk.Toplevel(self.root)
        dlg.title("Lệnh đang chạy / đang chờ")
        dlg.geometry("820x480")
        ttk.Label(
            dlg,
            text="▶ ĐANG CHẠY = nick đang xử lý | ⏳ CHỜ = nick/job còn trong hàng | ⏹ HỦY = đã dừng",
            font=("Arial", 9),
        ).pack(anchor='w', padx=10, pady=8)
        cols = ('Job', 'Lệnh', 'UID / Đối tượng', 'Trạng thái', 'Chi tiết')
        tree = ttk.Treeview(dlg, columns=cols, show='headings', height=16)
        for c, w in zip(cols, (70, 160, 120, 110, 280)):
            tree.heading(c, text=c)
            tree.column(c, width=w)
        tree.pack(fill='both', expand=True, padx=10, pady=4)
        running, waiting = [], []
        for it in self.job_items:
            st = it.get('status', '')
            row = (it.get('job_id'), it.get('task'), it.get('target'), st, it.get('detail'))
            tree.insert('', 'end', values=row)
            if 'ĐANG CHẠY' in st:
                running.append(f"{it.get('task')} → {it.get('target')}")
            elif 'CHỜ' in st:
                waiting.append(f"{it.get('task')} → {it.get('target')}")
        if not self.job_items:
            tree.insert('', 'end', values=('-', '(trống)', '-', '○ Rảnh', 'Không có lệnh'))
        summary = ttk.Label(
            dlg,
            text=f"Đang chạy: {len(running)}  |  Đang chờ: {len(waiting)}  |  "
                 f"Job queue: {len(self.job_queue)}  |  stop={self.stop_requested}",
            font=("Arial", 9, "bold"),
        )
        summary.pack(anchor='w', padx=10, pady=6)
        bf = ttk.Frame(dlg)
        bf.pack(fill='x', padx=10, pady=8)
        ttk.Button(bf, text="⏹ Dừng lệnh đang chạy", command=lambda: (self.stop_all_jobs(), dlg.destroy())).pack(side='left', padx=4)
        ttk.Button(bf, text="Đóng", command=dlg.destroy).pack(side='right', padx=4)

    def _submit_job(self, task_type, config=None, indices=None, label=None, kind='playwright', runner=None):
        """
        Đưa job vào chạy ngay hoặc xếp hàng chờ.
        runner: callable(job) cho job không qua _run_automation (proxy/live).
        """
        # Lớp chống crack: mọi job đều qua gate license (kể cả khi caller quên _pre_check)
        if not self._license_gate_ok():
            try:
                messagebox.showerror(
                    "License",
                    "Không chạy được — license offline / hết hạn / chữ ký không hợp lệ.\n"
                    f"Đăng nhập online server chính thức. Zalo: {HOTLINE}",
                )
            except Exception:
                pass
            try:
                self.log("❌ Chặn job: license không hợp lệ (anti-crack).")
            except Exception:
                pass
            return None
        if indices is None and kind == 'playwright':
            indices = list(range(len(self.accounts)))
        job = self._make_job(task_type, config=config, indices=indices or [], label=label, kind=kind)
        job['_runner'] = runner  # optional

        # Tự cuộn xuống Job/Log khi bấm lệnh
        try:
            self.scroll_to_processing_area(expand_dock=True)
        except Exception:
            pass

        with self._job_lock:
            if self.is_running or self.current_job:
                job['status'] = 'waiting'
                self.job_queue.append(job)
                self.log(
                    f"⏳ Xếp hàng chờ: {job['label']} [{job['id']}] — "
                    f"{len(job.get('uids') or [])} nick | queue={len(self.job_queue)}"
                )
                self._ui_refresh_jobs()
                return job

            self.is_running = True
            self.stop_requested = False
            try:
                self._stop_event.clear()
            except Exception:
                pass
            self.current_job = job
            job['status'] = 'running'
            job['started_at'] = datetime.now().strftime('%H:%M:%S')
            n_uid = len(job.get('uids') or job.get('indices') or [])
            job['message'] = f'Khởi động… {n_uid} đối tượng'
            self.log("=" * 56)
            self.log(f"▶ LỆNH ĐANG CHẠY: {job['label']}  |  ID={job['id']}  |  loại={job.get('task_type')}  |  {n_uid} nick")
            self.log("   💡 Bấm «⏹ Dừng lệnh» (header / Job Queue) hoặc phím Esc để dừng.")
            self.log("=" * 56)
            try:
                self._set_progress_label(
                    f"▶ {job['label']} [{job['id']}] — đang khởi động ({n_uid} nick)…"
                )
            except Exception:
                pass
            if hasattr(self, 'cmd_banner_label'):
                try:
                    self.cmd_banner_label.config(
                        text=f"▶ ĐANG CHẠY: {job['label']} [{job['id']}] · khởi động {n_uid} nick…"
                    )
                except Exception:
                    pass
            self._ui_refresh_jobs()
            self._start_job_thread(job)
            return job

    def _start_job_thread(self, job):
        kind = job.get('kind') or 'playwright'
        if kind == 'playwright':
            cfg = dict(job.get('config') or {})
            cfg['indices'] = list(job.get('indices') or [])
            cfg['_job_id'] = job.get('id')
            threading.Thread(
                target=self._run_automation,
                args=(job.get('task_type'), cfg),
                daemon=True,
                name=f"job-{job.get('id')}",
            ).start()
        elif callable(job.get('_runner')):
            threading.Thread(
                target=self._run_custom_job,
                args=(job,),
                daemon=True,
                name=f"job-{job.get('id')}",
            ).start()
        else:
            self.log(f"[{job.get('id')}] Không có runner — bỏ job")
            self._finish_current_job(stopped=True, message='Không có runner')

    def _run_custom_job(self, job):
        try:
            if self.stop_requested:
                raise JobStopped('Dừng trước khi chạy')
            runner = job.get('_runner')
            if callable(runner):
                runner(job)
        except JobStopped as ex:
            self.log(f"⏹ Job {job.get('id')} dừng: {ex}")
            job['status'] = 'stopped'
            job['message'] = str(ex)
            self._job_cancel_remaining(job)
        except Exception as ex:
            self.log(f"Lỗi job {job.get('id')}: {ex}")
            job['status'] = 'fail'
            job['message'] = str(ex)
        finally:
            self._finish_current_job(
                stopped=job.get('status') == 'stopped' or self.stop_requested,
                message=job.get('message') or '',
            )

    def _finish_current_job(self, stopped=False, message=''):
        with self._job_lock:
            job = self.current_job
            if job:
                if stopped or self.stop_requested:
                    job['status'] = 'stopped'
                    if not job.get('message'):
                        job['message'] = message or 'Đã dừng'
                    self._job_cancel_remaining(job, job['message'])
                elif job.get('status') == 'running':
                    job['status'] = 'done'
                    job['message'] = message or 'Hoàn tất'
                job['finished_at'] = datetime.now().strftime('%H:%M:%S')
                job['current_uid'] = ''
            self.current_job = None
            self._active_browser = None
            self._active_loop = None

            # Lấy job tiếp theo nếu không bị stop
            next_job = None
            if self.stop_requested:
                for j in self.job_queue:
                    j['status'] = 'stopped'
                    j['message'] = 'Hủy do dừng toàn bộ'
                    for uid in list(j.get('waiting_uids') or j.get('uids') or []):
                        if uid not in j['cancelled_uids']:
                            j['cancelled_uids'].append(uid)
                    j['waiting_uids'] = []
                self.job_queue.clear()
                self.is_running = False
                self.stop_requested = False
                try:
                    self._stop_event.clear()
                except Exception:
                    pass
                self.log("⏹ Đã dừng xong — hàng chờ đã xóa, hệ thống rảnh.")
                try:
                    self._set_progress_label('⏹ Đã dừng — hệ thống rảnh')
                except Exception:
                    pass
            elif self.job_queue:
                next_job = self.job_queue.pop(0)
                next_job['status'] = 'running'
                next_job['started_at'] = datetime.now().strftime('%H:%M:%S')
                self.current_job = next_job
                self.is_running = True
                self.stop_requested = False
                try:
                    self._stop_event.clear()
                except Exception:
                    pass
                self.log(f"▶ Chạy job chờ tiếp theo: {next_job['label']} [{next_job['id']}]")
            else:
                self.is_running = False
                self.stop_requested = False
                try:
                    self._stop_event.clear()
                except Exception:
                    pass

        self._ui_refresh_jobs()
        if next_job:
            self._start_job_thread(next_job)

    def browser_runtime_config(self):
        """Cấu hình browser: ẩn (headless) hoặc hiện mobile gọn."""
        mode = 'headless'
        if hasattr(self, 'var_run_mode'):
            mode = (self.var_run_mode.get() or 'headless').strip()
        elif hasattr(self, 'var_headless'):
            mode = 'headless' if self.var_headless.get() else 'mobile'
        # đồng bộ checkbox cũ
        headless = mode == 'headless'
        if hasattr(self, 'var_headless'):
            try:
                self.var_headless.set(headless)
            except Exception:
                pass
        profile_key = (
            self.var_mobile_profile.get()
            if hasattr(self, 'var_mobile_profile') else 'iphone_se'
        )
        if profile_key not in MOBILE_BROWSER_PROFILES:
            profile_key = 'iphone_se'
        workers = 1
        try:
            workers = int((self.var_parallel_workers.get() if hasattr(self, 'var_parallel_workers') else '1') or 1)
        except Exception:
            workers = 1
        workers = max(1, min(20, workers))
        return {
            'run_mode': mode,
            'headless': headless,
            'mobile': mode == 'mobile',
            'mobile_profile': profile_key,
            'tile_windows': bool(self.var_tile_windows.get()) if hasattr(self, 'var_tile_windows') else True,
            'browser': (self.var_browser.get() if hasattr(self, 'var_browser') else 'chromium') or 'chromium',
            'parallel_workers': workers,
            'parallel_same_proxy_max': 2,
        }

    def inject_runtime(self, config):
        """Gắn headless/mobile/browser vào mọi config automation."""
        if not isinstance(config, dict):
            config = {}
        rt = self.browser_runtime_config()
        config['run_mode'] = rt['run_mode']
        config['headless'] = rt['headless']
        config['mobile'] = rt['mobile']
        config['mobile_profile'] = rt['mobile_profile']
        config['tile_windows'] = rt['tile_windows']
        config['browser'] = rt['browser']
        config['parallel_workers'] = rt.get('parallel_workers', 1)
        config['parallel_same_proxy_max'] = rt.get('parallel_same_proxy_max', 2)
        config['require_proxy'] = bool(self.var_require_proxy.get()) if hasattr(self, 'var_require_proxy') else True
        return config

    def _register_active_browser(self, browser):
        self._active_browser = browser
        try:
            with self._active_browsers_lock:
                self._active_browsers.add(browser)
        except Exception:
            pass

    def _unregister_active_browser(self, browser):
        try:
            with self._active_browsers_lock:
                self._active_browsers.discard(browser)
        except Exception:
            pass
        if getattr(self, '_active_browser', None) is browser:
            self._active_browser = None

    def _get_mobile_profile(self, key=None):
        key = key or (
            self.var_mobile_profile.get()
            if hasattr(self, 'var_mobile_profile') else 'iphone_se'
        )
        return MOBILE_BROWSER_PROFILES.get(key) or MOBILE_BROWSER_PROFILES['iphone_se']

    def _mobile_window_pos(self, index, win_w, win_h, tile=True):
        """Tính vị trí xếp ô cửa sổ mobile trên màn hình (gọn, không đè full)."""
        if not tile:
            return 40 + (index % 5) * 24, 40 + (index % 5) * 24
        # lề + khoảng cách giữa cửa sổ
        gap_x, gap_y = 8, 32
        screen_w, screen_h = 1440, 900
        try:
            screen_w = max(800, int(self.root.winfo_screenwidth()))
            screen_h = max(600, int(self.root.winfo_screenheight()))
        except Exception:
            pass
        cols = max(1, (screen_w - 20) // (win_w + gap_x))
        col = index % cols
        row = index // cols
        # wrap row nếu vượt chiều cao
        max_rows = max(1, (screen_h - 40) // (win_h + gap_y))
        row = row % max_rows
        x = 10 + col * (win_w + gap_x)
        y = 30 + row * (win_h + gap_y)
        return int(x), int(y)

    def account_has_proxy(self, acc):
        """Nick đã được gán proxy đúng định dạng chưa."""
        px = (acc.get('proxy') or '').strip()
        if not px or px.lower() in ('không dùng', 'chua gan', 'chưa gán', '-'):
            return False
        return self.is_valid_proxy_format(px)

    # ------------------------------------------------------------------
    # Acc chết + cách ly proxy (bảo vệ nick LIVE còn lại)
    # ------------------------------------------------------------------
    def _is_dead_pool(self, acc):
        """Nick nằm trong tab Acc chết (checkpoint/die/cách ly proxy)."""
        if not acc:
            return False
        if acc.get('pool') == 'dead' or acc.get('is_dead'):
            return True
        st = str(acc.get('status') or '').upper()
        if st in ('DEAD', 'DIE', 'NO COOKIE') or 'CHECKPOINT' in st:
            return True
        if acc.get('discard_recommend') or acc.get('quarantine'):
            return True
        return False

    def _is_proxy_quarantined(self, proxy_str):
        key = self._ip_port_key(proxy_str or '') or self._normalize_proxy_key(proxy_str or '')
        if not key:
            return False
        for p in self.proxies:
            pk = self._ip_port_key(p.get('proxy', '')) or self._normalize_proxy_key(p.get('proxy', ''))
            if pk == key:
                st = str(p.get('status') or '').upper()
                if p.get('quarantine') or p.get('pool') == 'dead':
                    return True
                if any(x in st for x in ('QUARANTINE', 'NGƯNG', 'DEAD', 'BAN', 'CÁCH LY')):
                    return True
        return False

    def _proxy_matches(self, a, b):
        ka = self._ip_port_key(a or '') or self._normalize_proxy_key(a or '')
        kb = self._ip_port_key(b or '') or self._normalize_proxy_key(b or '')
        return bool(ka and kb and ka == kb)

    def _quarantine_proxy_record(self, proxy_str, reason=''):
        """Đánh dấu proxy NGƯNG DÙNG (tab Acc chết)."""
        if not (proxy_str or '').strip():
            return None
        key = self._ip_port_key(proxy_str) or self._normalize_proxy_key(proxy_str)
        now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        found = None
        for p in self.proxies:
            pk = self._ip_port_key(p.get('proxy', '')) or self._normalize_proxy_key(p.get('proxy', ''))
            if pk == key:
                p['status'] = 'QUARANTINE'
                p['quarantine'] = True
                p['pool'] = 'dead'
                p['note'] = (reason or 'Ngưng dùng — liên quan nick checkpoint')[:200]
                p['quarantine_at'] = now
                found = p
                break
        if not found and key:
            # proxy chỉ gắn trên nick, chưa có trong list → thêm vào kho chết
            found = {
                'proxy': self._normalize_proxy_key(proxy_str) or proxy_str,
                'status': 'QUARANTINE',
                'quarantine': True,
                'pool': 'dead',
                'note': reason or 'Ngưng dùng — checkpoint',
                'expire_date': '',
                'last_check': now,
                'quarantine_at': now,
            }
            self.proxies.append(found)
        return found

    def move_account_to_dead(self, acc, reason='CHECKPOINT', quarantine_related=True):
        """
        Chuyển nick sang pool Acc chết.
        Nếu quarantine_related: ngưng proxy + mọi nick LIVE cùng proxy.
        Trả về dict tóm tắt {dead_uids, proxy, quarantined_live}.
        """
        if not acc:
            return {}
        now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        uid = str(acc.get('uid') or '')
        px = acc.get('proxy') or ''
        summary = {
            'source_uid': uid,
            'proxy': self._ip_port_key(px) or px,
            'dead_uids': [],
            'quarantined_live': [],
            'reason': reason,
        }

        def _mark_dead(a, why):
            a['pool'] = 'dead'
            a['is_dead'] = True
            a['quarantine'] = True
            st = str(a.get('status') or '').upper()
            if 'CHECKPOINT' in st or 'checkpoint' in why.lower():
                a['status'] = 'CHECKPOINT'
            elif st in ('DIE', 'NO COOKIE'):
                pass
            else:
                # LIVE bị kéo theo proxy bẩn
                a['status'] = 'DEAD'
            a['login_status'] = why[:120]
            a['scenario_note'] = why[:200]
            a['dead_reason'] = why[:200]
            a['dead_at'] = now
            a['discard_recommend'] = True
            summary['dead_uids'].append(str(a.get('uid') or ''))

        # 1) nick nguồn
        if not self._is_dead_pool(acc) or reason:
            _mark_dead(acc, reason)

        # 2) proxy + nick cùng proxy
        if quarantine_related and (px or '').strip():
            self._quarantine_proxy_record(px, reason=f'Ngưng — nick {uid} {reason}')
            for other in self.accounts:
                if other is acc:
                    continue
                if not self._proxy_matches(other.get('proxy'), px):
                    continue
                was_live = 'LIVE' in str(other.get('status') or '').upper() and not self._is_dead_pool(other)
                if was_live:
                    summary['quarantined_live'].append(str(other.get('uid') or ''))
                _mark_dead(
                    other,
                    f'CÁCH LY — cùng proxy với nick checkpoint {uid} (bảo vệ farm)',
                )

        try:
            self.save_accounts()
            self.save_proxies()
        except Exception:
            pass
        try:
            self._ui_call(self._ui_refresh_dead_and_live)
        except Exception:
            pass
        return summary

    def _ui_refresh_dead_and_live(self):
        try:
            self.refresh_table()
            self.refresh_proxy_table()
            self.refresh_dead_table()
        except Exception:
            pass

    def on_checkpoint_detected(self, acc, reason='CHECKPOINT'):
        """Central: nick CP → Acc chết + ngưng proxy + cách ly nick cùng proxy."""
        try:
            sm = self.move_account_to_dead(acc, reason=reason, quarantine_related=True)
            uid = acc.get('uid')
            self.log(f"[{uid}] ☠ → Acc chết | proxy ngưng: {sm.get('proxy')}")
            if sm.get('quarantined_live'):
                self.log(
                    f"[{uid}] 🛡 Cách ly {len(sm['quarantined_live'])} nick LIVE cùng proxy: "
                    f"{', '.join(sm['quarantined_live'][:8])}"
                )
            return sm
        except Exception as ex:
            self.log(f"Lỗi on_checkpoint: {ex}")
            return {}

    def _migrate_existing_dead_pool(self):
        """Lần đầu: CHECKPOINT → Acc chết + cách ly proxy; DIE → Acc chết (giữ proxy nếu không dính CP)."""
        moved = 0
        for acc in list(self.accounts):
            if acc.get('pool') == 'dead':
                continue
            st = str(acc.get('status') or '').upper()
            reason = acc.get('scenario_note') or acc.get('login_status') or st or 'DEAD'
            if 'CHECKPOINT' in st or acc.get('discard_recommend') or acc.get('discard_reason'):
                self.move_account_to_dead(acc, reason=reason, quarantine_related=True)
                moved += 1
            elif st in ('DIE', 'DEAD', 'NO COOKIE'):
                self.move_account_to_dead(acc, reason=reason, quarantine_related=False)
                moved += 1
        if moved:
            self.log(f"🛡 Bảo vệ: đã chuyển {moved} nick → tab Acc chết (CP thì cách ly proxy)")

    def _confirm_danger_login_dead(self, accs):
        """
        User cố login/chạy nick Acc chết → cảnh báo NGUY HIỂM 2 lần.
        Trả True chỉ khi bấm Yes cả 2 lần.
        """
        if not accs:
            return False
        uids = [str(a.get('uid') or '') for a in accs[:8]]
        more = f" … (+{len(accs)-8})" if len(accs) > 8 else ""
        msg1 = (
            "⛔ CẢNH BÁO NGUY HIỂM (1/2)\n\n"
            "Bạn đang cố đăng nhập / chạy nick trong tab «Acc chết».\n"
            f"Nick: {', '.join(uids)}{more}\n\n"
            "• Nick checkpoint/die hoặc bị cách ly vì cùng proxy bẩn\n"
            "• Đăng nhập lại dễ làm hỏng thêm proxy & nick LIVE còn lại\n\n"
            "Vẫn muốn tiếp tục?"
        )
        if not messagebox.askyesno("⛔ NGUY HIỂM (1/2)", msg1, icon='warning'):
            return False
        msg2 = (
            "⛔ CẢNH BÁO NGUY HIỂM (2/2) — XÁC NHẬN LẦN CUỐI\n\n"
            "Tiếp tục login nick chết/cách ly có thể:\n"
            "• Làm nóng proxy → dính checkpoint hàng loạt\n"
            "• Mất session các nick đang LIVE\n\n"
            "Chỉ tiếp tục nếu bạn CHẮC CHẮN và chấp nhận rủi ro.\n\n"
            "Xác nhận lần 2?"
        )
        if not messagebox.askyesno("⛔ NGUY HIỂM (2/2)", msg2, icon='error'):
            return False
        self.log(
            f"⚠ User xác nhận NGUY HIỂM 2 lần — force dùng {len(accs)} nick Acc chết: "
            f"{', '.join(uids)}"
        )
        return True

    def _filter_active_indices(self, indices, task_label='TASK', allow_dead_with_confirm=False):
        """Loại nick Acc chết / proxy cách ly. Có thể force sau 2 cảnh báo."""
        if indices is None:
            indices = list(range(len(self.accounts)))
        active, dead = [], []
        for idx in indices:
            if idx < 0 or idx >= len(self.accounts):
                continue
            acc = self.accounts[idx]
            px = acc.get('proxy') or ''
            if self._is_dead_pool(acc) or self._is_proxy_quarantined(px):
                dead.append(idx)
            else:
                active.append(idx)
        if dead:
            uids = [str(self.accounts[i].get('uid') or i) for i in dead[:6]]
            self.log(
                f"[{task_label}] 🛡 Bảo vệ: bỏ qua {len(dead)} nick Acc chết/cách ly: "
                f"{', '.join(uids)}" + ('…' if len(dead) > 6 else '')
            )
            if allow_dead_with_confirm and not active:
                # chỉ khi toàn bộ selection là dead và user force
                if self._confirm_danger_login_dead([self.accounts[i] for i in dead]):
                    return dead
                return []
        return active

    def filter_indices_require_proxy(self, indices, task_label='TASK'):
        """
        Chỉ giữ nick CÒN SỐNG có proxy. Nick không proxy → CHỜ.
        Nick Acc chết / proxy cách ly → bỏ qua (bảo vệ).
        """
        if indices is None:
            indices = list(range(len(self.accounts)))
        # Bảo vệ: loại dead pool trước
        indices = self._filter_active_indices(indices, task_label=task_label)
        require = bool(self.var_require_proxy.get()) if hasattr(self, 'var_require_proxy') else True
        if not require:
            return list(indices)

        ready, waiting = [], []
        for idx in indices:
            if idx < 0 or idx >= len(self.accounts):
                continue
            acc = self.accounts[idx]
            if self._is_dead_pool(acc) or self._is_proxy_quarantined(acc.get('proxy')):
                continue
            if self.account_has_proxy(acc):
                ready.append(idx)
            else:
                waiting.append(acc.get('uid', str(idx)))

        extra_proxies = 0
        assigned = {
            self._normalize_proxy_key(a.get('proxy', ''))
            for a in self.accounts
            if self.account_has_proxy(a) and not self._is_dead_pool(a)
        }
        for p in self.proxies:
            if p.get('quarantine') or p.get('pool') == 'dead':
                continue
            k = self._normalize_proxy_key(p.get('proxy', ''))
            if k and k not in assigned:
                extra_proxies += 1

        if waiting:
            self.log(
                f"[{task_label}] {len(waiting)} nick KHÔNG có proxy → BỎ QUA "
                f"(không login/chạy): {', '.join(str(u) for u in waiting[:8])}"
                + ('...' if len(waiting) > 8 else '')
            )
        if extra_proxies:
            self.log(f"[{task_label}] {extra_proxies} proxy DƯ (chưa gán nick) → để đó.")
        self.log(f"[{task_label}] Sẵn sàng chạy (có proxy): {len(ready)}/{len(indices) or len(ready)} nick")

        if not ready:
            messagebox.showwarning(
                "Bắt buộc Proxy",
                "Không có tài khoản nào có proxy để chạy.\n\n"
                "Quy tắc: chỉ login & chạy tính năng khi nick đã gán proxy.\n"
                "Nick không proxy sẽ BỎ QUA (không login).\n"
                "Proxy dư giữ nguyên (không tự gán).\n\n"
                "Hãy gán proxy (tab Proxy / Quét gán 3 nick/1 proxy) rồi thử lại.",
            )
            return []
        if waiting:
            # Tóm tắt rõ khi user chọn lẫn nick có/không proxy
            try:
                sample = ', '.join(str(u) for u in waiting[:6])
                more = f' …(+{len(waiting)-6})' if len(waiting) > 6 else ''
                self.log(
                    f"[{task_label}] ℹ Sẽ CHẠY {len(ready)} nick có proxy | "
                    f"BỎ QUA {len(waiting)} nick chưa gán proxy ({sample}{more})"
                )
            except Exception:
                pass
        return ready

    def add_report(self, uid, task, target, status, message='', link=''):
        item = {
            'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'uid': uid or '',
            'task': task or '',
            'target': target or '',
            'status': status or '',
            'message': message or '',
            'link': link or '',
        }
        with self._data_lock:
            self.reports.append(item)
        try:
            self.save_reports()
        except Exception:
            pass
        icon = '✓' if str(status).upper() in ('SUCCESS', 'OK', 'LIVE', 'JOINED', 'PENDING') else '✗'
        self.log(f"[BÁO CÁO] {icon} [{uid}] {task} | {target} | {status} | {message}" + (f" | {link}" if link else ''))
        self._ui_call(self.refresh_report_table)
        return item

    def load_proxies(self):
        """Load structured proxy list; bootstrap from proxies.txt if needed."""
        proxies = []
        if os.path.exists(PROXY_DATA_FILE):
            try:
                with open(PROXY_DATA_FILE, 'r', encoding='utf-8') as f:
                    proxies = json.load(f)
            except Exception:
                proxies = []

        if not proxies and os.path.exists(PROXY_FILE):
            proxies = self._import_proxy_lines_from_file(PROXY_FILE)

        for p in proxies:
            p.setdefault('proxy', '')
            p.setdefault('status', 'Chưa check')
            p.setdefault('expire_date', '')
            p.setdefault('note', '')
            p.setdefault('last_check', '')
        return proxies

    def save_proxies(self):
        with open(PROXY_DATA_FILE, 'w', encoding='utf-8') as f:
            json.dump(self.proxies, f, indent=4, ensure_ascii=False)
        # Keep plain text file in sync for compatibility
        try:
            with open(PROXY_FILE, 'w', encoding='utf-8') as f:
                f.write("# Form: IP:PORT:USER:PASS  hoac  IP:PORT:USER:PASS|YYYY-MM-DD\n")
                for p in self.proxies:
                    line = p.get('proxy', '')
                    if p.get('expire_date'):
                        line = f"{line}|{p['expire_date']}"
                    f.write(line + '\n')
        except Exception:
            pass
        self._schedule_cloud_push()

    def _import_proxy_lines_from_file(self, path):
        result = []
        try:
            with open(path, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
                    proxy_str, expire = self._parse_proxy_line(line)
                    if proxy_str:
                        result.append({
                            'proxy': proxy_str,
                            'status': 'Chưa check',
                            'expire_date': expire,
                            'note': '',
                            'last_check': '',
                        })
        except Exception:
            pass
        return result

    def _is_valid_ipv4(self, ip):
        parts = str(ip).split('.')
        if len(parts) != 4:
            return False
        try:
            return all(p.isdigit() and 0 <= int(p) <= 255 and (p == '0' or not p.startswith('0') or len(p) == 1) for p in parts)
        except Exception:
            return False

    def _is_valid_port(self, port):
        try:
            n = int(str(port))
            return 1 <= n <= 65535
        except Exception:
            return False

    def is_valid_proxy_format(self, proxy_str):
        """
        Chỉ chấp nhận:
          IP:PORT
          IP:PORT:USER:PASS
          user:pass@ip:port  (sẽ normalize)
        """
        return bool(self._normalize_proxy_key(proxy_str))

    def _parse_proxy_line(self, line):
        """
        Accept (đúng form):
          IP:PORT
          IP:PORT:USER:PASS
          user:pass@ip:port
          ...|YYYY-MM-DD   (optional expire)
        Return (normalized IP:PORT[:USER:PASS], expire_date)
        Sai form → ('', expire) — KHÔNG cho gán.
        """
        line = (line or '').strip()
        expire = ''
        if not line or line.startswith('#'):
            return '', ''

        if '|' in line:
            left, right = line.rsplit('|', 1)
            right = right.strip()
            if re.match(r'^\d{4}-\d{2}-\d{2}$', right):
                line = left.strip()
                expire = right

        # user:pass@ip:port
        m = re.match(r'^([^:\s/@]+):([^@\s]+)@(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$', line)
        if m:
            user, password, ip, port = m.groups()
            if self._is_valid_ipv4(ip) and self._is_valid_port(port) and user and password:
                return f'{ip}:{port}:{user}:{password}', expire
            return '', expire

        # IP:PORT
        m = re.match(r'^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$', line)
        if m:
            ip, port = m.groups()
            if self._is_valid_ipv4(ip) and self._is_valid_port(port):
                return f'{ip}:{port}', expire
            return '', expire

        # IP:PORT:USER:PASS  (USER/PASS không chứa dấu :)
        m = re.match(r'^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5}):([^:\s]+):(.+)$', line)
        if m:
            ip, port, user, password = m.groups()
            password = password.strip()
            if self._is_valid_ipv4(ip) and self._is_valid_port(port) and user and password:
                return f'{ip}:{port}:{user}:{password}', expire
            return '', expire

        # Sai định dạng
        return '', expire

    def _normalize_proxy_key(self, proxy_str):
        if not proxy_str:
            return ''
        p, _ = self._parse_proxy_line(str(proxy_str).strip())
        return p  # rỗng nếu sai form

    def _get_assigned_map(self):
        """proxy_str -> list of account uids"""
        mapping = {}
        for acc in self.accounts:
            px = self._normalize_proxy_key(acc.get('proxy', ''))
            if px:
                mapping.setdefault(px, []).append(acc.get('uid', ''))
        return mapping

    def _sync_proxy_assignments(self):
        """Ensure every account proxy appears in proxy list."""
        known = {self._normalize_proxy_key(p.get('proxy', '')) for p in self.proxies}
        changed = False
        for acc in self.accounts:
            px = self._normalize_proxy_key(acc.get('proxy', ''))
            if px and px not in known:
                self.proxies.append({
                    'proxy': px,
                    'status': 'Chưa check',
                    'expire_date': '',
                    'note': 'Tự thêm từ tài khoản',
                    'last_check': '',
                })
                known.add(px)
                changed = True
        if changed:
            self.save_proxies()

    def _proxy_expire_info(self, expire_date):
        if not expire_date:
            return 'Chưa set', 'N/A'
        try:
            exp = datetime.strptime(expire_date, '%Y-%m-%d').date()
            today = datetime.now().date()
            days = (exp - today).days
            if days < 0:
                return 'HẾT HẠN', f'{abs(days)} ngày trước'
            if days == 0:
                return 'Hết hôm nay', '0 ngày'
            if days <= 3:
                return 'Sắp hết', f'Còn {days} ngày'
            return 'Còn hạn', f'Còn {days} ngày'
        except ValueError:
            return 'Sai ngày', expire_date

    def _build_proxy_url(self, proxy_str):
        proxy_str = self._normalize_proxy_key(proxy_str)
        parts = proxy_str.split(':')
        if len(parts) == 4:
            return f"http://{parts[2]}:{parts[3]}@{parts[0]}:{parts[1]}"
        if len(parts) == 2:
            return f"http://{parts[0]}:{parts[1]}"
        return f"http://{proxy_str}"

    def _build_playwright_proxy(self, proxy_str):
        proxy_str = self._normalize_proxy_key(proxy_str or '')
        if not proxy_str:
            return None
        parts = proxy_str.split(':')
        if len(parts) == 4:
            return {
                "server": f"http://{parts[0]}:{parts[1]}",
                "username": parts[2],
                "password": parts[3],
            }
        return {"server": f"http://{proxy_str}"}

    # ------------------------------------------------------------------
    # UI
    # ------------------------------------------------------------------
    def create_widgets(self):
        # ===== TOP APP BAR (2026) =====
        header = tk.Frame(self.root, bg=UI['header_grad_top'], highlightthickness=0)
        header.pack(fill='x')
        self._header_frame = header
        # accent line
        tk.Frame(self.root, bg=UI['accent'], height=2).pack(fill='x')

        brand = tk.Frame(header, bg=UI['header_grad_top'])
        brand.pack(side='left', padx=16, pady=10)
        tk.Label(
            brand,
            text="AI Maketing Facebook",
            font=UI['font_title'],
            bg=UI['header_grad_top'],
            fg=UI['text'],
        ).pack(anchor='w')
        self.lbl_user_bar = tk.Label(
            brand,
            text="",
            font=UI['font_sub'],
            bg=UI['header_grad_top'],
            fg=UI['muted'],
        )
        self.lbl_user_bar.pack(anchor='w')
        self._refresh_user_header()

        hdr_btns = tk.Frame(header, bg=UI['header_grad_top'])
        hdr_btns.pack(side='right', padx=14, pady=10)
        theme_label = "☀  Sáng" if self.theme_mode == 'dark' else "🌙  Tối"
        self._mk_header_btn(hdr_btns, theme_label, self.toggle_theme, kind='ghost').pack(side='left', padx=4)
        self.btn_stop_all = self._mk_header_btn(hdr_btns, "⏹  Dừng lệnh", self.stop_all_jobs, kind='danger')
        self.btn_stop_all.config(state='disabled')
        self.btn_stop_all.pack(side='left', padx=4)
        self._mk_header_btn(hdr_btns, "📋  Lệnh chạy / chờ", self.show_job_list_dialog, kind='ghost').pack(side='left', padx=4)
        # Phím tắt Esc = dừng lệnh
        try:
            self.root.bind('<Escape>', lambda e: self.stop_all_jobs())
        except Exception:
            pass
        self._mk_header_btn(hdr_btns, "▶  Kịch bản hôm nay", self.start_scenario_today, kind='accent').pack(side='left', padx=4)
        self._mk_header_btn(hdr_btns, "Đăng xuất", self.logout_user, kind='ghost').pack(side='left', padx=4)

        # Paned dọc: tabs (trên) | Job+Log (dưới) — kéo thanh chia để xem log
        self.main_pane = ttk.Panedwindow(self.root, orient='vertical')
        self.main_pane.pack(expand=True, fill='both', padx=12, pady=(10, 10))

        body = ttk.Frame(self.main_pane, style='TFrame')
        self.dock_frame = ttk.Frame(self.main_pane, style='TFrame')
        self.main_pane.add(body, weight=4)
        # dock thêm sau khi build xong nội dung (tránh pane rỗng)

        notebook = ttk.Notebook(body)
        notebook.pack(expand=True, fill='both')
        self.notebook = notebook

        # --- TAB 0: App account / package / theme ---
        tab_app = ttk.Frame(notebook)
        notebook.add(tab_app, text='  🪪  Tài khoản app  ')
        self._build_app_account_tab(tab_app)

        # --- TAB 1: FB Accounts ---
        # Layout: bảng nick (trên, co giãn) + thanh CHỨC NĂNG ghim dưới (luôn thấy)
        tab1 = ttk.Frame(notebook)
        notebook.add(tab1, text='  👤  Nick FB  ')

        # ===== 1) Thanh chức năng Ghim DƯỚI (pack bottom trước) =====
        actions = ttk.LabelFrame(
            tab1,
            text="  ⚡  CHỨC NĂNG NICK FB — luôn hiện dưới bảng  ",
        )
        actions.pack(side='bottom', fill='x', padx=8, pady=(4, 8))

        self.acc_summary = ttk.Label(actions, text="", style='Status.TLabel')
        self.acc_summary.pack(anchor='w', padx=10, pady=(6, 2))

        # Hàng 1 — quản lý danh sách
        row_mgmt = ttk.LabelFrame(actions, text=" 1. Quản lý tài khoản ")
        row_mgmt.pack(fill='x', padx=8, pady=3)
        btn_frame1 = ttk.Frame(row_mgmt)
        btn_frame1.pack(fill='x', padx=6, pady=4)
        ttk.Button(btn_frame1, text="Quét từ file (import_accounts.txt)", command=self.scan_and_import_accounts).pack(side='left', padx=3)
        ttk.Button(btn_frame1, text="Thêm Hàng Loạt (Dán)", style='Accent.TButton', command=self.show_bulk_add_dialog).pack(side='left', padx=3)
        ttk.Button(btn_frame1, text="Thêm Thủ Công", command=self.show_add_account_dialog).pack(side='left', padx=3)
        ttk.Button(btn_frame1, text="Xóa Tài Khoản", style='Danger.TButton', command=self.delete_account).pack(side='left', padx=3)
        ttk.Button(btn_frame1, text="Xóa Toàn Bộ", style='Danger.TButton', command=self.delete_all_accounts).pack(side='left', padx=3)

        # Hàng 2 — nhóm
        row_grp = ttk.LabelFrame(actions, text=" 2. Nhóm chất lượng ")
        row_grp.pack(fill='x', padx=8, pady=3)
        btn_grp = ttk.Frame(row_grp)
        btn_grp.pack(fill='x', padx=6, pady=4)
        ttk.Label(btn_grp, text="Gán nick đang chọn:", style='Accent.TLabel').pack(side='left', padx=(0, 6))
        ttk.Button(
            btn_grp, text="🌱 → Mới",
            command=lambda: self.assign_selected_acc_group(ACC_GROUP_MOI),
        ).pack(side='left', padx=3)
        ttk.Button(
            btn_grp, text="🐂 → Trâu",
            command=lambda: self.assign_selected_acc_group(ACC_GROUP_TRAU),
        ).pack(side='left', padx=3)
        ttk.Button(
            btn_grp, text="💎 → Siêu chất lượng",
            style='Accent.TButton',
            command=lambda: self.assign_selected_acc_group(ACC_GROUP_SIEU),
        ).pack(side='left', padx=3)
        ttk.Button(btn_grp, text="📋 Gán nhóm hàng loạt…", command=self.show_bulk_acc_group_dialog).pack(side='left', padx=6)
        ttk.Button(btn_grp, text="✓ Chọn hết nick đang lọc", command=self.select_all_visible_accounts).pack(side='left', padx=3)

        # Hàng 3 — proxy + check live (nổi bật)
        row_live = ttk.LabelFrame(actions, text=" 3. Proxy & Check Live (an toàn) ")
        row_live.pack(fill='x', padx=8, pady=3)
        btn_frame2 = ttk.Frame(row_live)
        btn_frame2.pack(fill='x', padx=6, pady=4)
        ttk.Button(btn_frame2, text="★ Quét proxy + Gán 3 nick/1 proxy", style='Accent.TButton', command=self.scan_folder_check_and_assign).pack(side='left', padx=3)
        ttk.Button(btn_frame2, text="🌍 Định vị + Gợi ý Proxy", command=self.open_geo_proxy_wizard).pack(side='left', padx=3)
        ttk.Button(btn_frame2, text="Gán Proxy Thủ Công", command=self.show_bulk_proxy_dialog).pack(side='left', padx=3)
        ttk.Button(
            btn_frame2,
            text="✓ Check Live An Toàn (từng nick)",
            style='Accent.TButton',
            command=self.check_all_fb_live,
        ).pack(side='left', padx=3)
        ttk.Button(
            btn_frame2,
            text="✓ Check Live Nick Đang Chọn",
            command=self.check_selected_fb_live,
        ).pack(side='left', padx=3)
        ttk.Button(
            btn_frame2,
            text="🛡 Verify Checkpoint",
            command=self.start_verify_checkpoints_assist,
        ).pack(side='left', padx=3)

        ttk.Label(
            row_live,
            text="An toàn: 1 nick/lần · DIE xác nhận 2 lần · tự dừng nếu die/lỗi hàng loạt · không cách ly farm",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=10, pady=(0, 4))

        # Hàng 4 — profile
        row_prof = ttk.LabelFrame(actions, text=" 4. Đổi profile (chỉ nick MỚI) ")
        row_prof.pack(fill='x', padx=8, pady=(3, 6))
        btn_frame3 = ttk.Frame(row_prof)
        btn_frame3.pack(fill='x', padx=6, pady=4)
        ttk.Button(btn_frame3, text="🎲 Random tên profile", command=self.assign_random_names_to_new).pack(side='left', padx=3)
        ttk.Button(btn_frame3, text="▶ Đổi TÊN", command=self.start_bulk_rename_new).pack(side='left', padx=3)
        ttk.Button(btn_frame3, text="▶ Đổi AVATAR", command=self.start_bulk_avatar_new).pack(side='left', padx=3)
        ttk.Button(btn_frame3, text="▶ Đổi TÊN + AVATAR", style='Accent.TButton', command=self.start_bulk_profile_new).pack(side='left', padx=3)
        ttk.Button(btn_frame3, text="⬇ Xem Job / Log", command=self.scroll_to_processing_area).pack(side='right', padx=6)

        # ===== 2) Phần trên: lọc + bảng nick (chiếm chỗ còn lại) =====
        top = ttk.Frame(tab1)
        top.pack(side='top', fill='both', expand=True)

        grp_bar = ttk.Frame(top)
        grp_bar.pack(fill='x', padx=10, pady=(8, 2))
        ttk.Label(grp_bar, text="Nhóm tài khoản:", style='Accent.TLabel').pack(side='left', padx=(0, 6))
        self.var_acc_group_filter = tk.StringVar(value='all')
        self.cmb_acc_group_filter = ttk.Combobox(
            grp_bar,
            textvariable=self.var_acc_group_filter,
            state='readonly',
            width=22,
            values=(
                'all|Tất cả nhóm',
                f'{ACC_GROUP_MOI}|🌱 Tài khoản Mới',
                f'{ACC_GROUP_TRAU}|🐂 Tài khoản Trâu',
                f'{ACC_GROUP_SIEU}|💎 Siêu chất lượng (cá nhân)',
            ),
        )
        self.cmb_acc_group_filter.current(0)
        self.cmb_acc_group_filter.pack(side='left', padx=4)
        self.cmb_acc_group_filter.bind('<<ComboboxSelected>>', lambda e: self.refresh_table())
        ttk.Label(
            grp_bar,
            text="Bảng nick phía dưới · Chức năng nằm cố định ở đáy tab (kéo cửa sổ cao hơn nếu cần)",
            style='Muted.TLabel',
        ).pack(side='left', padx=10)

        tree_wrap = ttk.Frame(top)
        tree_wrap.pack(fill='both', expand=True, padx=10, pady=(2, 4))
        columns = ('ID', 'UID / Email', 'Mật khẩu', '2FA', 'Cookie', 'Proxy', 'Tên profile FB', 'Nhóm', 'Trạng thái')
        self.tree = ttk.Treeview(tree_wrap, columns=columns, show='headings', height=8)
        tree_sb = ttk.Scrollbar(tree_wrap, orient='vertical', command=self.tree.yview)
        self.tree.configure(yscrollcommand=tree_sb.set)
        self.tree.heading('ID', text='STT')
        self.tree.column('ID', width=40, anchor='center')
        self.tree.heading('UID / Email', text='UID / Email')
        self.tree.column('UID / Email', width=110)
        self.tree.heading('Mật khẩu', text='Mật khẩu')
        self.tree.column('Mật khẩu', width=70)
        self.tree.heading('2FA', text='Mã 2FA')
        self.tree.column('2FA', width=100)
        self.tree.heading('Cookie', text='Cookie')
        self.tree.column('Cookie', width=80)
        self.tree.heading('Proxy', text='Proxy')
        self.tree.column('Proxy', width=140)
        self.tree.heading('Tên profile FB', text='Tên profile FB (sẽ đổi)')
        self.tree.column('Tên profile FB', width=140)
        self.tree.heading('Nhóm', text='Nhóm TK')
        self.tree.column('Nhóm', width=110, anchor='center')
        self.tree.heading('Trạng thái', text='Trạng thái')
        self.tree.column('Trạng thái', width=100, anchor='center')
        tree_sb.pack(side='right', fill='y')
        self.tree.pack(side='left', fill='both', expand=True)

        self.refresh_table()

        # --- TAB ACC CHẾT (checkpoint / die / cách ly proxy) ---
        tab_dead = ttk.Frame(notebook)
        notebook.add(tab_dead, text='  ☠  Acc chết  ')
        ttk.Label(
            tab_dead,
            text="Acc chết / Checkpoint / Cách ly proxy — KHÔNG chạy job. Bảo vệ nick LIVE còn lại.",
            style='Danger.TLabel',
        ).pack(anchor='w', padx=12, pady=(10, 2))
        ttk.Label(
            tab_dead,
            text=(
                "Khi 1 nick dính CHECKPOINT: nick → tab này, proxy NGƯNG DÙNG, "
                "mọi nick LIVE cùng proxy cũng bị cách ly. "
                "Cố login lại → cảnh báo NGUY HIỂM 2 lần."
            ),
            style='Muted.TLabel',
            wraplength=1100,
        ).pack(anchor='w', padx=12, pady=(0, 6))

        dead_cols = ('ID', 'UID', 'Email', 'Proxy', 'Trạng thái', 'Lý do', 'Thời điểm')
        self.dead_tree = ttk.Treeview(tab_dead, columns=dead_cols, show='headings', height=10)
        for c, w in (
            ('ID', 40), ('UID', 130), ('Email', 180), ('Proxy', 160),
            ('Trạng thái', 100), ('Lý do', 280), ('Thời điểm', 130),
        ):
            self.dead_tree.heading(c, text=c)
            self.dead_tree.column(c, width=w, anchor='w' if c in ('Email', 'Lý do', 'Proxy') else 'center')
        self.dead_tree.pack(expand=True, fill='both', padx=10, pady=4)

        self.dead_summary = ttk.Label(tab_dead, text="", font=("Arial", 9, "bold"))
        self.dead_summary.pack(anchor='w', padx=12, pady=2)

        ttk.Label(tab_dead, text="Proxy đã NGƯNG DÙNG (cách ly):", style='Accent.TLabel').pack(
            anchor='w', padx=12, pady=(8, 2),
        )
        px_dead_cols = ('ID', 'Proxy', 'Trạng thái', 'Ghi chú', 'Cách ly lúc')
        self.dead_proxy_tree = ttk.Treeview(tab_dead, columns=px_dead_cols, show='headings', height=5)
        for c, w in (
            ('ID', 40), ('Proxy', 280), ('Trạng thái', 100), ('Ghi chú', 320), ('Cách ly lúc', 130),
        ):
            self.dead_proxy_tree.heading(c, text=c)
            self.dead_proxy_tree.column(c, width=w)
        self.dead_proxy_tree.pack(fill='x', padx=10, pady=4)

        dbtn = ttk.Frame(tab_dead)
        dbtn.pack(fill='x', padx=10, pady=6)
        ttk.Button(dbtn, text="Làm mới", command=self.refresh_dead_table).pack(side='left', padx=4)
        ttk.Button(
            dbtn,
            text="⛔ Force Login nick đã chọn (cảnh báo 2 lần)",
            style='Danger.TButton',
            command=self.force_login_dead_selected,
        ).pack(side='left', padx=4)
        ttk.Button(
            dbtn,
            text="Khôi phục nick đã chọn → Nick FB (cẩn thận)",
            command=self.restore_dead_selected,
        ).pack(side='left', padx=4)
        ttk.Button(
            dbtn,
            text="Xóa hẳn nick đã chọn",
            style='Danger.TButton',
            command=self.delete_dead_selected,
        ).pack(side='left', padx=4)

        self.refresh_dead_table()

        # --- TAB PROXY MANAGER ---
        tab_proxy = ttk.Frame(notebook)
        notebook.add(tab_proxy, text='  🌐  Proxy  ')

        p_columns = ('ID', 'Proxy', 'Live', 'Gán', 'Nick gán', 'Hạn dùng', 'Còn lại', 'Ghi chú')
        self.proxy_tree = ttk.Treeview(tab_proxy, columns=p_columns, show='headings', height=12)
        self.proxy_tree.heading('ID', text='STT')
        self.proxy_tree.column('ID', width=40, anchor='center')
        self.proxy_tree.heading('Proxy', text='Proxy (IP:PORT:USER:PASS)')
        self.proxy_tree.column('Proxy', width=280)
        self.proxy_tree.heading('Live', text='Live')
        self.proxy_tree.column('Live', width=80, anchor='center')
        self.proxy_tree.heading('Gán', text='Trạng thái gán')
        self.proxy_tree.column('Gán', width=100, anchor='center')
        self.proxy_tree.heading('Nick gán', text='UID đã gán')
        self.proxy_tree.column('Nick gán', width=180)
        self.proxy_tree.heading('Hạn dùng', text='Hạn dùng')
        self.proxy_tree.column('Hạn dùng', width=90, anchor='center')
        self.proxy_tree.heading('Còn lại', text='Còn lại')
        self.proxy_tree.column('Còn lại', width=100, anchor='center')
        self.proxy_tree.heading('Ghi chú', text='Ghi chú')
        self.proxy_tree.column('Ghi chú', width=120)
        self.proxy_tree.pack(expand=True, fill='both', padx=10, pady=5)

        self.proxy_summary = ttk.Label(tab_proxy, text="", font=("Arial", 9, "bold"))
        self.proxy_summary.pack(anchor='w', padx=12, pady=2)

        pbtn0 = ttk.Frame(tab_proxy)
        pbtn0.pack(fill='x', padx=10, pady=4)
        ttk.Button(
            pbtn0,
            text="★ QUÉT TOÀN BỘ PROXY TRONG THƯ MỤC → CHECK LIVE → GÁN 1 PROXY / 3 NICK",
            command=self.scan_folder_check_and_assign,
        ).pack(side='left', padx=4)
        ttk.Button(
            pbtn0,
            text="🌍 Định vị nick + Gợi ý mua Proxy theo nước",
            style='Accent.TButton',
            command=self.open_geo_proxy_wizard,
        ).pack(side='left', padx=4)

        pbtn1 = ttk.Frame(tab_proxy)
        pbtn1.pack(fill='x', padx=10, pady=3)
        ttk.Button(pbtn1, text="Chỉ quét thư mục (nạp proxy)", command=self.scan_proxies_in_folder_only).pack(side='left', padx=4)
        ttk.Button(pbtn1, text="Nạp từ proxies.txt", command=self.import_proxies_from_file).pack(side='left', padx=4)
        ttk.Button(pbtn1, text="Thêm Proxy (Dán)", command=self.show_add_proxies_dialog).pack(side='left', padx=4)
        ttk.Button(pbtn1, text="Check Live Proxy", command=self.check_all_proxies_live).pack(side='left', padx=4)
        ttk.Button(pbtn1, text="Gán lại 3 nick/1 proxy", command=self.auto_assign_proxies).pack(side='left', padx=4)

        pbtn2 = ttk.Frame(tab_proxy)
        pbtn2.pack(fill='x', padx=10, pady=3)
        ttk.Button(pbtn2, text="Bỏ gán proxy đã chọn", command=self.unassign_selected_proxy).pack(side='left', padx=4)
        ttk.Button(pbtn2, text="Set hạn dùng (đã chọn)", command=self.set_expire_selected).pack(side='left', padx=4)
        ttk.Button(pbtn2, text="Set hạn tất cả (+N ngày)", command=self.set_expire_all_days).pack(side='left', padx=4)
        ttk.Button(pbtn2, text="Xóa proxy đã chọn", command=self.delete_selected_proxies).pack(side='left', padx=4)
        ttk.Button(pbtn2, text="Xóa toàn bộ proxy", command=self.delete_all_proxies).pack(side='left', padx=4)
        ttk.Button(pbtn2, text="Làm mới bảng", command=self.refresh_proxy_table).pack(side='left', padx=4)

        hint = ttk.Label(
            tab_proxy,
            text=(
                "★ Nút QUÉT: đọc mọi file .txt có proxy trong thư mục app "
                "(proxies.txt, proxy_list*.txt, ...) → check live → gán 1 proxy cho 3 tài khoản.  "
                "Form: IP:PORT:USER:PASS | user:pass@ip:port | ...|YYYY-MM-DD"
            ),
            style='Muted.TLabel',
        )
        hint.pack(anchor='w', padx=12, pady=4)

        self.refresh_proxy_table()

        # --- TAB LOGIN + RUNTIME ---
        tab_login = ttk.Frame(notebook)
        notebook.add(tab_login, text='  🔐  Đăng nhập  ')
        ttk.Label(
            tab_login,
            text="Chế độ trình duyệt: Ẩn (nhẹ nhất) hoặc Hiện mobile gọn (cửa sổ nhỏ, xếp ô).",
            style='Success.TLabel',
        ).pack(anchor='w', padx=15, pady=(12, 4))
        ttk.Label(
            tab_login,
            text=(
                "Login: Cookie còn sống → dùng cookie; DIE → UID/Pass + 2FA. "
                "Mobile mode: viewport điện thoại + cửa sổ nhỏ → nhẹ máy, gọn màn hình."
            ),
            style='Muted.TLabel',
            wraplength=1100,
        ).pack(anchor='w', padx=15, pady=4)

        # Runtime settings
        rt = ttk.LabelFrame(tab_login, text="Cấu hình chạy (áp dụng mọi tính năng)")
        rt.pack(fill='x', padx=15, pady=10)
        self.var_force_relogin = tk.BooleanVar(value=False)

        # Chế độ hiển thị
        r0 = ttk.Frame(rt)
        r0.pack(fill='x', padx=10, pady=6)
        ttk.Label(r0, text="Chế độ:", font=UI['font_ui_bold']).pack(side='left')

        def _sync_headless_from_mode(*_a):
            mode = self.var_run_mode.get()
            self.var_headless.set(mode == 'headless')
            # bật/tắt control mobile
            state = 'normal' if mode == 'mobile' else 'disabled'
            try:
                self.cmb_mobile_profile.configure(state='readonly' if mode == 'mobile' else 'disabled')
                self.chk_tile.configure(state=state)
            except Exception:
                pass

        ttk.Radiobutton(
            r0, text="🌑 Ẩn (headless) — nhẹ nhất, không hiện cửa sổ",
            variable=self.var_run_mode, value='headless', command=_sync_headless_from_mode,
        ).pack(side='left', padx=8)
        ttk.Radiobutton(
            r0, text="📱 Hiện mobile gọn — cửa sổ nhỏ, giả lập điện thoại",
            variable=self.var_run_mode, value='mobile', command=_sync_headless_from_mode,
        ).pack(side='left', padx=8)

        r0b = ttk.Frame(rt)
        r0b.pack(fill='x', padx=10, pady=4)
        ttk.Label(r0b, text="Kích thước mobile:").pack(side='left')
        # hiển thị nhãn đẹp; map ngược về key
        self._mobile_label_to_key = {
            v['label']: k for k, v in MOBILE_BROWSER_PROFILES.items()
        }
        self._mobile_key_to_label = {
            k: v['label'] for k, v in MOBILE_BROWSER_PROFILES.items()
        }
        self.cmb_mobile_profile = ttk.Combobox(
            r0b, width=42, state='disabled',
            values=list(self._mobile_key_to_label.values()),
        )
        cur_key = self.var_mobile_profile.get()
        self.cmb_mobile_profile.set(self._mobile_key_to_label.get(cur_key, list(self._mobile_key_to_label.values())[0]))
        self.cmb_mobile_profile.pack(side='left', padx=6)

        def _on_mobile_sel(_e=None):
            label = self.cmb_mobile_profile.get()
            key = self._mobile_label_to_key.get(label) or self.var_mobile_profile.get()
            if key in MOBILE_BROWSER_PROFILES:
                self.var_mobile_profile.set(key)
                meta = MOBILE_BROWSER_PROFILES[key]
                try:
                    self.lbl_mobile_info.config(
                        text=f"{meta['label']} · UA mobile · scale {meta['device_scale_factor']}"
                    )
                except Exception:
                    pass

        self.cmb_mobile_profile.bind('<<ComboboxSelected>>', _on_mobile_sel)
        self.chk_tile = ttk.Checkbutton(
            r0b, text="Xếp ô cửa sổ (tile) — không đè full màn",
            variable=self.var_tile_windows,
        )
        self.chk_tile.pack(side='left', padx=12)
        self.lbl_mobile_info = ttk.Label(rt, text="", style='Muted.TLabel')
        self.lbl_mobile_info.pack(anchor='w', padx=14, pady=(0, 4))
        _on_mobile_sel()
        _sync_headless_from_mode()

        r1 = ttk.Frame(rt)
        r1.pack(fill='x', padx=10, pady=6)
        ttk.Checkbutton(r1, text="Bắt buộc login lại (bỏ cookie cũ)", variable=self.var_force_relogin).pack(side='left', padx=4)

        r2 = ttk.Frame(rt)
        r2.pack(fill='x', padx=10, pady=6)
        ttk.Label(r2, text="Trình duyệt:").pack(side='left')
        ttk.Radiobutton(r2, text="Chrome (Chromium)", variable=self.var_browser, value='chromium').pack(side='left', padx=8)
        ttk.Radiobutton(r2, text="Firefox", variable=self.var_browser, value='firefox').pack(side='left', padx=8)

        r3 = ttk.Frame(rt)
        r3.pack(fill='x', padx=10, pady=6)
        ttk.Checkbutton(
            r3,
            text="BẮT BUỘC có Proxy — không proxy thì không login/chạy; nick thiếu proxy CHỜ; proxy dư để đó",
            variable=self.var_require_proxy,
        ).pack(side='left', padx=4)

        r_par = ttk.Frame(rt)
        r_par.pack(fill='x', padx=10, pady=6)
        ttk.Label(r_par, text="Luồng song song (máy mạnh):", font=UI['font_ui_bold']).pack(side='left')
        self.cmb_parallel_workers = ttk.Combobox(
            r_par,
            textvariable=self.var_parallel_workers,
            width=6,
            state='readonly',
            values=[str(i) for i in range(1, 21)],
        )
        try:
            self.cmb_parallel_workers.set(str(self.var_parallel_workers.get() or '1'))
        except Exception:
            self.cmb_parallel_workers.set('1')
        self.cmb_parallel_workers.pack(side='left', padx=8)
        ttk.Label(
            r_par,
            text="1–20 luồng · Cùng 1 proxy tối đa 2 nick · Headless khuyến nghị khi ≥3",
            style='Muted.TLabel',
        ).pack(side='left', padx=6)

        lim = ttk.LabelFrame(tab_login, text="Giới hạn gợi ý / ngày / 1 nick (Meta không cố định — mức an toàn thực tế)")
        lim.pack(fill='x', padx=15, pady=8)
        ttk.Label(
            lim,
            text=(
                "JOIN: Ít 5 | Vừa 12 | Tối đa 20  (rủi ro >20-30; tổng nhóm/nick ≤ 6000)\n"
                "COMMENT: Ít 12 | Vừa 35 | Tối đa 60  (rủi ro >100 comment+reply/ngày)\n"
                "ĐĂNG BÀI: Ít 3 | Vừa 6 | Tối đa 12  |  Chi tiết: LIMITS_GUIDE.txt"
            ),
            justify='left',
        ).pack(anchor='w', padx=10, pady=8)

        lf = ttk.Frame(tab_login)
        lf.pack(fill='x', padx=15, pady=12)
        ttk.Button(
            lf,
            text="🔐 Đăng nhập toàn bộ (KHÔNG xóa session)",
            style='Accent.TButton',
            command=self.start_login_all_keep_session,
        ).pack(side='left', padx=5)
        ttk.Button(
            lf,
            text="🔐 Làm mới session nick đang chọn",
            command=self.start_login_selected_keep_session,
        ).pack(side='left', padx=5)
        ttk.Button(lf, text="📊 Xem quota hôm nay", command=self.show_today_quota).pack(side='left', padx=5)

        lf2 = ttk.Frame(tab_login)
        lf2.pack(fill='x', padx=15, pady=(0, 8))
        ttk.Button(
            lf2,
            text="⚠ Login lại (cookie chết mới dùng pass) — Toàn bộ",
            command=self.start_login_all,
        ).pack(side='left', padx=5)
        ttk.Button(
            lf2,
            text="⚠ Login lại nick đang chọn",
            command=self.start_login_selected,
        ).pack(side='left', padx=5)
        ttk.Label(
            tab_login,
            text=(
                "• «Không xóa session»: nạp cookie → nếu LIVE thì refresh cookie, xong. Không clear, không login pass.\n"
                "• «Login lại»: chỉ khi cookie chết mới dùng UID/Pass/2FA. Tick «Bắt buộc login lại» nếu muốn ép pass khi cookie chết."
            ),
            style='Muted.TLabel',
            justify='left',
        ).pack(anchor='w', padx=15, pady=6)

        # --- TAB 2 ---
        tab2 = ttk.Frame(notebook)
        notebook.add(tab2, text='  🌱  Nuôi nick  ')

        self.var_read_post = tk.BooleanVar(value=True)
        self.var_auto_like = tk.BooleanVar(value=True)
        self.var_accept_friend = tk.BooleanVar(value=True)
        self.var_add_suggested = tk.BooleanVar(value=False)
        self.var_nurture_birthday = tk.BooleanVar(value=False)
        self.var_nurture_noti = tk.BooleanVar(value=True)

        ttk.Checkbutton(tab2, text="Lướt Newsfeed (scroll)", variable=self.var_read_post).pack(anchor='w', padx=20, pady=6)
        ttk.Checkbutton(tab2, text="Like ngẫu nhiên khi lướt", variable=self.var_auto_like).pack(anchor='w', padx=20, pady=6)
        ttk.Checkbutton(tab2, text="Accept lời mời kết bạn", variable=self.var_accept_friend).pack(anchor='w', padx=20, pady=6)
        ttk.Checkbutton(tab2, text="Kết bạn gợi ý (FB suggestions)", variable=self.var_add_suggested).pack(anchor='w', padx=20, pady=6)
        ttk.Checkbutton(tab2, text="Đọc thông báo", variable=self.var_nurture_noti).pack(anchor='w', padx=20, pady=6)
        ttk.Checkbutton(tab2, text="Chúc sinh nhật (nếu có)", variable=self.var_nurture_birthday).pack(anchor='w', padx=20, pady=6)
        ttk.Label(tab2, text="Chi tiết bạn bè / SN / group / fanpage: tab «💬 Tương tác».", style='Muted.TLabel').pack(anchor='w', padx=20, pady=4)

        par_fr = ttk.LabelFrame(tab2, text="  ⚡  Chạy song song (áp dụng nuôi nick & mọi job Playwright)  ")
        par_fr.pack(fill='x', padx=16, pady=(8, 4))
        par_row = ttk.Frame(par_fr)
        par_row.pack(fill='x', padx=10, pady=8)
        ttk.Label(par_row, text="Số luồng:").pack(side='left')
        ttk.Combobox(
            par_row,
            textvariable=self.var_parallel_workers,
            width=6,
            state='readonly',
            values=[str(i) for i in range(1, 21)],
        ).pack(side='left', padx=8)
        ttk.Label(
            par_row,
            text="Tối đa 20 · Cùng proxy ≤ 2 luồng · User tự chọn theo máy",
            style='Muted.TLabel',
        ).pack(side='left', padx=6)
        ttk.Label(
            par_fr,
            text="Khuyến nghị: headless khi ≥3 luồng. Proxy khác nhau mới tận dụng nhiều luồng.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=10, pady=(0, 8))

        ttk.Button(tab2, text="▶  Bắt đầu Nuôi Nick", style='Accent.TButton', command=self.start_nurture).pack(anchor='w', padx=20, pady=16)

        # --- TAB TƯƠNG TÁC ---
        tab_ix = ttk.Frame(notebook)
        notebook.add(tab_ix, text='  💬  Tương tác  ')
        self._build_interact_tab(tab_ix)

        # --- TAB 3: JOIN GROUP ---
        tab3 = ttk.Frame(notebook)
        notebook.add(tab3, text='  👥  Group  ')

        ttk.Label(tab3, text="Từ khóa tìm nhóm (cách nhau bởi dấu phẩy):", font=("Arial", 10, "bold")).pack(anchor='w', padx=10, pady=(12, 4))
        self.keyword_entry = self._mk_entry(tab3, width=80)
        self.keyword_entry.pack(anchor='w', padx=10)
        self.keyword_entry.insert(0, "bất động sản, mỹ phẩm, mẹ và bé")

        jmode = ttk.LabelFrame(tab3, text="Mức JOIN nhóm / ngày / nick (tự giới hạn + delay)")
        jmode.pack(fill='x', padx=10, pady=8)
        jm = ttk.Frame(jmode)
        jm.pack(fill='x', padx=8, pady=4)
        ttk.Radiobutton(jm, text="Ít (5/ngày)", variable=self.var_join_mode, value='low').pack(side='left', padx=6)
        ttk.Radiobutton(jm, text="Vừa (12/ngày)", variable=self.var_join_mode, value='medium').pack(side='left', padx=6)
        ttk.Radiobutton(jm, text="Tối đa (20/ngày)", variable=self.var_join_mode, value='high').pack(side='left', padx=6)
        ttk.Label(jmode, text="Nick mới nên chọn Ít. Vượt 20-30 join/ngày dễ bị khóa join 24-48h.", font=("Arial", 8), foreground="#555").pack(anchor='w', padx=8, pady=2)

        jrow = ttk.Frame(tab3)
        jrow.pack(fill='x', padx=10, pady=4)
        ttk.Label(jrow, text="Tùy chỉnh join / 1 từ khóa (0 = theo preset):").pack(side='left')
        self.join_limit_var = tk.StringVar(value='0')
        self._mk_entry(jrow, textvariable=self.join_limit_var, width=6).pack(side='left', padx=6)

        jbtn = ttk.Frame(tab3)
        jbtn.pack(fill='x', padx=10, pady=4)
        ttk.Button(jbtn, text="▶  Join Nhóm", style='Accent.TButton', command=self.start_join).pack(side='left', padx=4)
        ttk.Button(jbtn, text="▶  Lướt + Like group", command=self.start_group_browse).pack(side='left', padx=4)
        ttk.Button(jbtn, text="▶  Mời bạn vào group", command=self.start_group_invite).pack(side='left', padx=4)
        ttk.Button(jbtn, text="▶  Rời group", style='Danger.TButton', command=self.start_group_leave).pack(side='left', padx=4)

        ttk.Separator(tab3, orient='horizontal').pack(fill='x', padx=10, pady=4)
        ttk.Label(tab3, text="Nhóm đang join / đã join (theo tài khoản):", font=("Arial", 10, "bold")).pack(anchor='w', padx=10, pady=(8, 4))
        gcols = ('UID', 'Tên nhóm', 'Trạng thái', 'URL', 'Thời gian')
        self.groups_tree = ttk.Treeview(tab3, columns=gcols, show='headings', height=6)
        for c, w in zip(gcols, (120, 220, 100, 280, 130)):
            self.groups_tree.heading(c, text=c)
            self.groups_tree.column(c, width=w)
        self.groups_tree.pack(fill='both', expand=True, padx=10, pady=4)
        gbtn = ttk.Frame(tab3)
        gbtn.pack(fill='x', padx=10, pady=4)
        ttk.Button(gbtn, text="Làm mới danh sách nhóm", command=self.refresh_groups_table).pack(side='left', padx=4)
        ttk.Button(gbtn, text="Xóa lịch sử nhóm đã chọn", command=self.clear_selected_group_history).pack(side='left', padx=4)

        ttk.Separator(tab3, orient='horizontal').pack(fill='x', padx=10, pady=6)
        cmode = ttk.LabelFrame(tab3, text="Mức COMMENT / ngày / nick")
        cmode.pack(fill='x', padx=10, pady=4)
        cm = ttk.Frame(cmode)
        cm.pack(fill='x', padx=8, pady=4)
        ttk.Radiobutton(cm, text="Ít (12/ngày)", variable=self.var_comment_mode, value='low').pack(side='left', padx=6)
        ttk.Radiobutton(cm, text="Vừa (35/ngày)", variable=self.var_comment_mode, value='medium').pack(side='left', padx=6)
        ttk.Radiobutton(cm, text="Tối đa (60/ngày)", variable=self.var_comment_mode, value='high').pack(side='left', padx=6)
        ttk.Label(cmode, text=">100 comment+reply/ngày dễ bị restricted. App tự dừng khi hết quota.", font=("Arial", 8), foreground="#555").pack(anchor='w', padx=8, pady=2)

        ttk.Label(tab3, text="Comment nhóm (Spin {A|B}):", font=("Arial", 9, "bold")).pack(anchor='w', padx=10, pady=2)
        self.comment_text = tk.Text(tab3, height=3, width=90)
        self.comment_text.pack(anchor='w', padx=10)
        self._style_text(self.comment_text)
        self.comment_text.insert("1.0", "{Chào bạn|Xin chào|Hi bạn}, mình có sản phẩm phù hợp. Ib mình nhé!")
        ttk.Button(tab3, text="▶  Auto Comment Group (ẩn + quota)", style='Accent.TButton', command=self.start_group_comment).pack(anchor='w', padx=10, pady=8)

        # --- TAB 4: POST ---
        tab4 = ttk.Frame(notebook)
        notebook.add(tab4, text='  ✍️  Đăng bài  ')

        ttk.Label(tab4, text="Nội dung bài đăng (chỉnh sửa trước khi đăng — hỗ trợ Spin {A|B}):", font=UI['font_ui_bold']).pack(anchor='w', padx=10, pady=(12, 4))
        self.post_content = tk.Text(tab4, height=6, width=100)
        self.post_content.pack(anchor='w', padx=10)
        self._style_text(self.post_content)
        self.post_content.insert(
            "1.0",
            "{Chào mọi người|Xin chào mọi người}!\nHôm nay mình muốn chia sẻ một mẹo nhỏ rất hữu ích.\n{Xem thêm tại|Link}: ",
        )

        prow = ttk.Frame(tab4)
        prow.pack(fill='x', padx=10, pady=6)
        ttk.Label(prow, text="Link kèm cuối bài:").pack(side='left')
        self.promo_link = self._mk_entry(prow, width=55)
        self.promo_link.pack(side='left', padx=6)
        self.promo_link.insert(0, "https://shopee.vn/san-pham-demo")

        irow = ttk.Frame(tab4)
        irow.pack(fill='x', padx=10, pady=4)
        ttk.Label(irow, text="Ảnh đính kèm:").pack(side='left')
        self._mk_entry(irow, textvariable=self.post_image_path, width=60).pack(side='left', padx=6)
        ttk.Button(irow, text="Chọn ảnh...", command=self.browse_post_image).pack(side='left', padx=4)
        ttk.Button(irow, text="Xóa ảnh", command=lambda: self.post_image_path.set('')).pack(side='left', padx=4)

        self.var_post_wall = tk.BooleanVar(value=True)
        self.var_post_groups = tk.BooleanVar(value=False)
        crow = ttk.Frame(tab4)
        crow.pack(fill='x', padx=10, pady=6)
        ttk.Checkbutton(crow, text="Đăng tường cá nhân (Timeline)", variable=self.var_post_wall).pack(side='left', padx=4)
        ttk.Checkbutton(crow, text="Đăng vào nhóm đã join", variable=self.var_post_groups).pack(side='left', padx=4)
        ttk.Label(crow, text="Max nhóm/nick (0=preset):").pack(side='left', padx=(12, 4))
        self.post_group_limit = tk.StringVar(value='0')
        self._mk_entry(crow, textvariable=self.post_group_limit, width=5).pack(side='left')

        pmode = ttk.LabelFrame(tab4, text="Mức ĐĂNG BÀI / ngày / nick")
        pmode.pack(fill='x', padx=10, pady=6)
        pm = ttk.Frame(pmode)
        pm.pack(fill='x', padx=8, pady=4)
        ttk.Radiobutton(pm, text="Ít (3/ngày)", variable=self.var_post_mode, value='low').pack(side='left', padx=6)
        ttk.Radiobutton(pm, text="Vừa (6/ngày)", variable=self.var_post_mode, value='medium').pack(side='left', padx=6)
        ttk.Radiobutton(pm, text="Tối đa (12/ngày)", variable=self.var_post_mode, value='high').pack(side='left', padx=6)

        ttk.Label(tab4, text="Prompt AI (tuỳ chọn — tạo nội dung mẫu, vẫn sửa được trước khi đăng):", font=("Arial", 9)).pack(anchor='w', padx=10, pady=(8, 2))
        self.ai_prompt = tk.Text(tab4, height=2, width=100)
        self.ai_prompt.pack(anchor='w', padx=10)
        self._style_text(self.ai_prompt)
        self.ai_prompt.insert("1.0", "Viết bài chia sẻ mẹo vặt ngắn gọn, hài hước.")
        arow = ttk.Frame(tab4)
        arow.pack(fill='x', padx=10, pady=6)
        ttk.Button(arow, text="✨ Sinh nội dung mẫu (AI local)", command=self.generate_local_content).pack(side='left', padx=4)
        ttk.Button(arow, text="👁 Xem & chỉnh trước khi đăng", command=self.preview_and_post).pack(side='left', padx=4)
        ttk.Button(arow, text="▶  Đăng ngay", style='Accent.TButton', command=self.start_post_now).pack(side='left', padx=4)

        ttk.Label(
            tab4,
            text="Báo cáo: SUCCESS kèm link bài | FAIL + lý do. Xem tab 'Báo cáo'.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=10, pady=4)

        # keep compat for old AI post button path
        self.var_random_wall = self.var_post_wall

        # --- TAB 5: ĐỔI PROFILE FACEBOOK (thật trên FB, không phải nhãn trong app) ---
        tab5 = ttk.Frame(notebook)
        notebook.add(tab5, text='  🖼  Profile  ')
        self.var_change_name = tk.BooleanVar(value=True)
        self.var_use_vn_pool = tk.BooleanVar(value=True)
        self.var_only_new_accounts = tk.BooleanVar(value=True)
        self.var_change_avatar = tk.BooleanVar(value=True)

        ttk.Label(
            tab5,
            text="Các nút bên dưới ĐỔI THẬT trên Facebook: Tên hiển thị + Ảnh đại diện.",
            style='Warning.TLabel',
        ).pack(anchor='w', padx=12, pady=(12, 4))
        ttk.Label(
            tab5,
            text="Không phải đặt tên tiếng Việt để quản lý trong app. Cột 'Tên profile FB' = tên sẽ đẩy lên Facebook.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=(0, 8))

        opt = ttk.LabelFrame(tab5, text="Tùy chọn")
        opt.pack(fill='x', padx=12, pady=6)
        ttk.Checkbutton(opt, text="Đổi TÊN profile Facebook (First/Middle/Last name)", variable=self.var_change_name).pack(anchor='w', padx=10, pady=3)
        ttk.Checkbutton(
            opt,
            text=f"Lấy tên ngẫu nhiên từ kho {len(self.vn_names)} tên VN (file {NAMES_FILE})",
            variable=self.var_use_vn_pool,
        ).pack(anchor='w', padx=30, pady=2)
        ttk.Checkbutton(opt, text="Đổi AVATAR profile Facebook (random ảnh trong thư mục)", variable=self.var_change_avatar).pack(anchor='w', padx=10, pady=3)
        ttk.Checkbutton(
            opt,
            text="Chỉ nick MỚI — bỏ tick = áp dụng tất cả tài khoản",
            variable=self.var_only_new_accounts,
        ).pack(anchor='w', padx=10, pady=3)

        ttk.Label(tab5, text="Kho tên dự phòng (nếu tắt random file — cách nhau dấu phẩy):", font=("Arial", 9)).pack(anchor='w', padx=30, pady=(8, 2))
        self.names_entry = self._mk_entry(tab5, width=90)
        self.names_entry.pack(anchor='w', padx=30)
        if self.vn_names:
            sample = self.pick_random_names(min(8, len(self.vn_names)))
            if isinstance(sample, str):
                sample = [sample]
            self.names_entry.insert(0, ', '.join(sample))
        else:
            self.names_entry.insert(0, "Nguyễn Văn An, Trần Thị Bình, Lê Hoàng Cường")

        nrow = ttk.Frame(tab5)
        nrow.pack(fill='x', padx=30, pady=6)
        ttk.Button(nrow, text="Nạp lại kho tên", command=self.reload_vn_names).pack(side='left', padx=4)
        ttk.Button(nrow, text="Random 10 tên vào ô", command=self.fill_names_entry_sample).pack(side='left', padx=4)
        ttk.Button(nrow, text="🎲 Random tên profile cho nick MỚI", command=self.assign_random_names_to_new).pack(side='left', padx=4)
        ttk.Label(nrow, text=f"Kho: {len(self.vn_names)} tên", foreground="#555").pack(side='left', padx=10)

        ttk.Separator(tab5, orient='horizontal').pack(fill='x', padx=10, pady=10)
        ttk.Label(tab5, text="Thư mục ảnh Avatar (đổi ảnh đại diện Facebook):", font=("Arial", 9)).pack(anchor='w', padx=30, pady=2)
        folder_frame = ttk.Frame(tab5)
        folder_frame.pack(fill='x', padx=30)
        self.avatar_folder_entry = self._mk_entry(folder_frame, width=65)
        self.avatar_folder_entry.pack(side='left')
        _default_avatar_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'avatars')
        if os.path.isdir(_default_avatar_dir):
            self.avatar_folder_entry.insert(0, _default_avatar_dir)
        ttk.Button(folder_frame, text="Chọn Thư Mục", command=self.browse_folder).pack(side='left', padx=10)

        prow = ttk.Frame(tab5)
        prow.pack(fill='x', padx=12, pady=14)
        ttk.Button(prow, text="▶ ĐỔI TÊN profile Facebook (theo tùy chọn)", command=self.start_bulk_rename_new).pack(side='left', padx=4)
        ttk.Button(prow, text="▶ ĐỔI AVATAR profile Facebook", command=self.start_bulk_avatar_new).pack(side='left', padx=4)
        ttk.Button(prow, text="▶ ĐỔI TÊN + AVATAR profile Facebook", command=self.start_bulk_profile_new).pack(side='left', padx=4)
        ttk.Button(prow, text="▶ Chạy theo tùy chọn tick (tất cả/mới)", command=self.start_update_profile).pack(side='left', padx=4)

        ttk.Label(
            tab5,
            text="Chạy headless (Chrome/Firefox). Báo cáo SUCCESS/FAIL ở tab 'Báo cáo kết quả'. FB có thể giới hạn đổi tên ~60 ngày/lần.",
            font=("Arial", 8),
            foreground="#555",
        ).pack(anchor='w', padx=15, pady=4)

        # --- TAB SCENARIO: Kịch bản tuần tự (warm-up + daily) ---
        tab_sc = ttk.Frame(notebook)
        notebook.add(tab_sc, text='  🗓  Kịch bản  ')

        ttk.Label(
            tab_sc,
            text="Kịch bản sống lâu · Warm-up 7 ngày → Daily 3 ca · Chạy tuần tự · 1 proxy không song song 2 nick",
            style='Success.TLabel',
        ).pack(anchor='w', padx=12, pady=(10, 2))
        ttk.Label(
            tab_sc,
            text=(
                "GĐ1 Ngày1-3: chỉ lướt+like+đọc noti (CẤM kết bạn/comment/join/đăng). "
                "Ngày4-7: like + kết bạn 2-3 + accept + 1 status (không link ads) + join 1-2 group. "
                "GĐ2 >7 ngày: Sáng trust | Trưa join+đăng tường | Tối comment group (delay 3-5 phút)."
            ),
            wraplength=1200,
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=(0, 6))

        sc_cfg = ttk.LabelFrame(tab_sc, text="Cấu hình ca & nội dung (Spin {A|B|C})")
        sc_cfg.pack(fill='x', padx=10, pady=4)

        trow = ttk.Frame(sc_cfg)
        trow.pack(fill='x', padx=8, pady=4)
        ttk.Label(trow, text="Ca Sáng:").pack(side='left')
        self.sc_morning_start = tk.StringVar(value=str(self.scenario_cfg.get('morning_start', 8)))
        self.sc_morning_end = tk.StringVar(value=str(self.scenario_cfg.get('morning_end', 10)))
        self._mk_entry(trow, textvariable=self.sc_morning_start, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="→").pack(side='left')
        self._mk_entry(trow, textvariable=self.sc_morning_end, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="h   |  Ca Trưa:").pack(side='left', padx=(10, 0))
        self.sc_noon_start = tk.StringVar(value=str(self.scenario_cfg.get('noon_start', 12)))
        self.sc_noon_end = tk.StringVar(value=str(self.scenario_cfg.get('noon_end', 14)))
        self._mk_entry(trow, textvariable=self.sc_noon_start, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="→").pack(side='left')
        self._mk_entry(trow, textvariable=self.sc_noon_end, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="h   |  Ca Tối:").pack(side='left', padx=(10, 0))
        self.sc_evening_start = tk.StringVar(value=str(self.scenario_cfg.get('evening_start', 19)))
        self.sc_evening_end = tk.StringVar(value=str(self.scenario_cfg.get('evening_end', 22)))
        self._mk_entry(trow, textvariable=self.sc_evening_start, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="→").pack(side='left')
        self._mk_entry(trow, textvariable=self.sc_evening_end, width=4).pack(side='left', padx=2)
        ttk.Label(trow, text="h").pack(side='left')

        krow = ttk.Frame(sc_cfg)
        krow.pack(fill='x', padx=8, pady=3)
        ttk.Label(krow, text="Từ khóa Join group:").pack(side='left')
        self.sc_keywords = self._mk_entry(krow, width=70)
        self.sc_keywords.pack(side='left', padx=6)
        self.sc_keywords.insert(0, self.scenario_cfg.get('keywords', DEFAULT_SCENARIO_CFG['keywords']))

        lrow = ttk.Frame(sc_cfg)
        lrow.pack(fill='x', padx=8, pady=3)
        ttk.Label(lrow, text="Link SP (chỉ daily, nên rút gọn / landing):").pack(side='left')
        self.sc_promo_link = self._mk_entry(lrow, width=55)
        self.sc_promo_link.pack(side='left', padx=6)
        self.sc_promo_link.insert(0, self.scenario_cfg.get('promo_link', ''))

        ttk.Label(sc_cfg, text="Status tường (Spin):", style='Accent.TLabel').pack(anchor='w', padx=8)
        self.sc_status_text = tk.Text(sc_cfg, height=2, width=110)
        self.sc_status_text.pack(anchor='w', padx=8, pady=2)
        self._style_text(self.sc_status_text)
        self.sc_status_text.insert('1.0', self.scenario_cfg.get('status_spin', DEFAULT_SCENARIO_CFG['status_spin']))

        ttk.Label(sc_cfg, text="Comment bạn bè (Spin ca sáng):", style='Accent.TLabel').pack(anchor='w', padx=8)
        self.sc_friend_cmt = tk.Text(sc_cfg, height=2, width=110)
        self.sc_friend_cmt.pack(anchor='w', padx=8, pady=2)
        self._style_text(self.sc_friend_cmt)
        self.sc_friend_cmt.insert('1.0', self.scenario_cfg.get('friend_comment_spin', DEFAULT_SCENARIO_CFG['friend_comment_spin']))

        ttk.Label(sc_cfg, text="Comment group (Spin ca tối — đa dạng, delay 3–5 phút):", style='Accent.TLabel').pack(anchor='w', padx=8)
        self.sc_group_cmt = tk.Text(sc_cfg, height=2, width=110)
        self.sc_group_cmt.pack(anchor='w', padx=8, pady=2)
        self._style_text(self.sc_group_cmt)
        self.sc_group_cmt.insert('1.0', self.scenario_cfg.get('group_comment_spin', DEFAULT_SCENARIO_CFG['group_comment_spin']))

        brow = ttk.Frame(sc_cfg)
        brow.pack(fill='x', padx=8, pady=6)
        ttk.Label(brow, text="Phạm vi:").pack(side='left')
        ttk.Radiobutton(brow, text="Tất cả", variable=self.var_scenario_scope, value='all').pack(side='left', padx=4)
        ttk.Radiobutton(brow, text="Đang chọn", variable=self.var_scenario_scope, value='selected').pack(side='left', padx=4)
        ttk.Radiobutton(brow, text="Chỉ chưa xong hôm nay", variable=self.var_scenario_scope, value='pending').pack(side='left', padx=4)
        ttk.Label(brow, text="  |  Ca:").pack(side='left', padx=(8, 0))
        ttk.Radiobutton(brow, text="Tự theo giờ", variable=self.var_scenario_force_shift, value='auto').pack(side='left', padx=3)
        ttk.Radiobutton(brow, text="Sáng", variable=self.var_scenario_force_shift, value='morning').pack(side='left', padx=3)
        ttk.Radiobutton(brow, text="Trưa", variable=self.var_scenario_force_shift, value='noon').pack(side='left', padx=3)
        ttk.Radiobutton(brow, text="Tối", variable=self.var_scenario_force_shift, value='evening').pack(side='left', padx=3)
        ttk.Radiobutton(brow, text="Full (warmup)", variable=self.var_scenario_force_shift, value='full').pack(side='left', padx=3)

        btn_sc = ttk.Frame(tab_sc)
        btn_sc.pack(fill='x', padx=10, pady=4)
        ttk.Button(btn_sc, text="💾 Lưu cấu hình", command=self.save_scenario_cfg_from_ui).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="▶  CHẠY KỊCH BẢN HÔM NAY", style='Accent.TButton', command=self.start_scenario_today).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="▶ Chạy nick đang chọn", command=self.start_scenario_selected).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="🔄 Làm mới bảng", command=self.refresh_scenario_table).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="⏱ Reset warm-up (đổi IP/proxy)", command=self.reset_lifecycle_selected).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="⏱ Reset warm-up TẤT CẢ", style='Danger.TButton', command=self.reset_lifecycle_all).pack(side='left', padx=3)
        ttk.Button(btn_sc, text="📋 Báo cáo tóm tắt", command=self.show_scenario_summary).pack(side='left', padx=3)

        scols = (
            'STT', 'UID', 'Ngày', 'Giai đoạn', 'Ca',
            'Việc đã xong', 'Việc chưa xong', 'Trạng thái', 'Quota hôm nay', 'Proxy',
        )
        self.scenario_tree = ttk.Treeview(tab_sc, columns=scols, show='headings', height=11)
        for c, w in zip(scols, (40, 100, 55, 120, 70, 200, 200, 90, 140, 120)):
            self.scenario_tree.heading(c, text=c)
            self.scenario_tree.column(c, width=w, anchor='center' if c in ('STT', 'Ngày', 'Ca', 'Trạng thái') else 'w')
        self.scenario_tree.pack(fill='both', expand=True, padx=10, pady=6)

        self.scenario_summary = ttk.Label(tab_sc, text='', style='Status.TLabel')
        self.scenario_summary.pack(anchor='w', padx=12, pady=2)
        ttk.Label(
            tab_sc,
            text=(
                "Chốt chặn: delay 30–120s | Join 60–120s | Comment group 3–5 phút | "
                "Join ≤5/ngày | Comment ≤10/ngày | Friend ≤10/ngày | Không 2 nick cùng proxy song song."
            ),
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=(0, 6))
        self.refresh_scenario_table()

        # --- TAB REPORTS ---
        tab_rep = ttk.Frame(notebook)
        notebook.add(tab_rep, text='  📊  Báo cáo  ')
        rcols = ('Thời gian', 'UID', 'Tác vụ', 'Đối tượng', 'Kết quả', 'Chi tiết', 'Link')
        self.report_tree = ttk.Treeview(tab_rep, columns=rcols, show='headings', height=16)
        for c, w in zip(rcols, (130, 110, 100, 160, 90, 220, 220)):
            self.report_tree.heading(c, text=c)
            self.report_tree.column(c, width=w)
        self.report_tree.pack(fill='both', expand=True, padx=10, pady=8)
        rbtn = ttk.Frame(tab_rep)
        rbtn.pack(fill='x', padx=10, pady=4)
        ttk.Button(rbtn, text="Làm mới", command=self.refresh_report_table).pack(side='left', padx=4)
        ttk.Button(rbtn, text="Xóa báo cáo", style='Danger.TButton', command=self.clear_reports).pack(side='left', padx=4)
        ttk.Button(rbtn, text="Mở link dòng chọn", command=self.open_selected_report_link).pack(side='left', padx=4)
        self.report_summary = ttk.Label(tab_rep, text='', style='Status.TLabel')
        self.report_summary.pack(anchor='w', padx=12, pady=4)
        self.refresh_report_table()
        self.refresh_groups_table()

        # ===== BOTTOM DOCK: Job queue + Logs (trong paned — kéo lên/xuống) =====
        dock = self.dock_frame
        dock_hint = ttk.Frame(dock)
        dock_hint.pack(fill='x', pady=(2, 4))
        ttk.Label(
            dock_hint,
            text="↕ Kéo thanh ngang phía trên khung này để phóng to Job/Log  ·  Tab Nick FB: chức năng nằm DƯỚI bảng nick",
            style='Status.TLabel',
        ).pack(side='left')
        ttk.Button(dock_hint, text="⬆ Phóng Job/Log", command=self.scroll_to_processing_area, width=16).pack(side='right', padx=2)

        # Banner tên lệnh — luôn nổi, không phụ thuộc scroll log
        self.cmd_banner_label = ttk.Label(
            dock,
            text="○ Hệ thống rảnh — chưa có lệnh đang chạy",
            style='Status.TLabel',
            wraplength=1200,
        )
        self.cmd_banner_label.pack(fill='x', padx=4, pady=(0, 4))

        job_frame = ttk.LabelFrame(dock, text="  ⚡  Job Queue — lệnh đang chạy / đang chờ  ")
        job_frame.pack(fill='x', pady=(0, 6))
        self.job_status_label = ttk.Label(job_frame, text="○ Hệ thống rảnh — chưa có lệnh", style='Status.TLabel')
        self.job_status_label.pack(anchor='w', padx=10, pady=(6, 2))
        jcols = ('Job', 'Lệnh', 'UID / Đối tượng', 'Trạng thái', 'Chi tiết')
        self.job_tree = ttk.Treeview(job_frame, columns=jcols, show='headings', height=3)
        for c, w in zip(jcols, (60, 160, 130, 120, 400)):
            self.job_tree.heading(c, text=c)
            self.job_tree.column(c, width=w)
        self.job_tree.pack(fill='x', padx=10, pady=2)
        jbtn = ttk.Frame(job_frame)
        jbtn.pack(fill='x', padx=10, pady=(2, 8))
        self.btn_stop_dock = ttk.Button(
            jbtn, text="⏹  Dừng lệnh đang chạy", style='Danger.TButton', command=self.stop_all_jobs
        )
        self.btn_stop_dock.pack(side='left', padx=3)
        self.btn_stop_dock.config(state='disabled')
        ttk.Button(jbtn, text="📋 Chi tiết chạy/chờ", command=self.show_job_list_dialog).pack(side='left', padx=3)
        ttk.Button(jbtn, text="🔄 Làm mới", command=self.refresh_job_table).pack(side='left', padx=3)
        ttk.Label(
            jbtn,
            text="Dừng = hủy job + hàng chờ ngay · Esc · browser đóng cưỡng bức nếu treo.",
            style='Muted.TLabel',
        ).pack(side='left', padx=12)

        log_frame = ttk.LabelFrame(dock, text="  🖥  Nhật ký hệ thống  ")
        log_frame.pack(fill='both', expand=True)
        self.progress_label = ttk.Label(
            log_frame,
            text="○ Chưa có tiến trình — bấm tính năng để xem tiến độ / ETA tại đây",
            style='Status.TLabel',
            wraplength=1200,
        )
        self.progress_label.pack(anchor='w', padx=10, pady=(6, 0))

        log_wrap = ttk.Frame(log_frame)
        log_wrap.pack(fill='both', expand=True, padx=8, pady=8)
        self.log_scroll = ttk.Scrollbar(log_wrap, orient='vertical')
        self.log_box = tk.Text(
            log_wrap,
            height=12,
            state='disabled',
            yscrollcommand=self.log_scroll.set,
            wrap='word',
        )
        self.log_scroll.config(command=self.log_box.yview)
        self.log_scroll.pack(side='right', fill='y')
        self.log_box.pack(side='left', fill='both', expand=True)
        # Màu log cố định: nền tối + chữ xanh lá (dễ đọc, không phụ thuộc theme)
        try:
            self.log_box.configure(
                bg='#0a0e14',
                fg='#4ade80',
                insertbackground='#4ade80',
                selectbackground='#166534',
                selectforeground='#ffffff',
                font=('Menlo', 11) if os.name == 'posix' else ('Consolas', 10),
                relief='flat',
                borderwidth=0,
                highlightthickness=1,
                highlightbackground='#1f2937',
                padx=8,
                pady=6,
            )
        except Exception:
            self._style_text(self.log_box, mono=True)
        # cuộn chuột trên log (Windows/macOS)
        self.log_box.bind('<MouseWheel>', self._on_log_mousewheel)
        self.log_box.bind('<Button-4>', lambda e: self.log_box.yview_scroll(-3, 'units'))
        self.log_box.bind('<Button-5>', lambda e: self.log_box.yview_scroll(3, 'units'))

        # gắn dock vào paned (phần dưới)
        try:
            self.main_pane.add(self.dock_frame, weight=2)
        except Exception:
            self.dock_frame.pack(side='bottom', fill='x')
        # sash mặc định: chừa ~280px Job/Log (đủ banner + vài dòng log; tab Nick có thanh chức năng riêng)
        try:
            self.root.update_idletasks()
            h = max(self.root.winfo_height(), 860)
            # Ưu tiên vùng tab (chức năng nick) — dock gọn hơn một chút
            self.main_pane.sashpos(0, max(420, h - 280))
        except Exception:
            pass
        self.refresh_job_table()
        # Bắt đầu poller UI (chỉ main thread gọi root.after)
        self._start_ui_poller()
        self.log("✓ Sẵn sàng — log lệnh sẽ hiện ở đây khi bạn bấm Check Live / Kịch bản / …")

    # ------------------------------------------------------------------
    # Thread-safe UI (Tk không an toàn đa luồng)
    # ------------------------------------------------------------------
    def _start_ui_poller(self):
        if self._ui_poller_on:
            return
        self._ui_poller_on = True

        def poll():
            try:
                self._ui_drain()
            except Exception:
                pass
            try:
                self.root.after(50, poll)
            except Exception:
                self._ui_poller_on = False

        try:
            self.root.after(50, poll)
        except Exception:
            self._ui_poller_on = False

    def _ui_drain(self):
        """Main thread: ưu tiên log/progress, sau đó call — tránh log bị kẹt sau refresh bảng."""
        batch = []
        while len(batch) < 200:
            try:
                batch.append(self._ui_q.get_nowait())
            except queue.Empty:
                break
        if not batch:
            return
        # Pass 1: log + progress (user cần thấy ngay)
        calls = []
        for item in batch:
            try:
                kind = item[0]
                if kind == 'log':
                    self._log_write(item[1])
                elif kind == 'progress_label':
                    self._progress_label_write(item[1])
                elif kind == 'call':
                    calls.append(item)
            except Exception:
                pass
        # Pass 2: UI callbacks (giới hạn 40/lần để không block mainloop)
        for item in calls[:40]:
            try:
                _, fn, args, kwargs = item
                fn(*args, **kwargs)
            except Exception:
                pass
        # đẩy lại phần call chưa kịp xử lý
        for item in calls[40:]:
            try:
                self._ui_q.put(item)
            except Exception:
                pass

    def _ui_call(self, fn, *args, **kwargs):
        """Gọi fn trên main thread (an toàn từ worker)."""
        if threading.current_thread() is threading.main_thread():
            try:
                fn(*args, **kwargs)
            except Exception:
                pass
            return
        self._ui_q.put(('call', fn, args, kwargs))

    def _log_write(self, message):
        line = f"[{time.strftime('%H:%M:%S')}] {message}"
        # File backup — luôn ghi dù UI treo
        try:
            path = getattr(self, '_log_file_path', None) or 'activity.log'
            with open(path, 'a', encoding='utf-8') as f:
                f.write(line + '\n')
        except Exception:
            pass
        if not hasattr(self, 'log_box'):
            return
        try:
            self.log_box.config(state='normal')
            # Giữ tối đa ~2500 dòng để Text không nặng
            try:
                end_line = int(float(self.log_box.index('end-1c').split('.')[0]))
                if end_line > 2500:
                    self.log_box.delete('1.0', f'{end_line - 2000}.0')
            except Exception:
                pass
            self.log_box.insert('end', line + '\n')
            self.log_box.see('end')
            self.log_box.config(state='disabled')
            try:
                self.log_box.yview_moveto(1.0)
            except Exception:
                pass
        except Exception:
            pass

    def _progress_label_write(self, text):
        try:
            if hasattr(self, 'progress_label'):
                self.progress_label.config(text=text)
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Account table
    # ------------------------------------------------------------------
    def _current_acc_group_filter_key(self):
        """all | moi | trau | sieu từ combobox lọc."""
        raw = ''
        try:
            raw = (self.var_acc_group_filter.get() or 'all').strip()
        except Exception:
            raw = 'all'
        key = raw.split('|', 1)[0].strip().lower()
        if key in ('', 'all', 'tat ca', 'tất cả'):
            return 'all'
        return self._normalize_acc_group(key)

    def refresh_table(self):
        """Bảng Nick FB: chỉ nick CÒN SỐNG (không Acc chết), có lọc theo nhóm."""
        if not hasattr(self, 'tree'):
            return
        for row in self.tree.get_children():
            self.tree.delete(row)
        live = die = checkpoint = nocheck = named = with_px = no_px = 0
        g_moi = g_trau = g_sieu = 0
        active_n = 0
        shown = 0
        filt = self._current_acc_group_filter_key()
        dead_n = sum(1 for a in self.accounts if self._is_dead_pool(a))
        for idx, acc in enumerate(self.accounts):
            self._ensure_acc_group(acc)
            if self._is_dead_pool(acc):
                st = str(acc.get('status') or '').upper()
                if 'CHECKPOINT' in st:
                    checkpoint += 1
                elif st in ('DIE', 'DEAD', 'NO COOKIE') or 'DIE' in st:
                    die += 1
                continue
            active_n += 1
            gkey = self._ensure_acc_group(acc)
            if gkey == ACC_GROUP_MOI:
                g_moi += 1
            elif gkey == ACC_GROUP_SIEU:
                g_sieu += 1
            else:
                g_trau += 1
            if filt != 'all' and gkey != filt:
                continue
            shown += 1
            cookie_mask = "[Có Cookie]" if acc.get('cookie') else "[Không]"
            status = acc.get('status') or 'Chưa check'
            st = str(status).upper()
            if st == 'LIVE' or ('LIVE' in st and 'DIE' not in st and 'ĐANG' not in st):
                live += 1
            elif 'CHECKPOINT' in st:
                checkpoint += 1
            elif st in ('DIE', 'NO COOKIE') or 'DIE' in st:
                die += 1
            else:
                nocheck += 1
            if acc.get('planned_name'):
                named += 1
            if self.account_has_proxy(acc) and not self._is_proxy_quarantined(acc.get('proxy')):
                with_px += 1
            else:
                no_px += 1
            self.tree.insert(
                '', 'end',
                values=(
                    idx + 1,
                    acc.get('uid', ''),
                    "******",
                    acc.get('two_fa', ''),
                    cookie_mask,
                    acc.get('proxy') or 'Chưa gán',
                    acc.get('planned_name') or '-',
                    self._acc_group_label(gkey),
                    status,
                ),
            )
        total = len(self.accounts)
        filt_label = {
            'all': 'Tất cả',
            ACC_GROUP_MOI: 'Mới',
            ACC_GROUP_TRAU: 'Trâu',
            ACC_GROUP_SIEU: 'Siêu CL',
        }.get(filt, filt)
        self.acc_summary.config(
            text=(
                f"Nick sống: {active_n}/{total}  |  Acc chết: {dead_n} (tab ☠)  |  "
                f"Nhóm 🌱Mới:{g_moi}  🐂Trâu:{g_trau}  💎Siêu:{g_sieu}  |  "
                f"Đang hiện ({filt_label}): {shown}  |  "
                f"Proxy OK: {with_px}  |  Không proxy: {no_px}  |  "
                f"LIVE: {live}  |  Tên profile: {named}"
            )
        )
        try:
            self.refresh_dead_table()
        except Exception:
            pass

    def assign_selected_acc_group(self, group_key):
        """Gán nhóm chất lượng cho nick đang chọn trên bảng."""
        indices = self._selected_account_indices()
        if not indices:
            messagebox.showwarning("Nhóm tài khoản", "Hãy chọn ít nhất 1 nick trên bảng.")
            return
        g = self._normalize_acc_group(group_key)
        label = self._acc_group_label(g)
        n = 0
        for idx in indices:
            if self._is_dead_pool(self.accounts[idx]):
                continue
            self._set_acc_group(self.accounts[idx], g)
            n += 1
        if n <= 0:
            messagebox.showwarning("Nhóm tài khoản", "Không có nick sống nào được gán (nick chết nằm tab Acc chết).")
            return
        self.save_accounts()
        self.refresh_table()
        self.log(f"📁 Đã gán nhóm «{label}» cho {n} nick")
        messagebox.showinfo("Nhóm tài khoản", f"Đã gán «{label}» cho {n} nick.")

    def select_all_visible_accounts(self):
        """Chọn tất cả dòng đang hiển thị (theo bộ lọc nhóm)."""
        if not hasattr(self, 'tree'):
            return
        kids = self.tree.get_children()
        if not kids:
            return
        self.tree.selection_set(kids)

    def show_bulk_acc_group_dialog(self):
        """Dialog gán nhóm hàng loạt: nick chọn / cả bộ lọc / toàn bộ nick sống."""
        dlg = tk.Toplevel(self.root)
        dlg.title("Gán nhóm tài khoản hàng loạt")
        dlg.geometry("480x320")
        dlg.transient(self.root)
        dlg.grab_set()
        ttk.Label(
            dlg,
            text="Phân loại nick để vận hành: Mới · Trâu · Siêu chất lượng (cá nhân)",
            style='Accent.TLabel',
            wraplength=440,
        ).pack(anchor='w', padx=14, pady=(14, 6))
        var_group = tk.StringVar(value=ACC_GROUP_TRAU)
        var_scope = tk.StringVar(value='selected')
        gf = ttk.LabelFrame(dlg, text="Nhóm đích")
        gf.pack(fill='x', padx=14, pady=6)
        for key, lab in (
            (ACC_GROUP_MOI, "🌱 Tài khoản Mới — warm-up / đổi profile"),
            (ACC_GROUP_TRAU, "🐂 Tài khoản Trâu — nuôi / group / tương tác"),
            (ACC_GROUP_SIEU, "💎 Siêu chất lượng (cá nhân) — hạn chế spam"),
        ):
            ttk.Radiobutton(gf, text=lab, variable=var_group, value=key).pack(anchor='w', padx=10, pady=3)
        sf = ttk.LabelFrame(dlg, text="Phạm vi")
        sf.pack(fill='x', padx=14, pady=6)
        ttk.Radiobutton(sf, text="Chỉ nick đang chọn trên bảng", variable=var_scope, value='selected').pack(anchor='w', padx=10, pady=2)
        ttk.Radiobutton(sf, text="Tất cả nick đang hiện (theo bộ lọc nhóm)", variable=var_scope, value='visible').pack(anchor='w', padx=10, pady=2)
        ttk.Radiobutton(sf, text="Toàn bộ nick sống (không gồm Acc chết)", variable=var_scope, value='all_live').pack(anchor='w', padx=10, pady=2)

        def apply():
            g = self._normalize_acc_group(var_group.get())
            scope = var_scope.get()
            if scope == 'selected':
                indices = self._selected_account_indices()
            elif scope == 'visible':
                indices = []
                for iid in self.tree.get_children():
                    try:
                        idx = int(self.tree.item(iid)['values'][0]) - 1
                        if 0 <= idx < len(self.accounts):
                            indices.append(idx)
                    except Exception:
                        pass
            else:
                indices = [i for i, a in enumerate(self.accounts) if not self._is_dead_pool(a)]
            indices = [i for i in indices if not self._is_dead_pool(self.accounts[i])]
            if not indices:
                messagebox.showwarning("Nhóm tài khoản", "Không có nick nào để gán.", parent=dlg)
                return
            for i in indices:
                self._set_acc_group(self.accounts[i], g)
            self.save_accounts()
            self.refresh_table()
            self.log(f"📁 Gán nhóm «{self._acc_group_label(g)}» hàng loạt: {len(indices)} nick ({scope})")
            messagebox.showinfo(
                "Xong",
                f"Đã gán «{self._acc_group_label(g)}» cho {len(indices)} nick.",
                parent=dlg,
            )
            dlg.destroy()

        bf = ttk.Frame(dlg)
        bf.pack(fill='x', padx=14, pady=12)
        ttk.Button(bf, text="Áp dụng", style='Accent.TButton', command=apply).pack(side='right', padx=4)
        ttk.Button(bf, text="Hủy", command=dlg.destroy).pack(side='right', padx=4)

    def refresh_dead_table(self):
        """Tab Acc chết: nick + proxy cách ly."""
        if not hasattr(self, 'dead_tree'):
            return
        for row in self.dead_tree.get_children():
            self.dead_tree.delete(row)
        if hasattr(self, 'dead_proxy_tree'):
            for row in self.dead_proxy_tree.get_children():
                self.dead_proxy_tree.delete(row)

        dead_accs = []
        for idx, acc in enumerate(self.accounts):
            if not self._is_dead_pool(acc):
                continue
            dead_accs.append((idx, acc))
            reason = (
                acc.get('dead_reason')
                or acc.get('scenario_note')
                or acc.get('login_status')
                or acc.get('status')
                or ''
            )
            self.dead_tree.insert(
                '', 'end',
                values=(
                    idx + 1,
                    acc.get('uid', ''),
                    acc.get('email', ''),
                    acc.get('proxy_fingerprint') or acc.get('proxy') or '(không)',
                    acc.get('status') or '',
                    (reason[:80] + '…') if len(str(reason)) > 80 else reason,
                    acc.get('dead_at') or acc.get('last_check') or '',
                ),
            )

        q_px = 0
        for idx, p in enumerate(self.proxies):
            st = str(p.get('status') or '').upper()
            if not (p.get('quarantine') or p.get('pool') == 'dead' or 'QUARANTINE' in st or 'NGƯNG' in st):
                continue
            q_px += 1
            if hasattr(self, 'dead_proxy_tree'):
                self.dead_proxy_tree.insert(
                    '', 'end',
                    values=(
                        idx + 1,
                        p.get('proxy', ''),
                        p.get('status') or 'QUARANTINE',
                        p.get('note') or '',
                        p.get('quarantine_at') or p.get('last_check') or '',
                    ),
                )

        if hasattr(self, 'dead_summary'):
            self.dead_summary.config(
                text=(
                    f"☠ Acc chết: {len(dead_accs)} nick  |  Proxy ngưng dùng: {q_px}  |  "
                    f"Không chạy job tự động — force login cần xác nhận 2 lần"
                )
            )

    def _selected_dead_indices(self):
        if not hasattr(self, 'dead_tree'):
            return []
        indices = []
        for sel in self.dead_tree.selection():
            try:
                idx = int(self.dead_tree.item(sel)['values'][0]) - 1
            except Exception:
                continue
            if 0 <= idx < len(self.accounts) and self._is_dead_pool(self.accounts[idx]):
                indices.append(idx)
        return indices

    def force_login_dead_selected(self):
        """Force login nick Acc chết — cảnh báo nguy hiểm 2 lần."""
        indices = self._selected_dead_indices()
        if not indices:
            messagebox.showwarning("Acc chết", "Chọn ít nhất 1 nick trong tab Acc chết.")
            return
        accs = [self.accounts[i] for i in indices]
        if not self._confirm_danger_login_dead(accs):
            self.log("Đã hủy force login Acc chết (user từ chối cảnh báo).")
            return
        # Bỏ qua filter dead — inject indices thẳng
        config = self.inject_runtime({'force_relogin': True, 'indices': indices, 'require_proxy': False})
        config['allow_dead'] = True
        self._submit_job(
            'LOGIN',
            config=config,
            indices=indices,
            label=f'FORCE LOGIN Acc chết ({len(indices)})',
            kind='playwright',
        )

    def restore_dead_selected(self):
        """Khôi phục nick về tab Nick FB (bỏ cờ dead) — vẫn cảnh báo."""
        indices = self._selected_dead_indices()
        if not indices:
            messagebox.showwarning("Acc chết", "Chọn nick cần khôi phục.")
            return
        if not messagebox.askyesno(
            "Khôi phục",
            f"Khôi phục {len(indices)} nick về tab Nick FB?\n"
            "Proxy cách ly KHÔNG tự bật lại (tránh tái nhiễm).",
        ):
            return
        for idx in indices:
            acc = self.accounts[idx]
            acc['pool'] = 'active'
            acc['is_dead'] = False
            acc['quarantine'] = False
            acc['discard_recommend'] = False
            if str(acc.get('status') or '').upper() in ('DEAD',):
                acc['status'] = 'Chưa check'
            acc['dead_reason'] = ''
            acc['scenario_note'] = (acc.get('scenario_note') or '') + ' [đã khôi phục]'
            # gỡ proxy đã quarantine để user gán proxy mới
            if self._is_proxy_quarantined(acc.get('proxy')):
                acc['proxy'] = ''
                acc['proxy_fingerprint'] = ''
        self.save_accounts()
        self._ui_refresh_dead_and_live()
        messagebox.showinfo("Khôi phục", f"Đã khôi phục {len(indices)} nick. Gán proxy MỚI trước khi chạy.")

    def delete_dead_selected(self):
        indices = self._selected_dead_indices()
        if not indices:
            messagebox.showwarning("Acc chết", "Chọn nick cần xóa.")
            return
        if not messagebox.askyesno("Xóa", f"Xóa hẳn {len(indices)} nick Acc chết?"):
            return
        # xóa từ cao xuống thấp
        for idx in sorted(indices, reverse=True):
            if 0 <= idx < len(self.accounts):
                del self.accounts[idx]
        self.save_accounts()
        self._ui_refresh_dead_and_live()

    def refresh_proxy_table(self):
        if not hasattr(self, 'proxy_tree'):
            return
        for row in self.proxy_tree.get_children():
            self.proxy_tree.delete(row)

        assigned_map = self._get_assigned_map()
        total = len(self.proxies)
        used = free = live = die = expired = 0

        for idx, p in enumerate(self.proxies):
            # Proxy cách ly chỉ hiện ở tab Acc chết
            st0 = str(p.get('status') or '').upper()
            if p.get('quarantine') or p.get('pool') == 'dead' or 'QUARANTINE' in st0 or 'NGƯNG' in st0:
                continue
            px = self._normalize_proxy_key(p.get('proxy', ''))
            uids = assigned_map.get(px, [])
            # chỉ đếm nick còn sống
            uids = [
                u for u in uids
                if not any(
                    str(a.get('uid')) == str(u) and self._is_dead_pool(a)
                    for a in self.accounts
                )
            ]
            if uids:
                assign_status = f'ĐÃ GÁN ({len(uids)})'
                used += 1
                nick_text = ', '.join(uids[:4]) + ('...' if len(uids) > 4 else '')
            else:
                assign_status = 'CHƯA GÁN'
                free += 1
                nick_text = '-'

            exp_label, remain = self._proxy_expire_info(p.get('expire_date', ''))
            if exp_label == 'HẾT HẠN':
                expired += 1

            live_st = p.get('status') or 'Chưa check'
            if 'LIVE' in str(live_st).upper():
                live += 1
            elif 'DIE' in str(live_st).upper():
                die += 1

            self.proxy_tree.insert(
                '', 'end',
                values=(
                    idx + 1,
                    px,
                    live_st,
                    assign_status,
                    nick_text,
                    p.get('expire_date') or 'Chưa set',
                    remain,
                    p.get('note', ''),
                ),
            )

        self.proxy_summary.config(
            text=(
                f"Tổng proxy: {total}  |  Đã gán: {used}  |  Chưa gán: {free}  |  "
                f"LIVE: {live}  |  DIE: {die}  |  Hết hạn: {expired}"
            )
        )

    # ------------------------------------------------------------------
    # Account import / CRUD
    # ------------------------------------------------------------------
    def scan_and_import_accounts(self):
        if not os.path.exists(IMPORT_FILE):
            with open(IMPORT_FILE, 'w', encoding='utf-8') as f:
                f.write("# Nhap danh sach tai khoan can them (Moi dong 1 tai khoan)\n")
                f.write("# Dinh dang: UID|Pass|2FA|Cookie|Email\n")
            messagebox.showinfo(
                "Thông báo",
                f"Không tìm thấy file. Đã tự động tạo file '{IMPORT_FILE}' tại thư mục của tool.\n"
                "Vui lòng mở file, dán danh sách account và bấm quét lại!",
            )
            return

        with open(IMPORT_FILE, 'r', encoding='utf-8') as f:
            lines = f.readlines()

        success_count = 0
        duplicate_count = 0

        for line in lines:
            line = line.strip()
            if not line or line.startswith('#'):
                continue

            parts = line.split('|')
            if len(parts) >= 3:
                uid = parts[0].strip()
                password = parts[1].strip()
                two_fa = parts[2].strip()

                cookie_str = ""
                email = ""

                for part in parts[3:]:
                    part = part.strip()
                    if "c_user=" in part or "xs=" in part or "datr=" in part:
                        cookie_str = part
                    elif "@" in part and "." in part and "Device=" not in part and len(part) < 80:
                        email = part

                if not email:
                    email = uid

                if not any(acc['uid'] == uid for acc in self.accounts):
                    self.accounts.append(self._new_account_dict(
                        uid=uid, password=password, two_fa=two_fa, email=email, cookie=cookie_str
                    ))
                    success_count += 1
                else:
                    duplicate_count += 1

        if success_count > 0:
            # Tự gán tên VN cho nick mới vừa import
            assigned = self._assign_names_to_accounts([
                a for a in self.accounts
                if (self._ensure_acc_group(a) == ACC_GROUP_MOI or a.get('is_new'))
                and not a.get('planned_name')
                and not self._is_dead_pool(a)
            ])
            self.save_accounts()
            self.refresh_table()
            self.refresh_proxy_table()
            self.log(
                f"Đã quét và thêm {success_count} tài khoản từ {IMPORT_FILE} "
                f"(trùng {duplicate_count}). Đã chọn sẵn {assigned} tên profile FB (chưa đẩy lên FB)."
            )
            messagebox.showinfo(
                "Thành công",
                f"Đã thêm {success_count} tài khoản mới!\n"
                f"Đã random {assigned} tên profile Facebook.\n\n"
                "Bấm 'Đổi TÊN profile FB' / 'Đổi AVATAR profile FB' để đổi THẬT trên Facebook.",
            )
        else:
            messagebox.showwarning("Cảnh báo", f"Không tìm thấy tài khoản hợp lệ nào mới trong file (Trùng lặp: {duplicate_count}).")

    def show_bulk_add_dialog(self):
        dialog = tk.Toplevel(self.root)
        dialog.title("Thêm Tài Khoản Hàng Loạt (Dán Định Dạng Pipe |)")
        dialog.geometry("650x500")

        ttk.Label(dialog, text="Dán danh sách tài khoản vào đây (Mỗi dòng 1 tài khoản):", font=("Arial", 9, "bold")).pack(anchor='w', padx=15, pady=10)
        text_box = tk.Text(dialog, height=18, width=75, font=("Consolas", 9))
        text_box.pack(padx=15)

        def parse_and_save():
            raw_data = text_box.get("1.0", "end").strip()
            if not raw_data:
                messagebox.showerror("Lỗi", "Vui lòng nhập dữ liệu!")
                return

            lines = raw_data.split('\n')
            success_count = 0
            duplicate_count = 0

            for line in lines:
                line = line.strip()
                if not line:
                    continue

                parts = line.split('|')
                if len(parts) >= 3:
                    uid = parts[0].strip()
                    password = parts[1].strip()
                    two_fa = parts[2].strip()

                    cookie_str = ""
                    email = ""
                    for part in parts[3:]:
                        part = part.strip()
                        if "c_user=" in part or "xs=" in part or "datr=" in part:
                            cookie_str = part
                        elif "@" in part and "." in part and "Device=" not in part and len(part) < 80:
                            email = part

                    if not email:
                        email = uid

                    if not any(acc['uid'] == uid for acc in self.accounts):
                        self.accounts.append(self._new_account_dict(
                            uid=uid, password=password, two_fa=two_fa, email=email, cookie=cookie_str
                        ))
                        success_count += 1
                    else:
                        duplicate_count += 1

            assigned = 0
            if success_count > 0:
                assigned = self._assign_names_to_accounts(
                    [a for a in self.accounts if a.get('is_new') and not a.get('planned_name')]
                )
            self.save_accounts()
            self.refresh_table()
            self.log(f"Đã thêm {success_count} tài khoản, trùng {duplicate_count}, gán tên VN: {assigned}.")
            dialog.destroy()

        ttk.Button(dialog, text="Xử lý & Thêm vào hệ thống", command=parse_and_save).pack(pady=15)

    def show_add_account_dialog(self):
        dialog = tk.Toplevel(self.root)
        dialog.title("Thêm Tài Khoản Thủ Công")
        dialog.geometry("450x320")

        ttk.Label(dialog, text="UID:").grid(row=0, column=0, sticky='w', padx=20, pady=10)
        uid_entry = self._mk_entry(dialog, width=35)
        uid_entry.grid(row=0, column=1)

        ttk.Label(dialog, text="Mật khẩu:").grid(row=1, column=0, sticky='w', padx=20, pady=10)
        pass_entry = self._mk_entry(dialog, show="*", width=35)
        pass_entry.grid(row=1, column=1)

        ttk.Label(dialog, text="Mã 2FA:").grid(row=2, column=0, sticky='w', padx=20, pady=10)
        fa_entry = self._mk_entry(dialog, width=35)
        fa_entry.grid(row=2, column=1)

        ttk.Label(dialog, text="Proxy:").grid(row=3, column=0, sticky='w', padx=20, pady=10)
        proxy_entry = self._mk_entry(dialog, width=35)
        proxy_entry.grid(row=3, column=1)

        def save():
            uid = uid_entry.get().strip()
            if not uid:
                messagebox.showerror("Lỗi", "Vui lòng nhập UID!")
                return
            if not any(acc['uid'] == uid for acc in self.accounts):
                acc = self._new_account_dict(
                    uid=uid,
                    password=pass_entry.get().strip(),
                    two_fa=fa_entry.get().strip(),
                    email=uid,
                    proxy=proxy_entry.get().strip(),
                )
                acc['planned_name'] = self.pick_random_names(1)
                self.accounts.append(acc)
                self.save_accounts()
                self._sync_proxy_assignments()
                self.refresh_table()
                self.refresh_proxy_table()
                self.log(f"Thêm tài khoản: {uid} | tên gán: {acc['planned_name']}")
            else:
                messagebox.showwarning("Cảnh báo", "Tài khoản đã tồn tại!")
            dialog.destroy()

        ttk.Button(dialog, text="Lưu", command=save).grid(row=4, column=0, columnspan=2, pady=15)

    def delete_account(self):
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("Cảnh báo", "Vui lòng chọn tài khoản cần xóa!")
            return
        if messagebox.askyesno("Xác nhận", "Bạn có chắc muốn xóa?"):
            item = self.tree.item(selected[0])
            idx = item['values'][0] - 1
            deleted = self.accounts.pop(idx)
            self.save_accounts()
            self.refresh_table()
            self.refresh_proxy_table()
            self.log(f"Đã xóa tài khoản: {deleted.get('uid')}")

    def delete_all_accounts(self):
        if not self.accounts:
            return
        if messagebox.askyesno("Xác nhận nguy hiểm", "Bạn có thực sự muốn XÓA TOÀN BỘ tài khoản khỏi hệ thống?"):
            self.accounts = []
            self.save_accounts()
            self.refresh_table()
            self.refresh_proxy_table()
            self.log("Đã xóa toàn bộ tài khoản khỏi hệ thống.")

    def browse_folder(self):
        folder_selected = filedialog.askdirectory()
        if folder_selected:
            self.avatar_folder_entry.delete(0, tk.END)
            self.avatar_folder_entry.insert(0, folder_selected)

    def browse_post_image(self):
        path = filedialog.askopenfilename(
            title="Chọn ảnh đăng bài",
            filetypes=[("Images", "*.jpg *.jpeg *.png *.gif *.webp *.bmp"), ("All", "*.*")],
        )
        if path:
            self.post_image_path.set(path)

    def refresh_report_table(self):
        if not hasattr(self, 'report_tree'):
            return
        for row in self.report_tree.get_children():
            self.report_tree.delete(row)
        ok = fail = 0
        for item in reversed(self.reports[-300:]):
            st = str(item.get('status', '')).upper()
            if st in ('SUCCESS', 'OK', 'JOINED', 'PENDING', 'LIVE'):
                ok += 1
            else:
                fail += 1
            self.report_tree.insert(
                '', 'end',
                values=(
                    item.get('time', ''),
                    item.get('uid', ''),
                    item.get('task', ''),
                    item.get('target', ''),
                    item.get('status', ''),
                    item.get('message', ''),
                    item.get('link', ''),
                ),
            )
        if hasattr(self, 'report_summary'):
            self.report_summary.config(text=f"Tổng (300 gần nhất): {min(len(self.reports), 300)}  |  OK/Success: {ok}  |  Fail/khác: {fail}")

    def refresh_groups_table(self):
        if not hasattr(self, 'groups_tree'):
            return
        for row in self.groups_tree.get_children():
            self.groups_tree.delete(row)
        for acc in self.accounts:
            uid = acc.get('uid', '')
            for g in acc.get('joined_groups') or []:
                self.groups_tree.insert(
                    '', 'end',
                    values=(
                        uid,
                        g.get('name', ''),
                        g.get('status', ''),
                        g.get('url', ''),
                        g.get('joined_at', ''),
                    ),
                )

    def clear_reports(self):
        if messagebox.askyesno("Xác nhận", "Xóa toàn bộ báo cáo?"):
            self.reports = []
            self.save_reports()
            self.refresh_report_table()

    def open_selected_report_link(self):
        sel = self.report_tree.selection()
        if not sel:
            messagebox.showwarning("Cảnh báo", "Chọn 1 dòng báo cáo có link!")
            return
        link = self.report_tree.item(sel[0])['values'][6]
        if not link:
            messagebox.showinfo("Thông báo", "Dòng này không có link.")
            return
        try:
            import webbrowser
            webbrowser.open(str(link))
        except Exception as ex:
            messagebox.showerror("Lỗi", str(ex))

    def clear_selected_group_history(self):
        sel = self.groups_tree.selection()
        if not sel:
            messagebox.showwarning("Cảnh báo", "Chọn dòng nhóm cần xóa khỏi lịch sử!")
            return
        for s in sel:
            vals = self.groups_tree.item(s)['values']
            uid, name, _, url, _ = vals[0], vals[1], vals[2], vals[3], vals[4]
            for acc in self.accounts:
                if acc.get('uid') != str(uid):
                    continue
                groups = acc.get('joined_groups') or []
                acc['joined_groups'] = [
                    g for g in groups
                    if not (g.get('name') == name or (url and g.get('url') == url))
                ]
        self.save_accounts()
        self.refresh_groups_table()

    def generate_local_content(self):
        """Sinh nội dung mẫu local (không cần API) từ prompt + spin."""
        prompt = self.ai_prompt.get("1.0", "end").strip()
        link = self.promo_link.get().strip()
        templates = [
            f"{{Hôm nay|Ngày mới}} mình muốn chia sẻ: {prompt}\n{{Mọi người xem thử nhé|Tham khảo tại}}: {link}",
            f"{prompt}\n\n{{Link hữu ích|Xem chi tiết}}: {link}\n#chia sẻ #mẹo vặt",
            f"{{Chào cả nhà|Xin chào}}!\n{prompt}\n{{Ib mình nếu cần|Xem thêm}}: {link}",
        ]
        content = random.choice(templates)
        self.post_content.delete("1.0", tk.END)
        self.post_content.insert("1.0", content)
        messagebox.showinfo("Đã tạo", "Đã chèn nội dung mẫu — hãy chỉnh sửa rồi bấm Xem & chỉnh / Đăng ngay.")

    def preview_and_post(self):
        """Dialog xem/sửa nội dung + ảnh trước khi đăng."""
        dialog = tk.Toplevel(self.root)
        dialog.title("Xem & chỉnh nội dung trước khi đăng")
        dialog.geometry("700x520")
        ttk.Label(dialog, text="Nội dung (có thể sửa, Spin {A|B} sẽ random khi đăng):", font=("Arial", 9, "bold")).pack(anchor='w', padx=12, pady=8)
        box = tk.Text(dialog, height=14, width=80, font=("Arial", 11))
        box.pack(padx=12, fill='both', expand=True)
        box.insert("1.0", self.post_content.get("1.0", "end").strip())

        img_var = tk.StringVar(value=self.post_image_path.get())
        irow = ttk.Frame(dialog)
        irow.pack(fill='x', padx=12, pady=8)
        ttk.Label(irow, text="Ảnh:").pack(side='left')
        self._mk_entry(irow, textvariable=img_var, width=55).pack(side='left', padx=6)

        def pick():
            p = filedialog.askopenfilename(filetypes=[("Images", "*.jpg *.jpeg *.png *.gif *.webp"), ("All", "*.*")])
            if p:
                img_var.set(p)

        ttk.Button(irow, text="Chọn ảnh", command=pick).pack(side='left')

        def confirm():
            content = box.get("1.0", "end").strip()
            if not content:
                messagebox.showerror("Lỗi", "Nội dung trống!", parent=dialog)
                return
            self.post_content.delete("1.0", tk.END)
            self.post_content.insert("1.0", content)
            self.post_image_path.set(img_var.get().strip())
            dialog.destroy()
            self.start_post_now()

        ttk.Button(dialog, text="✓ Xác nhận & Đăng", command=confirm).pack(pady=12)

    @staticmethod
    def spin_text(text):
        """Thay {A|B|C} bằng 1 lựa chọn ngẫu nhiên."""
        def repl(m):
            parts = m.group(1).split('|')
            return random.choice(parts).strip()
        prev = None
        out = text
        while prev != out:
            prev = out
            out = re.sub(r'\{([^{}]+)\}', repl, out)
        return out

    @staticmethod
    def generate_totp(secret):
        secret = re.sub(r'\s+', '', (secret or '').upper())
        if not secret:
            return ''
        try:
            import pyotp
            return pyotp.TOTP(secret).now()
        except Exception:
            pass
        # fallback TOTP
        import hmac
        import hashlib
        import struct
        import base64
        try:
            pad = '=' * ((8 - len(secret) % 8) % 8)
            key = base64.b32decode(secret + pad, casefold=True)
            counter = struct.pack('>Q', int(time.time()) // 30)
            hs = hmac.new(key, counter, hashlib.sha1).digest()
            o = hs[-1] & 0x0F
            code = ((hs[o] & 0x7F) << 24 | (hs[o + 1] & 0xFF) << 16 | (hs[o + 2] & 0xFF) << 8 | (hs[o + 3] & 0xFF))
            return str(code % 1_000_000).zfill(6)
        except Exception:
            return ''

    # ------------------------------------------------------------------
    # Proxy management
    # ------------------------------------------------------------------
    def _app_dir(self):
        return os.path.dirname(os.path.abspath(__file__)) or os.getcwd()

    def _ip_port_key(self, proxy_str):
        """Key gộp trùng theo IP:PORT (bỏ user/pass)."""
        p = self._normalize_proxy_key(proxy_str or '')
        if not p:
            return ''
        parts = p.split(':')
        if len(parts) >= 2:
            return f'{parts[0]}:{parts[1]}'
        return p

    def _proxy_has_auth(self, proxy_str):
        return len(self._normalize_proxy_key(proxy_str or '').split(':')) == 4

    def _proxy_file_candidates(self):
        """Mọi file .txt trong thư mục app có thể chứa proxy (trừ file account/readme)."""
        app_dir = self._app_dir()
        skip_names = {
            IMPORT_FILE.lower(),
            'import_accounts.txt.txt',
            'readme.txt',
            'requirements.txt',
            'accounts_v9.json',
        }
        skip_prefixes = ('import_accounts', 'account', 'readme', 'requirement')
        files = []
        try:
            for name in os.listdir(app_dir):
                low = name.lower()
                if not low.endswith('.txt'):
                    continue
                if low in skip_names:
                    continue
                if any(low.startswith(p) for p in skip_prefixes):
                    continue
                # Ưu tiên file có chữ proxy
                path = os.path.join(app_dir, name)
                if not os.path.isfile(path):
                    continue
                files.append(path)
        except Exception as ex:
            self.log(f"Lỗi đọc thư mục app: {ex}")
        # Ưu tiên file tên có 'proxy'
        files.sort(key=lambda p: (0 if 'proxy' in os.path.basename(p).lower() else 1, os.path.basename(p).lower()))
        return files

    def _scan_proxies_from_app_dir(self):
        """
        Quét toàn bộ file proxy trong thư mục app.
        Trả về (list items, sources_info).
        Ưu tiên bản có USER:PASS khi trùng IP:PORT.
        """
        by_ip_port = {}  # ip:port -> item dict
        sources = []

        for path in self._proxy_file_candidates():
            imported = self._import_proxy_lines_from_file(path)
            if not imported:
                # fallback: đọc raw + regex user:pass@ip:port và ip:port
                try:
                    raw = open(path, 'r', encoding='utf-8', errors='replace').read()
                except Exception:
                    continue
                imported = []
                for m in re.finditer(r'([^:\s]+):([^@\s]+)@(\d+\.\d+\.\d+\.\d+):(\d+)', raw):
                    user, password, ip, port = m.groups()
                    imported.append({
                        'proxy': f'{ip}:{port}:{user}:{password}',
                        'status': 'Chưa check',
                        'expire_date': '',
                        'note': f'Từ {os.path.basename(path)}',
                        'last_check': '',
                    })
                for m in re.finditer(r'(?<![@\d])(\d+\.\d+\.\d+\.\d+):(\d+)(?!:)', raw):
                    ip, port = m.groups()
                    imported.append({
                        'proxy': f'{ip}:{port}',
                        'status': 'Chưa check',
                        'expire_date': '',
                        'note': f'Từ {os.path.basename(path)}',
                        'last_check': '',
                    })

            if not imported:
                continue

            sources.append(f"{os.path.basename(path)} ({len(imported)})")
            for item in imported:
                key = self._ip_port_key(item['proxy'])
                if not key:
                    continue
                item = dict(item)
                item['proxy'] = self._normalize_proxy_key(item['proxy'])
                item.setdefault('status', 'Chưa check')
                item.setdefault('expire_date', '')
                item.setdefault('note', f"Từ {os.path.basename(path)}")
                item.setdefault('last_check', '')

                if key not in by_ip_port:
                    by_ip_port[key] = item
                else:
                    # Ưu tiên bản có auth; cập nhật expire nếu có
                    old = by_ip_port[key]
                    if self._proxy_has_auth(item['proxy']) and not self._proxy_has_auth(old['proxy']):
                        if old.get('expire_date') and not item.get('expire_date'):
                            item['expire_date'] = old['expire_date']
                        by_ip_port[key] = item
                    elif item.get('expire_date') and not old.get('expire_date'):
                        old['expire_date'] = item['expire_date']

        return list(by_ip_port.values()), sources

    def _merge_scanned_proxies(self, scanned_items):
        """Gộp proxy quét được vào self.proxies. Trả về số thêm mới."""
        # map ip:port -> index
        index_by_ip = {}
        for i, p in enumerate(self.proxies):
            index_by_ip[self._ip_port_key(p.get('proxy', ''))] = i

        added = 0
        for item in scanned_items:
            key = self._ip_port_key(item['proxy'])
            if key not in index_by_ip:
                self.proxies.append(item)
                index_by_ip[key] = len(self.proxies) - 1
                added += 1
            else:
                old = self.proxies[index_by_ip[key]]
                # nâng cấp lên bản có auth
                if self._proxy_has_auth(item['proxy']) and not self._proxy_has_auth(old.get('proxy', '')):
                    old['proxy'] = item['proxy']
                if item.get('expire_date') and not old.get('expire_date'):
                    old['expire_date'] = item['expire_date']
                if item.get('note') and not old.get('note'):
                    old['note'] = item['note']
        return added

    def scan_proxies_in_folder_only(self):
        """Chỉ quét / nạp proxy từ mọi file trong thư mục app (không check, không gán)."""
        scanned, sources = self._scan_proxies_from_app_dir()
        if not scanned:
            messagebox.showwarning(
                "Cảnh báo",
                "Không tìm thấy proxy nào trong thư mục app.\n"
                "Hãy đặt file proxies.txt / proxy_list*.txt cạnh app.",
            )
            return
        added = self._merge_scanned_proxies(scanned)
        self.save_proxies()
        self.refresh_proxy_table()
        src_text = ', '.join(sources) if sources else 'N/A'
        self.log(f"Quét thư mục: +{added} mới, tổng {len(self.proxies)}. Nguồn: {src_text}")
        messagebox.showinfo(
            "Quét xong",
            f"Nguồn: {src_text}\nThêm mới: {added}\nTổng proxy: {len(self.proxies)}",
        )

    def scan_folder_check_and_assign(self):
        """
        1) Quét mọi file proxy trong thư mục app
        2) Check live (bỏ proxy cách ly)
        3) Gán 1 proxy LIVE / 3 nick CÒN SỐNG
        """
        # Nếu job kẹt: tự reset khi không còn current_job thực sự
        if self.is_running and not self.current_job and not self.job_queue:
            self.is_running = False
            self.stop_requested = False

        live_n = sum(1 for a in self.accounts if not self._is_dead_pool(a))
        if live_n <= 0:
            messagebox.showwarning(
                "Cảnh báo",
                "Không còn nick sống để gán proxy.\n"
                "(Nick checkpoint/die nằm ở tab Acc chết — không gán.)",
            )
            return

        scanned, sources = self._scan_proxies_from_app_dir()
        added = self._merge_scanned_proxies(scanned) if scanned else 0
        self.save_proxies()
        try:
            self.refresh_proxy_table()
        except Exception:
            pass

        usable = [
            p for p in self.proxies
            if not (p.get('quarantine') or p.get('pool') == 'dead')
        ]
        if not usable and not self.proxies:
            messagebox.showwarning(
                "Cảnh báo",
                "Không có proxy để check.\nĐặt file proxies.txt / proxy_list*.txt cạnh app rồi thử lại.",
            )
            return
        if not usable:
            messagebox.showwarning(
                "Cảnh báo",
                f"Có {len(self.proxies)} proxy nhưng tất cả đã CÁCH LY (tab Acc chết).\n"
                "Thêm proxy MỚI vào proxies.txt rồi quét lại.",
            )
            return

        src_text = ', '.join(sources) if sources else 'danh sách hiện có'
        need_proxy = (live_n + 2) // 3
        q_n = len(self.proxies) - len(usable)
        self.log(
            f">>> QUÉT + GÁN PROXY: nguồn [{src_text}] | +{added} mới | "
            f"proxy dùng được={len(usable)} | cách ly={q_n} | "
            f"nick sống={live_n} | cần ≥{need_proxy} proxy (1 proxy / 3 nick)"
        )
        if self.is_running or self.current_job:
            self.log("⏳ Đang có job khác — xếp hàng chờ quét/gán proxy…")

        def runner(job):
            job['current_uid'] = '(scan+assign)'
            job['waiting_uids'] = []
            self._ui_refresh_jobs()
            self._run_proxy_check_and_assign()
            job.setdefault('done_uids', []).append('(scan+assign)')

        self._submit_job(
            'SCAN_ASSIGN_PROXY',
            config={},
            indices=[],
            label='Quét & gán proxy',
            kind='scan',
            runner=runner,
        )
        messagebox.showinfo(
            "Đã bắt đầu",
            f"Đang quét/check/gán proxy nền…\n"
            f"Nick sống: {live_n}\n"
            f"Proxy dùng được: {len(usable)} (cách ly: {q_n})\n"
            f"Cần tối thiểu {need_proxy} proxy LIVE.\n\n"
            "Xem log / panel Lệnh chạy. Xong sẽ có hộp thoại kết quả.",
        )

    def import_proxies_from_file(self):
        if not os.path.exists(PROXY_FILE):
            with open(PROXY_FILE, 'w', encoding='utf-8') as f:
                f.write("# Form: IP:PORT:USER:PASS  hoac  IP:PORT:USER:PASS|YYYY-MM-DD\n")
            messagebox.showinfo("Thông báo", f"Đã tạo file '{PROXY_FILE}'. Hãy dán proxy vào rồi nạp lại.")
            return

        imported = self._import_proxy_lines_from_file(PROXY_FILE)
        if not imported:
            messagebox.showwarning("Cảnh báo", "File proxies.txt trống hoặc không có proxy hợp lệ.")
            return

        added = self._merge_scanned_proxies(imported)
        self.save_proxies()
        self.refresh_proxy_table()
        self.log(f"Nạp proxy từ file: thêm mới {added}, tổng {len(self.proxies)}.")
        messagebox.showinfo("Thành công", f"Đã nạp proxy. Thêm mới: {added}. Tổng: {len(self.proxies)}.")

    def show_add_proxies_dialog(self):
        dialog = tk.Toplevel(self.root)
        dialog.title("Thêm Proxy Hàng Loạt")
        dialog.geometry("560x480")

        ttk.Label(
            dialog,
            text="Dán proxy (mỗi dòng 1 cái). Hỗ trợ hạn: IP:PORT:USER:PASS|YYYY-MM-DD",
            font=("Arial", 9, "bold"),
        ).pack(anchor='w', padx=15, pady=10)
        text_box = tk.Text(dialog, height=16, width=70, font=("Consolas", 9))
        text_box.pack(padx=15)

        default_exp = self._mk_entry(dialog, width=20)
        row = ttk.Frame(dialog)
        row.pack(fill='x', padx=15, pady=6)
        ttk.Label(row, text="Hạn mặc định (YYYY-MM-DD, để trống nếu không):").pack(side='left')
        default_exp.pack(side='left', padx=8)

        def add():
            raw = text_box.get("1.0", "end").strip()
            if not raw:
                messagebox.showerror("Lỗi", "Vui lòng dán proxy!")
                return
            def_exp = default_exp.get().strip()
            if def_exp and not re.match(r'^\d{4}-\d{2}-\d{2}$', def_exp):
                messagebox.showerror("Lỗi", "Hạn mặc định sai form (YYYY-MM-DD)!")
                return

            known = {self._normalize_proxy_key(p.get('proxy', '')) for p in self.proxies}
            added = 0
            for line in raw.split('\n'):
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                proxy_str, expire = self._parse_proxy_line(line)
                if not proxy_str:
                    continue
                if not expire:
                    expire = def_exp
                key = self._normalize_proxy_key(proxy_str)
                if key not in known:
                    self.proxies.append({
                        'proxy': key,
                        'status': 'Chưa check',
                        'expire_date': expire,
                        'note': '',
                        'last_check': '',
                    })
                    known.add(key)
                    added += 1
            self.save_proxies()
            self.refresh_proxy_table()
            self.log(f"Đã thêm {added} proxy mới.")
            messagebox.showinfo("Thành công", f"Đã thêm {added} proxy.")
            dialog.destroy()

        ttk.Button(dialog, text="Thêm vào hệ thống", command=add).pack(pady=12)

    def show_bulk_proxy_dialog(self):
        dialog = tk.Toplevel(self.root)
        dialog.title("Gán Proxy Hàng Loạt (3 Nick / 1 Proxy)")
        dialog.geometry("500x400")

        ttk.Label(dialog, text="Dán danh sách Proxy vào đây (Mỗi dòng 1 Proxy):", font=("Arial", 9, "bold")).pack(anchor='w', padx=15, pady=10)
        text_box = tk.Text(dialog, height=15, width=55, font=("Consolas", 9))
        text_box.pack(padx=15)

        def assign():
            raw_data = text_box.get("1.0", "end").strip()
            if not raw_data:
                messagebox.showerror("Lỗi", "Vui lòng nhập danh sách Proxy!")
                return

            proxies = []
            invalid = 0
            for line in raw_data.split('\n'):
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                p, _ = self._parse_proxy_line(line)
                if p and self.is_valid_proxy_format(p):
                    proxies.append(p)
                else:
                    invalid += 1
                    self.log(f"[PROXY SAI FORM] Bỏ qua, không gán: {line[:80]}")

            if not proxies:
                messagebox.showerror(
                    "Lỗi định dạng proxy",
                    f"Không có proxy đúng form để gán!\n"
                    f"Sai form: {invalid}\n\n"
                    "Form đúng:\n"
                    "  IP:PORT\n"
                    "  IP:PORT:USER:PASS\n"
                    "  user:pass@ip:port",
                )
                return

            if not self.accounts:
                messagebox.showerror("Lỗi", "Danh sách tài khoản đang trống!")
                return

            # register only valid proxies
            known = {self._normalize_proxy_key(p.get('proxy', '')) for p in self.proxies}
            for p in proxies:
                if p not in known:
                    self.proxies.append({
                        'proxy': p, 'status': 'Chưa check', 'expire_date': '', 'note': '', 'last_check': ''
                    })
                    known.add(p)

            live_accs = [a for a in self.accounts if not self._is_dead_pool(a)]
            # bỏ proxy đang cách ly khỏi list gán
            proxies = [p for p in proxies if not self._is_proxy_quarantined(p)]
            if not proxies:
                messagebox.showerror("Lỗi", "Mọi proxy dán vào đều đang cách ly / không dùng được.")
                return
            if not live_accs:
                messagebox.showerror("Lỗi", "Không còn nick sống để gán.")
                return

            proxy_idx = 0
            count = 0
            for i, acc in enumerate(live_accs):
                if proxy_idx < len(proxies):
                    acc['proxy'] = proxies[proxy_idx]
                    parts = str(proxies[proxy_idx]).split(':')
                    acc['proxy_fingerprint'] = ':'.join(parts[:2]) if len(parts) >= 2 else proxies[proxy_idx]
                    count += 1
                    if (i + 1) % 3 == 0:
                        proxy_idx += 1
                else:
                    break

            self.save_accounts()
            self.save_proxies()
            self.refresh_table()
            self.refresh_proxy_table()
            left = len(live_accs) - count
            self.log(
                f"Đã gán proxy cho {count}/{len(live_accs)} nick sống (3 nick/1 proxy). Còn trống: {left}."
            )
            msg = f"Đã gán {count}/{len(live_accs)} nick sống!"
            if left:
                msg += f"\n⚠ Còn {left} nick trống — thiếu proxy."
            messagebox.showinfo("Thành công", msg)
            dialog.destroy()

        ttk.Button(dialog, text="Xử lý & Gán Proxy", command=assign).pack(pady=15)

    def _build_assign_pool(self, prefer_live=True):
        """Danh sách proxy để gán: LIVE + còn hạn trước → chưa check → DIE (cuối)."""
        today = datetime.now().date()

        def is_expired(p):
            exp = p.get('expire_date') or ''
            if not exp:
                return False
            try:
                return datetime.strptime(exp, '%Y-%m-%d').date() < today
            except ValueError:
                return False

        live_pool, pending_pool, die_pool = [], [], []
        for p in self.proxies:
            px = self._normalize_proxy_key(p.get('proxy', ''))
            if not px or is_expired(p):
                continue
            # Không gán proxy đã cách ly / Acc chết
            st = str(p.get('status', '')).upper()
            if p.get('quarantine') or p.get('pool') == 'dead' or 'QUARANTINE' in st or 'NGƯNG' in st:
                continue
            if 'LIVE' in st and 'DIE' not in st:
                live_pool.append(px)
            elif 'DIE' in st:
                die_pool.append(px)
            else:
                pending_pool.append(px)

        if prefer_live:
            pool = live_pool + pending_pool + die_pool
        else:
            pool = live_pool + pending_pool + die_pool

        if not pool:
            pool = [
                self._normalize_proxy_key(p.get('proxy', ''))
                for p in self.proxies
                if self._normalize_proxy_key(p.get('proxy', ''))
                and not (p.get('quarantine') or p.get('pool') == 'dead')
            ]

        seen, uniq = set(), []
        for px in pool:
            if px and px not in seen:
                seen.add(px)
                uniq.append(px)
        return uniq

    def _assign_one_proxy_per_three_accounts(self, pool=None, mode='reassign'):
        """
        Gán proxy cho nick CÒN SỐNG (bỏ Acc chết).
        mode:
          - reassign: gán lại toàn bộ nick sống (1 proxy / 3 nick)
          - fill: chỉ lấp nick chưa có proxy (giữ gán cũ nếu proxy còn sạch)
        Trả về (số nick đã gán trong lượt, số proxy dùng, số nick sống còn trống).
        """
        if pool is None:
            pool = self._build_assign_pool(prefer_live=True)

        live_accs = [a for a in self.accounts if not self._is_dead_pool(a)]
        if not live_accs:
            return 0, 0, 0

        # Lọc pool sạch
        clean_pool = []
        seen = set()
        for px in (pool or []):
            px = self._normalize_proxy_key(px or '')
            if not px or px in seen:
                continue
            if self._is_proxy_quarantined(px):
                continue
            seen.add(px)
            clean_pool.append(px)
        pool = clean_pool
        if not pool:
            return 0, 0, len(live_accs)

        assigned = 0
        used_keys = set()

        if mode == 'fill':
            # Đếm đã gán hợp lệ
            load = {}  # proxy_key -> count
            need = []
            for acc in live_accs:
                px = self._normalize_proxy_key(acc.get('proxy') or '')
                if px and not self._is_proxy_quarantined(px):
                    load[px] = load.get(px, 0) + 1
                    used_keys.add(px)
                else:
                    # proxy bẩn/trống → bỏ gán cũ
                    acc['proxy'] = ''
                    acc['proxy_fingerprint'] = ''
                    need.append(acc)

            # Mở rộng pool bằng proxy đang dùng (còn slot)
            for px in list(load.keys()):
                if px not in pool:
                    pool.append(px)

            for acc in need:
                placed = False
                for px in pool:
                    if load.get(px, 0) >= 3:
                        continue
                    acc['proxy'] = px
                    parts = px.split(':')
                    acc['proxy_fingerprint'] = ':'.join(parts[:2]) if len(parts) >= 2 else px
                    load[px] = load.get(px, 0) + 1
                    used_keys.add(px)
                    assigned += 1
                    placed = True
                    break
                if not placed:
                    break
        else:
            # reassign full
            for acc in live_accs:
                acc['proxy'] = ''
                acc['proxy_fingerprint'] = ''
            for i, acc in enumerate(live_accs):
                proxy_idx = i // 3
                if proxy_idx >= len(pool):
                    break
                px = pool[proxy_idx]
                acc['proxy'] = px
                parts = px.split(':')
                acc['proxy_fingerprint'] = ':'.join(parts[:2]) if len(parts) >= 2 else px
                used_keys.add(px)
                assigned += 1

        unassigned = sum(
            1 for a in live_accs
            if not (a.get('proxy') or '').strip() or self._is_proxy_quarantined(a.get('proxy'))
        )
        return assigned, len(used_keys), unassigned

    def auto_assign_proxies(self):
        if not self.proxies:
            messagebox.showwarning("Cảnh báo", "Chưa có proxy trong hệ thống!")
            return
        live_n = sum(1 for a in self.accounts if not self._is_dead_pool(a))
        if live_n <= 0:
            messagebox.showwarning("Cảnh báo", "Không còn nick sống để gán proxy.")
            return

        pool = self._build_assign_pool(prefer_live=True)
        if not pool:
            messagebox.showwarning(
                "Cảnh báo",
                "Không có proxy sạch để gán (toàn bộ đang cách ly hoặc trống).\n"
                "Thêm proxy mới / bỏ cách ly rồi thử lại.",
            )
            return

        assigned, used, unassigned = self._assign_one_proxy_per_three_accounts(pool, mode='reassign')

        self.save_accounts()
        self.refresh_table()
        self.refresh_proxy_table()
        try:
            self.refresh_dead_table()
        except Exception:
            pass
        self.log(
            f"Gán 1 proxy / 3 nick: gán {assigned} nick sống, dùng {used} proxy, "
            f"còn trống {unassigned}/{live_n}."
        )
        msg = (
            f"Đã gán: {assigned} nick sống\n"
            f"Dùng: {used} proxy (1 proxy / 3 nick)\n"
            f"Nick sống còn trống: {unassigned}/{live_n}"
        )
        if unassigned:
            need = (unassigned + 2) // 3
            msg += f"\n\n⚠ Thiếu khoảng {need} proxy LIVE sạch nữa."
        messagebox.showinfo("Gán proxy", msg)

    def unassign_selected_proxy(self):
        selected = self.proxy_tree.selection()
        if not selected:
            messagebox.showwarning("Cảnh báo", "Chọn proxy cần bỏ gán!")
            return
        keys = set()
        for sel in selected:
            vals = self.proxy_tree.item(sel)['values']
            keys.add(str(vals[1]))
        count = 0
        for acc in self.accounts:
            px = self._normalize_proxy_key(acc.get('proxy', ''))
            if px in keys:
                acc['proxy'] = ''
                count += 1
        self.save_accounts()
        self.refresh_table()
        self.refresh_proxy_table()
        self.log(f"Đã bỏ gán proxy khỏi {count} tài khoản.")
        messagebox.showinfo("Thành công", f"Đã bỏ gán {count} tài khoản.")

    def set_expire_selected(self):
        selected = self.proxy_tree.selection()
        if not selected:
            messagebox.showwarning("Cảnh báo", "Chọn proxy cần set hạn!")
            return
        date_str = simpledialog.askstring(
            "Hạn sử dụng",
            "Nhập ngày hết hạn (YYYY-MM-DD):\nVí dụ: 2026-08-28",
            parent=self.root,
        )
        if not date_str:
            return
        date_str = date_str.strip()
        if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
            messagebox.showerror("Lỗi", "Sai form ngày. Dùng YYYY-MM-DD.")
            return
        keys = {str(self.proxy_tree.item(s)['values'][1]) for s in selected}
        n = 0
        for p in self.proxies:
            if self._normalize_proxy_key(p['proxy']) in keys:
                p['expire_date'] = date_str
                n += 1
        self.save_proxies()
        self.refresh_proxy_table()
        self.log(f"Đã set hạn {date_str} cho {n} proxy.")

    def set_expire_all_days(self):
        if not self.proxies:
            return
        days = simpledialog.askinteger(
            "Hạn sử dụng",
            "Set hạn = hôm nay + N ngày cho TẤT CẢ proxy:\nNhập N (ví dụ 30):",
            parent=self.root,
            minvalue=1,
            maxvalue=3650,
        )
        if not days:
            return
        date_str = (datetime.now() + timedelta(days=days)).strftime('%Y-%m-%d')
        for p in self.proxies:
            p['expire_date'] = date_str
        self.save_proxies()
        self.refresh_proxy_table()
        self.log(f"Đã set hạn {date_str} (+{days} ngày) cho {len(self.proxies)} proxy.")
        messagebox.showinfo("Thành công", f"Hạn dùng: {date_str} (+{days} ngày) cho tất cả proxy.")

    def delete_selected_proxies(self):
        selected = self.proxy_tree.selection()
        if not selected:
            messagebox.showwarning("Cảnh báo", "Chọn proxy cần xóa!")
            return
        if not messagebox.askyesno("Xác nhận", f"Xóa {len(selected)} proxy đã chọn?"):
            return
        keys = {str(self.proxy_tree.item(s)['values'][1]) for s in selected}
        self.proxies = [p for p in self.proxies if self._normalize_proxy_key(p['proxy']) not in keys]
        # clear from accounts
        for acc in self.accounts:
            if self._normalize_proxy_key(acc.get('proxy', '')) in keys:
                acc['proxy'] = ''
        self.save_proxies()
        self.save_accounts()
        self.refresh_table()
        self.refresh_proxy_table()
        self.log(f"Đã xóa {len(keys)} proxy.")

    def delete_all_proxies(self):
        if not self.proxies:
            return
        if not messagebox.askyesno("Xác nhận", "Xóa TOÀN BỘ proxy?"):
            return
        self.proxies = []
        for acc in self.accounts:
            acc['proxy'] = ''
        self.save_proxies()
        self.save_accounts()
        self.refresh_table()
        self.refresh_proxy_table()
        self.log("Đã xóa toàn bộ proxy.")

    def check_all_proxies_live(self):
        if not self.proxies:
            messagebox.showwarning("Cảnh báo", "Chưa có proxy!")
            return
        usable = [
            p for p in self.proxies
            if not (p.get('quarantine') or p.get('pool') == 'dead')
        ]
        if not usable:
            messagebox.showwarning("Cảnh báo", "Không còn proxy sạch (toàn bộ đang cách ly).")
            return
        labels = [(p.get('proxy') or '')[:40] for p in usable]

        def runner(job):
            live = die = 0
            now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            total = len(usable)
            job['uids'] = list(labels)
            job['waiting_uids'] = list(labels)
            for i, p in enumerate(usable):
                if self._should_stop():
                    left = job.get('waiting_uids') or []
                    for u in left:
                        if u not in job.get('cancelled_uids', []):
                            job.setdefault('cancelled_uids', []).append(u)
                    job['waiting_uids'] = []
                    self.log(f"⏹ Dừng check proxy — còn {total - i} proxy")
                    raise JobStopped('Dừng check proxy')
                px = p.get('proxy', '')
                tag = (px or '')[:40]
                job['current_uid'] = tag[:32]
                if tag in (job.get('waiting_uids') or []):
                    job['waiting_uids'].remove(tag)
                self._ui_refresh_jobs()
                ok = self._check_one_proxy(px)
                if not (p.get('quarantine') or p.get('pool') == 'dead'):
                    p['status'] = 'LIVE' if ok else 'DIE'
                p['last_check'] = now
                if ok:
                    live += 1
                    job.setdefault('done_uids', []).append(tag[:32])
                    self.log(f"[PROXY LIVE] {px}")
                else:
                    die += 1
                    job.setdefault('failed_uids', []).append(tag[:32])
                    self.log(f"[PROXY DIE]  {px}")
                job['current_uid'] = ''
                self._ui_refresh_jobs()
            self.save_proxies()
            self._ui_call(self.refresh_proxy_table)
            self.log(f">>> XONG CHECK PROXY: LIVE={live} | DIE={die} (chỉ proxy sạch)")
            job['message'] = f'LIVE={live} DIE={die}'

        self._submit_job(
            'CHECK_PROXY',
            config={},
            indices=list(range(len(self.proxies))),
            label='Check live Proxy',
            kind='proxy',
            runner=runner,
        )
        # override uids display after submit (job may be current or queued)
        target = self.current_job if (self.current_job and self.current_job.get('task_type') == 'CHECK_PROXY') else None
        if not target:
            for j in reversed(self.job_queue):
                if j.get('task_type') == 'CHECK_PROXY':
                    target = j
                    break
        if target:
            target['uids'] = list(labels)
            target['waiting_uids'] = list(labels)
            self._ui_refresh_jobs()
        self.log(f">>> CHECK LIVE {len(self.proxies)} PROXY...")

    def _check_one_proxy(self, proxy_str):
        try:
            proxy_url = self._build_proxy_url(proxy_str)
            handler = urllib.request.ProxyHandler({'http': proxy_url, 'https': proxy_url})
            opener = urllib.request.build_opener(handler)
            req = urllib.request.Request(
                'http://www.google.com',
                headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'},
            )
            opener.open(req, timeout=8)
            return True
        except Exception:
            return False

    def scan_and_check_proxies(self):
        """Alias cũ — chuyển sang quét cả thư mục app."""
        self.scan_folder_check_and_assign()

    def _run_proxy_check_and_assign(self):
        """Check live proxy sạch + gán cho nick sống (1 proxy / 3 nick)."""
        live_proxies = []
        die_proxies = []
        skipped_q = 0
        now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        # Chỉ check proxy CHƯA cách ly
        to_check = [
            p for p in self.proxies
            if not (p.get('quarantine') or p.get('pool') == 'dead')
        ]
        total = len(to_check)
        stopped = False
        live_n = sum(1 for a in self.accounts if not self._is_dead_pool(a))

        if total == 0:
            self.log("⚠ Không còn proxy sạch để check (toàn bộ cách ly).")
            def _empty():
                try:
                    self.refresh_proxy_table()
                    self.refresh_table()
                except Exception:
                    pass
                messagebox.showwarning(
                    "Quét proxy",
                    "Không có proxy sạch để check/gán.\n"
                    "Proxy cách ly nằm ở tab Acc chết — thêm proxy MỚI vào proxies.txt.",
                )
            self._ui_call(_empty)
            return

        self.log(f">>> CHECK {total} proxy sạch (bỏ qua cách ly) | nick sống cần gán: {live_n}")

        for i, p in enumerate(to_check, 1):
            if self._should_stop():
                stopped = True
                self.log(f"⏹ Dừng check proxy lúc {i - 1}/{total}")
                break
            px = p.get('proxy', '')
            if self.current_job:
                self.current_job['current_uid'] = (px or '')[:32]
                self._ui_refresh_jobs()
            self.log(f"[CHECKING {i}/{total}] {px}...")
            ok = self._check_one_proxy(px)
            p['last_check'] = now
            # Giữ cờ quarantine nếu có (không “hồi sinh” proxy chết)
            if p.get('quarantine') or p.get('pool') == 'dead':
                skipped_q += 1
                continue
            if ok:
                p['status'] = 'LIVE'
                live_proxies.append(self._normalize_proxy_key(px) or px)
                self.log("   => [LIVE]")
            else:
                p['status'] = 'DIE'
                die_proxies.append(px)
                self.log("   => [DIE]")

        self.save_proxies()
        self.log(
            f">>> CHECK XONG. LIVE: {len(live_proxies)}/{total} | DIE: {len(die_proxies)} "
            f"| bỏ qua cách ly khi check: {skipped_q}"
        )

        if not stopped and not self._should_stop():
            pool = list(live_proxies)
            # Thiếu LIVE → bổ sung proxy chưa check (vẫn không lấy quarantine)
            if len(pool) * 3 < live_n:
                for p in self.proxies:
                    if p.get('quarantine') or p.get('pool') == 'dead':
                        continue
                    px = self._normalize_proxy_key(p.get('proxy', ''))
                    if px and px not in pool:
                        pool.append(px)

            # reassign full cho nick sống
            assigned, used, unassigned = self._assign_one_proxy_per_three_accounts(
                pool, mode='reassign',
            )
            self.save_accounts()
            self.log(
                f">>> GÁN XONG (1 proxy / 3 nick sống): gán {assigned} nick, "
                f"dùng {used} proxy, còn trống {unassigned}/{live_n}."
            )
            if self.current_job:
                self.current_job['message'] = (
                    f"LIVE={len(live_proxies)} gán={assigned} trống={unassigned}"
                )

            def _done():
                try:
                    self.refresh_table()
                    self.refresh_proxy_table()
                    self.refresh_dead_table()
                except Exception:
                    pass
                msg = (
                    f"Check proxy sạch: LIVE {len(live_proxies)} / {total}\n"
                    f"DIE: {len(die_proxies)}\n"
                    f"Nick sống: {live_n}\n"
                    f"Đã gán: {assigned} nick\n"
                    f"Dùng: {used} proxy (1 proxy / 3 nick)\n"
                    f"Còn trống: {unassigned} nick"
                )
                if unassigned:
                    need = (unassigned + 2) // 3
                    msg += (
                        f"\n\n⚠ Thiếu khoảng {need} proxy LIVE sạch nữa "
                        f"để gán đủ {live_n} nick."
                    )
                if not live_proxies:
                    msg += "\n\n⚠ Không có proxy LIVE — hãy kiểm tra mạng/proxy."
                messagebox.showinfo("Quét + Gán proxy xong", msg)

            self._ui_call(_done)
        else:
            self._ui_call(self.refresh_proxy_table)
            if self.current_job:
                self.current_job['message'] = 'Đã dừng khi check proxy'
            raise JobStopped('Dừng khi quét/check proxy')

    # ------------------------------------------------------------------
    # Facebook LIVE check
    # ------------------------------------------------------------------
    def _start_fb_live_job(self, indices, label='Check live Facebook'):
        if not indices:
            return
        # Chỉ đánh dấu UI tạm — KHÔNG save_accounts ở đây (tránh sót «Đang check...» khi crash)
        for idx in indices:
            if 0 <= idx < len(self.accounts):
                # giữ status cũ trong prev_status để restore nếu dừng giữa chừng
                acc = self.accounts[idx]
                if not self._is_transient_status(acc.get('status')):
                    acc['_status_before_check'] = acc.get('status') or 'Chưa check'
                acc['status'] = 'Đang check...'
        self.refresh_table()

        def runner(job):
            try:
                self._run_fb_live_check(indices, job=job)
            finally:
                # Mọi nick còn «Đang check...» → khôi phục / Chưa check
                self._clear_in_progress_check_statuses(indices)

        self._submit_job(
            'CHECK_LIVE',
            config={'safe_mode': True},
            indices=indices,
            label=label,
            kind='fb_live',
            runner=runner,
        )
        est = max(20, int(len(indices) * 7.5))
        self.log(
            f">>> CHECK LIVE AN TOÀN {len(indices)} nick (từng nick · DIE 2 lần · circuit breaker) "
            f"| dự kiến ~{self._fmt_duration(est)}"
        )

    def _clear_in_progress_check_statuses(self, indices=None):
        """Hết job check: xóa «Đang check...» còn sót, không để lưu vĩnh viễn."""
        if indices is None:
            targets = list(range(len(self.accounts)))
        else:
            targets = list(indices)
        changed = False
        for idx in targets:
            if idx < 0 or idx >= len(self.accounts):
                continue
            acc = self.accounts[idx]
            if not self._is_transient_status(acc.get('status')):
                # dọn key tạm
                acc.pop('_status_before_check', None)
                continue
            prev = acc.pop('_status_before_check', None)
            raw = str(acc.get('live_check_raw') or '').strip()
            if raw and not self._is_transient_status(raw):
                acc['status'] = raw.split()[0] if ' ' in raw else raw
            elif prev and not self._is_transient_status(prev):
                acc['status'] = prev
            else:
                acc['status'] = 'Chưa check (check dở)'
            changed = True
        if changed:
            try:
                self.save_accounts()
            except Exception:
                pass
            try:
                self._ui_call(self.refresh_table)
            except Exception:
                pass

    def check_all_fb_live(self):
        if not self._pre_check(need_accounts=True):
            return
        indices = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label='CHECK_LIVE')
        if not indices:
            return
        ok = messagebox.askyesno(
            "Check Live An Toàn",
            f"Sẽ check {len(indices)} nick theo chế độ AN TOÀN:\n\n"
            "• Từng nick một (không spam song song)\n"
            "• Chỉ qua proxy nick (không lộ IP máy)\n"
            "• DIE phải xác nhận 2 request mới đưa Acc chết\n"
            "• Checkpoint: chỉ nick đó — không cách ly cả farm\n"
            "• Tự DỪNG nếu die/lỗi hàng loạt hoặc rate-limit\n\n"
            "Tiếp tục?",
        )
        if not ok:
            return
        self._start_fb_live_job(indices, label='Check live an toàn (tất cả)')

    def check_selected_fb_live(self):
        if not self._pre_check(need_accounts=True):
            return
        selected = self.tree.selection()
        if not selected:
            messagebox.showwarning("Cảnh báo", "Chọn ít nhất 1 tài khoản!")
            return
        indices = []
        for sel in selected:
            idx = self.tree.item(sel)['values'][0] - 1
            if 0 <= idx < len(self.accounts):
                indices.append(idx)
        indices = self.filter_indices_require_proxy(indices, task_label='CHECK_LIVE')
        if not indices:
            return
        if len(indices) > 5:
            ok = messagebox.askyesno(
                "Check Live An Toàn",
                f"Check {len(indices)} nick đã chọn (chế độ an toàn, từng nick).\nTiếp tục?",
            )
            if not ok:
                return
        self._start_fb_live_job(indices, label='Check live an toàn (chọn)')

    def _ui_refresh_accounts(self):
        try:
            self.refresh_table()
        except Exception:
            pass

    def _ui_set_account_status(self, idx, status):
        """Cập nhật 1 dòng trạng thái trên Treeview (thread-safe)."""
        def _do():
            try:
                if 0 <= idx < len(self.accounts):
                    self.accounts[idx]['status'] = status
                if not hasattr(self, 'tree'):
                    return
                for item_id in self.tree.get_children():
                    vals = list(self.tree.item(item_id)['values'])
                    if not vals:
                        continue
                    try:
                        stt = int(vals[0])
                    except Exception:
                        continue
                    if stt == idx + 1:
                        vals[-1] = status
                        self.tree.item(item_id, values=vals)
                        break
            except Exception:
                pass
        self._ui_call(_do)

    def _apply_live_result(self, idx, status, now, counters, *, safe_mode=True):
        """
        Ghi kết quả 1 nick — CHẾ ĐỘ AN TOÀN mặc định:
        - LIVE: cập nhật trạng thái
        - DIE (đã xác nhận 2 lần): đánh dấu DIE + đưa Acc chết RIÊNG nick (không đụng proxy/nick khác)
        - CHECKPOINT: chỉ đánh dấu nick đó + Acc chết RIÊNG; KHÔNG cách ly cả farm cùng proxy
        - ERROR/UNKNOWN/RATE_LIMIT: KHÔNG đụng pool chết — giữ cookie, ghi trạng thái tạm
        """
        if idx < 0 or idx >= len(self.accounts):
            return
        acc = self.accounts[idx]
        uid = acc.get('uid', '')
        st = str(status or '').upper().strip()
        acc['last_check'] = now
        acc['live_check_raw'] = str(status)[:80]

        if st == 'LIVE':
            counters['live'] += 1
            acc['status'] = 'LIVE'
            acc['login_status'] = 'Cookie LIVE (verify browser/HTTP)'
            # gỡ cờ chết nhầm nếu có
            if acc.get('pool') == 'dead' or acc.get('is_dead'):
                acc['pool'] = ''
                acc['is_dead'] = False
                acc['quarantine'] = False
                acc.pop('dead_reason', None)
                acc['discard_recommend'] = False
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'SUCCESS', 'Tài khoản LIVE', f'https://www.facebook.com/{uid}')
            self._job_mark_account_done(uid, ok=True)
            self._ui_set_account_status(idx, 'LIVE')
            return

        if st == 'CHECKPOINT' or st.startswith('CHECKPOINT'):
            counters['checkpoint'] += 1
            acc['status'] = 'CHECKPOINT'
            acc['login_status'] = 'Checkpoint (check live)'
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'FAIL', 'CHECKPOINT')
            # AN TOÀN: chỉ chết 1 nick — KHÔNG cascade proxy (tránh acc chết hàng loạt khi CP giả)
            try:
                self.move_account_to_dead(
                    acc,
                    reason='CHECKPOINT khi check live (an toàn: không cách ly proxy farm)',
                    quarantine_related=False,
                )
            except Exception:
                pass
            self.log(f"[{uid}] ⚠ CHECKPOINT — chỉ đánh dấu nick này (không cách ly proxy/farm)")
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, 'CHECKPOINT')
            return

        if st == 'DIE' or st == 'DIE_CONFIRMED':
            counters['die'] += 1
            acc['status'] = 'DIE'
            acc['login_status'] = 'Cookie DIE (xác nhận 2 lần)' if safe_mode else 'Cookie DIE'
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'FAIL', 'DIE — cookie chết (confirmed)')
            try:
                self.move_account_to_dead(
                    acc,
                    reason='DIE cookie (xác nhận kép, không cascade proxy)',
                    quarantine_related=False,
                )
            except Exception:
                pass
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, 'DIE')
            return

        if st == 'DIE_SOFT' or st == 'DIE?':
            # Nghi DIE nhưng chưa xác nhận — KHÔNG đưa Acc chết
            counters['suspect'] = counters.get('suspect', 0) + 1
            acc['status'] = 'DIE?'
            acc['login_status'] = 'Nghi cookie chết — cần check lại'
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'WARN', 'Nghi DIE chưa xác nhận — giữ nick')
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, 'DIE?')
            return

        if st == 'NO COOKIE':
            counters['no_cookie'] += 1
            acc['status'] = 'NO COOKIE'
            acc['login_status'] = 'Thiếu cookie'
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'FAIL', 'NO COOKIE')
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, 'NO COOKIE')
            return

        if 'RATE' in st or 'LIMIT' in st:
            counters['error'] += 1
            counters['rate_limit'] = counters.get('rate_limit', 0) + 1
            prev = str(acc.get('status') or '')
            if prev.upper() != 'LIVE':
                acc['status'] = 'RATE_LIMIT'
            acc['login_status'] = f'Rate limit / chặn tạm: {status}'[:120]
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'WARN', str(status)[:120])
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, acc.get('status') or 'RATE_LIMIT')
            return

        if st == 'FB_BLOCK' or 'FB_BLOCK' in st or 'BROWSER' in st:
            # FB chặn kênh check (mbasic/browser) — NICK CHƯA CHẾT
            counters['error'] += 1
            counters['fb_block'] = counters.get('fb_block', 0) + 1
            prev = str(acc.get('status') or '')
            if prev.upper() == 'LIVE':
                acc['login_status'] = 'Check bị FB chặn kênh (giữ LIVE) — đổi proxy/thử lại'
                display = 'LIVE'
            else:
                acc['status'] = 'CHECK_FAIL'
                acc['login_status'] = (
                    'FB chặn check (browser/mbasic/proxy) — KHÔNG phải acc chết. '
                    'Đổi proxy hoặc login browser để xác nhận.'
                )[:160]
                display = 'CHECK_FAIL'
            self.log(f"[{uid}] ⚠ CHECK_FAIL — FB chặn phương thức check, không đưa Acc chết")
            self.add_report(uid, 'CHECK_LIVE', 'cookie', 'WARN', 'FB_BLOCK — không kết luận DIE')
            self._job_mark_account_done(uid, ok=False)
            self._ui_set_account_status(idx, display)
            return

        # ERROR / UNKNOWN / STOPPED — tuyệt đối không move dead
        counters['error'] += 1
        prev = str(acc.get('status') or '')
        display = str(status)[:48] if status else 'ERROR'
        if prev.upper() == 'LIVE':
            acc['login_status'] = f'Check lỗi mạng (giữ LIVE): {display}'[:120]
            self._ui_set_account_status(idx, 'LIVE')
        else:
            # UNKNOWN → nhãn rõ ràng cho user
            if st == 'UNKNOWN' or display.upper() == 'UNKNOWN':
                acc['status'] = 'CHECK_FAIL'
                acc['login_status'] = (
                    'Không xác định LIVE/DIE (FB/proxy/HTML lạ) — KHÔNG phải đã chết. '
                    'Cần check lại hoặc login browser.'
                )[:160]
                display = 'CHECK_FAIL'
            else:
                acc['status'] = display if str(display).startswith('ERROR') else f'Lỗi: {display}'
                acc['login_status'] = str(status)[:120]
            self._ui_set_account_status(idx, acc['status'] if acc.get('status') else display)
        self.add_report(uid, 'CHECK_LIVE', 'cookie', 'WARN', f'Không chắc chắn — {status}')
        self._job_mark_account_done(uid, ok=False)

    def _live_circuit_should_stop(self, counters, done_n, total, die_streak, err_streak):
        """
        Circuit breaker: phát hiện nguy hiểm → DỪNG ngay, không check tiếp.
        Trả (True, reason) nếu phải dừng.
        """
        live = counters.get('live', 0)
        die = counters.get('die', 0)
        cp = counters.get('checkpoint', 0)
        err = counters.get('error', 0)
        rate = counters.get('rate_limit', 0)
        suspect = counters.get('suspect', 0)

        if rate >= 1:
            return True, f'Phát hiện RATE LIMIT / chặn tạm ({rate}) — dừng để tránh ban hàng loạt'
        if die_streak >= 3:
            return True, f'{die_streak} nick DIE liên tiếp — nghi proxy/mạng lỗi hoặc cookie hàng loạt sai'
        if err_streak >= 5:
            return True, f'{err_streak} lỗi mạng liên tiếp — dừng kiểm tra proxy/mạng'
        if done_n >= 5:
            bad = die + cp
            if bad / done_n >= 0.5 and live == 0:
                return True, (
                    f'Tỷ lệ DIE+CP cao ({bad}/{done_n}, LIVE=0) — '
                    f'nghi proxy chết / FB chặn / cookie lô sai. DỪNG để xử lý.'
                )
            if bad / done_n >= 0.6:
                return True, f'Tỷ lệ DIE+CP {bad}/{done_n} ≥ 60% — dừng bảo vệ farm'
        if done_n >= 3 and live == 0 and (die + cp + suspect) >= 3:
            return True, '3+ nick đầu không LIVE nào — dừng, kiểm tra proxy & cookie trước'
        return False, ''

    def _run_fb_live_check(self, indices, job=None):
        """
        Check live AN TOÀN (mặc định):
        - Từng nick tuần tự (1 luồng) + nghỉ ngẫu nhiên giữa nick
        - Chỉ qua proxy (không fallback IP máy — tránh lộ IP / kết quả sai)
        - DIE phải xác nhận 2 request mới ghi chết
        - Circuit breaker: die hàng loạt / rate-limit → DỪNG
        - Không cách ly cả farm khi 1 nick checkpoint
        """
        counters = {
            'live': 0, 'die': 0, 'checkpoint': 0, 'no_cookie': 0,
            'error': 0, 'suspect': 0, 'rate_limit': 0,
        }
        now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        total = len(indices)
        job = job or self.current_job
        # Ước lượng: ~6–9s/nick (request + confirm + delay)
        est = max(15, total * 7.5)
        self._progress_start(
            'CHECK_LIVE',
            (job or {}).get('label') or 'Check live FB (an toàn)',
            indices,
            est_total_sec=est,
        )
        done_n = 0
        die_streak = 0
        err_streak = 0
        t0 = time.time()
        stopped_reason = ''

        try:
            label = (job or {}).get('label') or 'Check live Facebook'
            self.log("=" * 56)
            self.log(f"▶ CHECK LIVE AN TOÀN: «{label}»")
            self.log(f"   {total} nick · 1 luồng · DIE xác nhận kép · tự dừng nếu nguy hiểm")
            self.log("   Không fallback IP máy · Không cách ly proxy farm khi 1 nick CP")
            self.log("=" * 56)
            if job:
                job['message'] = f'Check live an toàn 0/{total}'
                job['current_uid'] = ''
                if not job.get('waiting_uids') and job.get('uids'):
                    job['waiting_uids'] = list(job.get('uids') or [])
            self._set_progress_label(f"▶ {label} — 0/{total} · chế độ an toàn (từng nick)…")
            self._ui_refresh_jobs()

            for n, idx in enumerate(indices, 1):
                if self._should_stop():
                    stopped_reason = 'Người dùng dừng'
                    self.log("⏹ Check Live: người dùng bấm Dừng")
                    raise JobStopped(stopped_reason)

                if idx < 0 or idx >= len(self.accounts):
                    continue
                acc = self.accounts[idx]
                uid = str(acc.get('uid', '') or '')
                cookie = (acc.get('cookie') or '').strip()
                proxy = acc.get('proxy') or ''

                if job:
                    job['current_uid'] = uid
                    job['message'] = f'{n}/{total} đang check {uid}…'
                self._progress['current_uid'] = uid
                self._progress['current_task'] = 'request cookie'
                self._progress_publish(force_log=False)
                self._ui_refresh_jobs()
                self.log(f"[CHECK LIVE] ({n}/{total}) → nick {uid} | proxy={'có' if proxy else 'KHÔNG'}")

                if not cookie:
                    status = 'NO COOKIE'
                else:
                    try:
                        status = self._check_one_fb_account_safe(cookie, proxy, uid)
                    except Exception as ex:
                        status = f'ERROR: {ex}'

                if self._should_stop():
                    raise JobStopped('Người dùng dừng')

                self._apply_live_result(idx, status, now, counters, safe_mode=True)
                done_n += 1
                st_u = str(status).upper()

                # streaks cho circuit breaker
                if st_u in ('DIE', 'DIE_CONFIRMED') or st_u.startswith('CHECKPOINT'):
                    die_streak += 1
                    err_streak = 0
                elif st_u.startswith('ERROR') or 'RATE' in st_u or st_u in ('UNKNOWN', 'DIE_SOFT', 'DIE?'):
                    err_streak += 1
                    if st_u not in ('DIE_SOFT', 'DIE?'):
                        die_streak = 0
                else:
                    die_streak = 0
                    err_streak = 0

                elapsed = max(0.2, time.time() - t0)
                avg = elapsed / max(done_n, 1)
                rem = avg * max(0, total - done_n)
                self._progress['done'] = done_n
                self._progress['current_task'] = status
                self._progress['est_remaining_sec'] = rem
                self._progress['samples'] = [avg]
                pct = int(100 * done_n / total) if total else 0
                if job:
                    w = job.get('waiting_uids') or []
                    if uid in w:
                        w.remove(uid)
                    job['message'] = (
                        f"{done_n}/{total} ({pct}%) LIVE={counters['live']} "
                        f"DIE={counters['die']} CP={counters['checkpoint']} "
                        f"ERR={counters['error']} · {uid}={status}"
                    )
                self.log(
                    f"[CHECK LIVE] {done_n}/{total} ({pct}%) | [{uid}] => {status} | "
                    f"LIVE={counters['live']} DIE={counters['die']} CP={counters['checkpoint']} "
                    f"ERR={counters['error']} | còn ~{self._fmt_duration(rem)}"
                )
                self._progress_publish(force_log=False)
                self._ui_refresh_jobs()

                if done_n % 3 == 0 or done_n == total:
                    try:
                        self.save_accounts()
                    except Exception:
                        pass

                # Circuit breaker
                should_stop, why = self._live_circuit_should_stop(
                    counters, done_n, total, die_streak, err_streak,
                )
                if should_stop:
                    stopped_reason = why
                    self.log("🛑 DỪNG AN TOÀN — " + why)
                    self.log("   → Kiểm tra proxy LIVE, cookie lô, mạng. Không check tiếp để bảo vệ farm.")
                    if job:
                        job['message'] = 'DỪNG AN TOÀN: ' + why[:120]
                        for rest in indices[n:]:
                            if 0 <= rest < len(self.accounts):
                                u = str(self.accounts[rest].get('uid', rest))
                                if u not in (job.get('cancelled_uids') or []):
                                    job.setdefault('cancelled_uids', []).append(u)
                        job['waiting_uids'] = []
                    raise JobStopped(why)

                # Nghỉ giữa 2 nick (giảm rate-limit)
                if n < total and not self._should_stop():
                    pause = random.uniform(1.2, 2.8)
                    self.log(f"  ⏳ Nghỉ {pause:.1f}s trước nick kế (an toàn)…")
                    if not self._interruptible_sleep(pause):
                        raise JobStopped('Dừng trong lúc nghỉ giữa nick')

            summary = (
                f"CHECK LIVE XONG (an toàn): LIVE={counters['live']} | DIE={counters['die']} | "
                f"CP={counters['checkpoint']} | DIE?={counters.get('suspect', 0)} | "
                f"NO COOKIE={counters['no_cookie']} | ERROR={counters['error']} | "
                f"Tổng xong={done_n}/{total} | {self._fmt_duration(time.time() - t0)}"
            )
            self.log(f">>> {summary}")
            if job:
                job['message'] = summary
            try:
                self.save_accounts()
            except Exception as ex:
                self.log(f"Lỗi lưu accounts: {ex}")
            self._progress_finish(stopped=False)

            def _done():
                self._ui_refresh_accounts()
                try:
                    self.refresh_report_table()
                    self.refresh_dead_table()
                except Exception:
                    pass
                if not self._should_stop():
                    messagebox.showinfo("Check Live xong", summary)

            self._ui_call(_done)
        except JobStopped as ex:
            self._progress_finish(stopped=True)
            msg = stopped_reason or str(ex)
            if job:
                job['message'] = msg[:160]
                for rest_idx in indices[done_n:]:
                    if 0 <= rest_idx < len(self.accounts):
                        u = str(self.accounts[rest_idx].get('uid', rest_idx))
                        st = str(self.accounts[rest_idx].get('status') or '')
                        if st.startswith('Đang') or st == 'Đang check...':
                            if u not in (job.get('cancelled_uids') or []):
                                job.setdefault('cancelled_uids', []).append(u)
                job['waiting_uids'] = []
                self._ui_refresh_jobs()
            try:
                self.save_accounts()
            except Exception:
                pass
            # Thông báo nếu dừng do an toàn (không phải user Esc trống)
            if msg and 'Người dùng' not in msg:
                warn = msg

                def _warn():
                    messagebox.showwarning(
                        "Đã dừng bảo vệ farm",
                        f"{warn}\n\n"
                        f"Đã xong: {done_n}/{total}\n"
                        f"LIVE={counters['live']} DIE={counters['die']} "
                        f"CP={counters['checkpoint']} ERR={counters['error']}\n\n"
                        "Hãy kiểm tra proxy + cookie trước khi check tiếp.",
                    )

                self._ui_call(_warn)
            raise
        except Exception as ex:
            self._progress_finish(stopped=True)
            self.log(f"Lỗi check live: {ex}")
            err = str(ex)
            self._ui_call(lambda: messagebox.showerror("Lỗi Check Live", err))
            raise
        finally:
            self._ui_call(self._ui_refresh_accounts)

    def _check_one_fb_account_safe(self, cookie_str, proxy_str, uid):
        """
        Check 1 nick — chính xác:
        1) HTTP nhanh (www) nếu ra LIVE/DIE/CP rõ
        2) Nếu FB chặn HTTP / UNKNOWN → verify bằng Chromium + cookie + proxy (thật)
        3) Chỉ DIE khi browser thấy form login; CP khi URL/UI checkpoint thật
        """
        if not (cookie_str or '').strip():
            return 'NO COOKIE'

        proxy = (proxy_str or '').strip()
        if not proxy:
            self.log(f"[{uid}] ⚠ Check không có proxy — kết quả có thể kém tin cậy")

        http_result = None
        try:
            http_result = self._fb_live_request(
                cookie_str, proxy, uid, timeout=10, max_urls=2, safe_classify=True,
            )
        except Exception as e:
            http_result = f'ERROR: {e}'

        if http_result == 'STOPPED' or self._should_stop():
            return 'STOPPED'
        if http_result == 'LIVE':
            return 'LIVE'

        # DIE/CP từ HTTP → xác nhận browser (HTTP hay sai)
        need_browser = True
        if http_result == 'DIE':
            self.log(f"[{uid}] HTTP nghi DIE → xác nhận bằng browser…")
        elif http_result and 'CHECKPOINT' in str(http_result).upper():
            self.log(f"[{uid}] HTTP nghi CHECKPOINT → xác nhận bằng browser…")
        elif http_result in ('FB_BLOCK', 'UNKNOWN', 'RATE_LIMIT') or str(http_result).startswith('ERROR'):
            self.log(f"[{uid}] HTTP={http_result} → verify browser (chính xác)…")
        else:
            self.log(f"[{uid}] HTTP={http_result} → verify browser…")

        if need_browser:
            if self._should_stop():
                return 'STOPPED'
            br = self._browser_verify_cookie_live(cookie_str, proxy, uid)
            if br == 'STOPPED' or self._should_stop():
                return 'STOPPED'
            if br in ('LIVE', 'DIE', 'CHECKPOINT'):
                return br
            if br == 'DIE_SOFT':
                return 'DIE_SOFT'
            # browser cũng không chắc
            if http_result == 'DIE':
                return 'DIE_SOFT'
            return br or http_result or 'UNKNOWN'

        return str(http_result)

    def _browser_verify_cookie_live(self, cookie_str, proxy_str, uid):
        """
        Mở Chromium headless + cookie + proxy → kết luận LIVE/DIE/CHECKPOINT thật.
        Không đọc full HTML JS (tránh false CP vì chữ 'checkpoint' trong bundle).
        """
        try:
            from playwright.sync_api import sync_playwright
        except Exception as e:
            self.log(f"[{uid}] Không có Playwright: {e}")
            return f'ERROR: no playwright ({e})'

        c_user = str(uid or '')
        m = re.search(r'c_user=(\d+)', cookie_str or '')
        if m:
            c_user = m.group(1)

        proxy_cfg = self._build_playwright_proxy(proxy_str) if proxy_str else None
        cookies = []
        try:
            cookies = self._cookie_header_to_list(cookie_str)
        except Exception:
            cookies = []

        def _classify(page):
            url = page.url or ''
            url_l = url.lower()
            title = ''
            try:
                title = page.title() or ''
            except Exception:
                pass

            # CP theo URL thật
            if any(x in url_l for x in (
                '/checkpoint', 'checkpoint/', 'two_step', 'security_checkup',
                'confirmemail', '/disabled', 'account_status',
            )):
                return 'CHECKPOINT'

            try:
                cks = page.context.cookies()
                has_c_user = any(
                    c.get('name') == 'c_user' and str(c.get('value')) == str(c_user)
                    for c in cks
                )
                has_xs = any(c.get('name') == 'xs' for c in cks)
            except Exception:
                has_c_user = False
                has_xs = False

            try:
                has_login = (
                    page.locator('input[name="email"], input#email').count() > 0
                    and page.locator('input[name="pass"], input#pass').count() > 0
                )
            except Exception:
                has_login = False

            # CP UI text (body visible, không full source)
            try:
                body_text = (page.locator('body').inner_text(timeout=4000) or '')[:3500].lower()
            except Exception:
                body_text = ''
            if any(s in body_text for s in (
                'confirm your identity', 'we suspended your account',
                'your account has been disabled', 'upload a photo of your id',
                'xác nhận danh tính', 'tài khoản của bạn đã bị vô hiệu',
            )):
                return 'CHECKPOINT'

            if has_login and (not has_c_user or any(x in url_l for x in ('login', 'identify', 'recover'))):
                return 'DIE'

            live_dom = False
            for sel in (
                'div[role="feed"]', 'div[role="navigation"]',
                '[aria-label="Account"]', '[aria-label="Your profile"]',
            ):
                try:
                    if page.locator(sel).count() > 0:
                        live_dom = True
                        break
                except Exception:
                    pass

            notif_title = bool(re.match(r'^\(\d+\)\s*facebook', (title or '').strip().lower()))
            if has_c_user and has_xs and not has_login:
                return 'LIVE'
            if has_c_user and (live_dom or notif_title) and not has_login:
                return 'LIVE'
            if has_login:
                return 'DIE'
            return 'UNKNOWN'

        browser = None
        try:
            with sync_playwright() as p:
                browser = p.chromium.launch(
                    headless=True,
                    args=['--disable-blink-features=AutomationControlled', '--no-sandbox'],
                )
                ctx_kw = {
                    'user_agent': (
                        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                        'AppleWebKit/537.36 (KHTML, like Gecko) '
                        'Chrome/121.0.0.0 Safari/537.36'
                    ),
                    'viewport': {'width': 1280, 'height': 800},
                    'locale': 'en-US',
                }
                if proxy_cfg:
                    ctx_kw['proxy'] = proxy_cfg
                context = browser.new_context(**ctx_kw)
                if cookies:
                    try:
                        context.add_cookies(cookies)
                    except Exception as ex:
                        self.log(f"[{uid}] add_cookies: {ex}")
                page = context.new_page()
                if self._should_stop():
                    return 'STOPPED'
                page.goto('https://www.facebook.com/', timeout=55000, wait_until='domcontentloaded')
                time.sleep(3.0)
                if self._should_stop():
                    return 'STOPPED'
                st = _classify(page)
                if st in ('UNKNOWN',):
                    try:
                        page.goto('https://www.facebook.com/me', timeout=40000, wait_until='domcontentloaded')
                        time.sleep(2.5)
                        st = _classify(page)
                    except Exception:
                        pass
                # refresh cookie nếu LIVE
                if st == 'LIVE':
                    try:
                        seen = {}
                        for c in context.cookies():
                            if 'facebook.com' in (c.get('domain') or ''):
                                seen[c['name']] = c['value']
                        new_ck = ';'.join(f'{k}={v}' for k, v in seen.items())
                        if new_ck and 'c_user=' in new_ck:
                            # cập nhật cookie trên acc nếu tìm thấy
                            for acc in self.accounts:
                                if str(acc.get('uid')) == str(uid):
                                    acc['cookie'] = new_ck
                                    break
                    except Exception:
                        pass
                self.log(f"[{uid}] Browser verify → {st} | url={(page.url or '')[:80]}")
                return st
        except Exception as e:
            self.log(f"[{uid}] Browser verify lỗi: {e}")
            return f'ERROR: browser {e}'
        finally:
            try:
                if browser:
                    browser.close()
            except Exception:
                pass

    def _check_one_fb_account(self, cookie_str, proxy_str, uid):
        """Alias — luôn đi chế độ an toàn."""
        return self._check_one_fb_account_safe(cookie_str, proxy_str, uid)

    def _normalize_cookie_header(self, cookie_str):
        """Chuẩn hóa cookie header: ; + space, bỏ presence (dễ lỗi)."""
        raw = (cookie_str or '').replace('\n', ' ').replace('\r', ' ').strip()
        if not raw:
            return ''
        pairs = []
        for part in raw.split(';'):
            part = part.strip()
            if not part or '=' not in part:
                continue
            name = part.split('=', 1)[0].strip().lower()
            if name == 'presence':
                continue
            pairs.append(part)
        return '; '.join(pairs)

    def _fb_live_request(self, cookie_str, proxy_str, uid, timeout=12, max_urls=1, safe_classify=True):
        """
        Check cookie 1 lượt.
        Ưu tiên www.facebook.com (mbasic hay bị chặn: "not available on this browser").
        - LIVE: redirect profile.php?id={c_user} / USER_ID trong HTML
        - DIE: login form / login URL rõ
        - CHECKPOINT: checkpoint URL/body
        - FB_BLOCK / RATE_LIMIT / ERROR: KHÔNG phải acc chết
        """
        import ssl
        import urllib.parse

        cookie_hdr = self._normalize_cookie_header(cookie_str)
        c_user = str(uid or '')
        m = re.search(r'c_user=(\d+)', cookie_hdr)
        if m:
            c_user = m.group(1)

        # Desktop Chrome — mbasic mobile UA hiện hay bị FB trả trang Error Lite
        headers = {
            'User-Agent': (
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                'AppleWebKit/537.36 (KHTML, like Gecko) '
                'Chrome/121.0.0.0 Safari/537.36'
            ),
            'Cookie': cookie_hdr,
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
            'Accept-Language': 'en-US,en;q=0.9,vi;q=0.8',
            'Cache-Control': 'no-cache',
            'Upgrade-Insecure-Requests': '1',
            'Referer': 'https://www.facebook.com/',
        }

        handlers = []
        if proxy_str:
            proxy_url = self._build_proxy_url(proxy_str)
            # encode user/pass nếu có ký tự đặc biệt
            try:
                if '://' in proxy_url and '@' in proxy_url:
                    scheme, rest = proxy_url.split('://', 1)
                    cred, host = rest.rsplit('@', 1)
                    if ':' in cred:
                        u, p = cred.split(':', 1)
                        proxy_url = f'{scheme}://{urllib.parse.quote(u, safe="")}:{urllib.parse.quote(p, safe="")}@{host}'
            except Exception:
                pass
            handlers.append(urllib.request.ProxyHandler({'http': proxy_url, 'https': proxy_url}))
        else:
            handlers.append(urllib.request.ProxyHandler({}))

        ctx = ssl.create_default_context()
        handlers.append(urllib.request.HTTPSHandler(context=ctx))
        opener = urllib.request.build_opener(*handlers)

        # Ưu tiên www — mbasic thường trả "not available on this browser"
        urls = [
            f'https://www.facebook.com/profile.php?id={c_user}' if c_user else None,
            'https://www.facebook.com/me',
            'https://www.facebook.com/',
            'https://mbasic.facebook.com/me',
        ]
        urls = [u for u in urls if u]
        # max_urls=1 → chỉ URL đầu; =2 → 2 URL đầu, ...
        urls = urls[: max(1, int(max_urls or 1))]

        last_err = None
        saw_fb_block = False
        for url in urls:
            if self._should_stop():
                return 'STOPPED'
            try:
                req = urllib.request.Request(url, headers=headers, method='GET')
                resp = opener.open(req, timeout=timeout)
                final_url = (resp.geturl() or '')
                final_l = final_url.lower()
                body = resp.read(120_000).decode('utf-8', errors='ignore')
                body_l = body.lower()
                head = body_l[:8000]

                # FB chặn phương thức check / browser (KHÔNG phải DIE)
                if any(x in head for x in (
                    'not available on this browser',
                    'not available on this device',
                    'get the facebook lite app',
                    'get one of the browsers below',
                )):
                    saw_fb_block = True
                    self.log(f"[{uid}] ⚠ FB chặn kênh check (browser/mbasic) — không kết luận DIE")
                    continue

                if any(x in head or x in final_l for x in (
                    'rate limit', 'too many requests', 'try again later',
                    'temporarily blocked', 'unusual traffic',
                )):
                    return 'RATE_LIMIT'

                # Checkpoint
                if 'checkpoint' in final_l or 'checkpoint' in head:
                    return 'CHECKPOINT'
                if any(s in head for s in (
                    'confirm your identity', 'xác nhận danh tính',
                    'upload a photo of your id', 'we suspended',
                    'account has been disabled', 'bảo mật tài khoản',
                )):
                    return 'CHECKPOINT'

                # LIVE — redirect về profile của chính nick
                if c_user and (
                    f'profile.php?id={c_user}' in final_l
                    or final_l.rstrip('/').endswith('/' + c_user)
                    or f'facebook.com/{c_user}' in final_l
                ):
                    return 'LIVE'
                if c_user and (
                    f'"USER_ID":"{c_user}"' in body
                    or f'"ACCOUNT_ID":"{c_user}"' in body
                    or f'"actorID":"{c_user}"' in body
                    or f'"userID":"{c_user}"' in body
                ):
                    return 'LIVE'
                # mbasic live signals (nếu còn)
                if any(x in body_l for x in (
                    'mbasic_logout_button', 'logout.php', '/logout',
                    'composer/', 'home_icon', 'news_feed',
                )):
                    return 'LIVE'
                # HTML SPA lớn + có c_user id trong body (logged-in shell)
                if c_user and c_user in body and len(body) > 40000 and 'name="pass"' not in body_l:
                    if 'login' not in final_l:
                        return 'LIVE'

                # DIE rõ
                hard_login = any(x in final_l for x in ('/login', 'login.php', 'identify', 'recover'))
                has_login_form = (
                    ('name="email"' in body_l or 'id="email"' in body_l)
                    and ('name="pass"' in body_l or 'id="pass"' in body_l)
                )
                if hard_login and has_login_form:
                    return 'DIE'
                if has_login_form and 'login' in final_l:
                    return 'DIE'
                if 'log in to facebook' in head and has_login_form:
                    return 'DIE'
                if hard_login and 'checkpoint' not in final_l:
                    if safe_classify:
                        return 'UNKNOWN' if not has_login_form else 'DIE'
                    return 'DIE'

                # title Error chung của FB (hay đi kèm block)
                if re.search(r'<title[^>]*>\s*error\s*</title>', body_l):
                    saw_fb_block = True
                    continue

                if safe_classify:
                    return 'UNKNOWN'
                return 'UNKNOWN'
            except urllib.error.HTTPError as e:
                last_err = e
                code = getattr(e, 'code', 0) or 0
                if code == 429:
                    return 'RATE_LIMIT'
                if code in (400, 401, 403):
                    # Proxy/FB chặn request — KHÔNG DIE
                    try:
                        eb = e.read(2000).decode('utf-8', errors='ignore').lower()
                    except Exception:
                        eb = ''
                    if 'not available' in eb or 'facebook lite' in eb or '<title>error</title>' in eb:
                        saw_fb_block = True
                        continue
                    if safe_classify:
                        return f'ERROR: HTTP {code} (FB/proxy chặn check — không phải DIE)'
                    continue
                if code >= 500:
                    return f'ERROR: HTTP {code}'
                continue
            except Exception as e:
                last_err = e
                continue

        if saw_fb_block:
            return 'FB_BLOCK'
        if last_err:
            return f'ERROR: {last_err}'
        return 'UNKNOWN'

    # ------------------------------------------------------------------
    # Logging / automation
    # ------------------------------------------------------------------
    def _on_log_mousewheel(self, event):
        """Cuộn log bằng bánh xe chuột."""
        try:
            delta = int(-1 * (event.delta / 120)) if event.delta else 0
            if delta == 0:
                # macOS often sends small deltas
                delta = -1 if event.delta > 0 else 1
            self.log_box.yview_scroll(delta, 'units')
        except Exception:
            pass
        return 'break'

    def scroll_to_processing_area(self, expand_dock=True):
        """
        Đưa khung Job Queue + Nhật ký vào vùng nhìn thấy.
        Gọi sau khi bấm lệnh / submit job.
        """
        def _do():
            try:
                self.root.update_idletasks()
                if expand_dock and hasattr(self, 'main_pane') and hasattr(self, 'dock_frame'):
                    h = max(int(self.root.winfo_height() or 900), 700)
                    # Khi chạy lệnh: mở dock ~320px; vẫn chừa chỗ tab
                    target = max(300, h - 320)
                    try:
                        self.main_pane.sashpos(0, target)
                    except Exception:
                        pass
                if hasattr(self, 'log_box'):
                    self.log_box.see('end')
                if hasattr(self, 'dock_frame'):
                    try:
                        self.dock_frame.focus_set()
                    except Exception:
                        pass
            except Exception:
                pass
        try:
            self._safe_after(30, _do)
            self._safe_after(120, _do)  # lần 2 sau khi layout ổn
        except Exception:
            pass

    def log(self, message):
        """Thread-safe: worker đẩy queue, main thread ghi Text."""
        try:
            if threading.current_thread() is threading.main_thread():
                self._log_write(message)
            else:
                self._ui_q.put(('log', message))
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Tiến độ realtime (log + nhãn ETA)
    # ------------------------------------------------------------------
    def _fmt_duration(self, sec):
        try:
            sec = max(0, int(sec))
        except Exception:
            return '—'
        if sec < 60:
            return f'{sec}s'
        m, s = divmod(sec, 60)
        if m < 60:
            return f'{m}p{s:02d}s' if s else f'{m}p'
        h, m = divmod(m, 60)
        return f'{h}g{m:02d}p'

    def _estimate_task_seconds(self, task_id, phase='daily', shift='full'):
        """Ước lượng thời gian 1 task kịch bản (giây, lấy mid range delay)."""
        tid = str(task_id or '')
        # mid of configured ranges
        scroll_long = sum(SAFE_SCROLL_SEC) / 2
        scroll_short = sum(SAFE_SCROLL_SHORT) / 2
        act = sum(SAFE_ACTION_DELAY) / 2
        join_d = sum(SAFE_JOIN_DELAY) / 2
        cmt_d = sum(SAFE_COMMENT_DELAY) / 2
        if tid == 'scroll':
            if phase == 'warmup_silent':
                return scroll_long + 20
            if phase == 'daily' and shift == 'morning':
                return 125 + 15
            return scroll_short + 15
        if tid == 'like':
            return 3 * (act * 0.4) + 40
        if tid == 'read_noti':
            return 35
        if tid in ('friend_suggest', 'friend_accept'):
            return 70 + act
        if tid in ('post_wall', 'post_wall_safe'):
            return 80 + act
        if tid == 'join':
            return 2 * (join_d * 0.6 + 45)
        if tid == 'comment_friend':
            return 50 + act
        if tid == 'group_browse_like':
            return 55 + act
        if tid == 'comment_group':
            # 3–5 cmt × delay 3–5 phút
            return 4 * (cmt_d * 0.85 + 25)
        return 60

    def _estimate_nick_scenario_seconds(self, acc, force_shift='auto'):
        """Ước lượng 1 nick kịch bản (login + tasks còn lại + buffer)."""
        phase, _day = self._scenario_phase(acc)
        if phase == 'daily':
            if force_shift in ('morning', 'noon', 'evening', 'full'):
                shift = force_shift
            else:
                shift = self._current_shift_name()
                if shift == 'full':
                    shift = 'morning'
        else:
            shift = 'full'
        tasks = self._scenario_task_list(phase, shift if phase == 'daily' else 'full')
        uid = str(acc.get('uid', ''))
        day_rec, shift_rec, _ = self._today_progress(uid, phase, shift if phase == 'daily' else 'full')
        pending = []
        for t in tasks:
            prev = (shift_rec.get('tasks') or {}).get(t)
            if prev not in ('done', 'success', 'skip'):
                pending.append(t)
        if not pending:
            pending = list(tasks)  # chạy lại / recheck
        sec = 50  # login + mở browser
        for t in pending:
            sec += self._estimate_task_seconds(t, phase, shift)
        sec += 14  # nghỉ giữa nick
        return max(60, sec), phase, shift, pending

    def _progress_start(self, task_type, label, indices, est_total_sec=None):
        total = len(indices or [])
        est = float(est_total_sec or 0)
        if est <= 0 and task_type == 'SCENARIO':
            force = 'auto'
            # est from config later
            est = total * 180  # fallback
        self._progress = {
            'active': True,
            'task_type': task_type or '',
            'label': label or task_type or 'Job',
            'total': total,
            'done': 0,
            'started': time.time(),
            'nick_started': 0.0,
            'current_uid': '',
            'current_task': '',
            'task_i': 0,
            'task_n': 0,
            'est_total_sec': est,
            'est_remaining_sec': est,
            'samples': [],
            'indices': list(indices or []),
        }
        self._progress_publish(force_log=True, prefix='▶ BẮT ĐẦU')

    def _progress_nick_start(self, pos, uid, extra=''):
        p = self._progress
        if not p.get('active'):
            return
        p['current_uid'] = str(uid or '')
        p['nick_started'] = time.time()
        p['task_i'] = 0
        p['task_n'] = 0
        p['current_task'] = 'Đăng nhập / mở browser'
        # remaining estimate: avg sample or est
        done = int(p.get('done') or 0)
        total = max(1, int(p.get('total') or 1))
        left = max(0, total - done)
        samples = p.get('samples') or []
        if samples:
            avg = sum(samples) / len(samples)
            p['est_remaining_sec'] = avg * left
        else:
            per = (p.get('est_total_sec') or 0) / max(total, 1)
            p['est_remaining_sec'] = per * left
        self._progress_publish(
            force_log=True,
            prefix='▶ NICK',
            detail=f"({done + 1}/{total}) UID {uid}" + (f" | {extra}" if extra else ''),
        )

    def _progress_task(self, task_i, task_n, task_label):
        p = self._progress
        if not p.get('active'):
            return
        p['task_i'] = int(task_i or 0)
        p['task_n'] = int(task_n or 0)
        p['current_task'] = task_label or ''
        self._progress_publish(force_log=True, prefix='  → TASK')

    def _progress_nick_done(self, uid, ok=True):
        p = self._progress
        if not p.get('active'):
            return
        p['done'] = int(p.get('done') or 0) + 1
        if p.get('nick_started'):
            elapsed = max(1.0, time.time() - float(p['nick_started']))
            p.setdefault('samples', []).append(elapsed)
            # giữ 20 mẫu gần nhất
            if len(p['samples']) > 20:
                p['samples'] = p['samples'][-20:]
        done = int(p['done'])
        total = max(1, int(p.get('total') or 1))
        left = max(0, total - done)
        samples = p.get('samples') or []
        if samples:
            avg = sum(samples) / len(samples)
            p['est_remaining_sec'] = avg * left
        self._progress_publish(
            force_log=True,
            prefix='✓ NICK XONG' if ok else '✗ NICK LỖI/BỎ',
            detail=f"UID {uid} | xong {done}/{total}",
        )
        p['current_task'] = ''
        p['task_i'] = 0
        p['task_n'] = 0

    def _progress_finish(self, stopped=False):
        p = self._progress
        if not p.get('active') and not p.get('total'):
            self._set_progress_label('○ Hệ thống rảnh — chưa có tiến trình')
            return
        elapsed = time.time() - float(p.get('started') or time.time())
        done = int(p.get('done') or 0)
        total = int(p.get('total') or 0)
        msg = (
            f"{'⏹ ĐÃ DỪNG' if stopped else '✅ HOÀN TẤT'} {p.get('label') or p.get('task_type')} — "
            f"{done}/{total} nick · thời gian {self._fmt_duration(elapsed)}"
        )
        self.log(f"[TIẾN ĐỘ] {msg}")
        self._set_progress_label(msg)
        p['active'] = False
        p['current_uid'] = ''
        p['current_task'] = ''

    def _set_progress_label(self, text):
        try:
            if threading.current_thread() is threading.main_thread():
                self._progress_label_write(text)
            else:
                self._ui_q.put(('progress_label', text))
        except Exception:
            pass

    def _progress_publish(self, force_log=False, prefix='', detail=''):
        p = self._progress or {}
        if not p.get('active') and not force_log:
            return
        total = max(1, int(p.get('total') or 1))
        done = int(p.get('done') or 0)
        # nick hiện tại tính là done+1 khi đang chạy
        cur = min(total, done + (1 if p.get('current_uid') else 0))
        pct = int(100 * done / total) if total else 0
        elapsed = time.time() - float(p.get('started') or time.time())
        rem = float(p.get('est_remaining_sec') or 0)
        samples = p.get('samples') or []
        if samples and p.get('current_uid'):
            avg = sum(samples) / len(samples)
            rem = avg * max(0, total - done - 0.15)  # còn gần xong nick hiện tại
        eta_clock = ''
        if rem > 0:
            try:
                eta_ts = datetime.now() + timedelta(seconds=int(rem))
                eta_clock = eta_ts.strftime('%H:%M')
            except Exception:
                eta_clock = ''
        bar_n = 12
        filled = int(bar_n * done / total) if total else 0
        bar = '█' * filled + '░' * (bar_n - filled)
        task_part = ''
        if p.get('task_n'):
            task_part = f" | task {int(p.get('task_i') or 0)}/{int(p.get('task_n'))}: {p.get('current_task') or '…'}"
        elif p.get('current_task'):
            task_part = f" | {p.get('current_task')}"
        uid_part = f" | UID {p.get('current_uid')}" if p.get('current_uid') else ''
        line = (
            f"[{bar}] {done}/{total} nick ({pct}%) · {p.get('label') or p.get('task_type')}"
            f"{uid_part}{task_part} · đã chạy {self._fmt_duration(elapsed)}"
            f" · còn ~{self._fmt_duration(rem)}"
            + (f" · xong ~{eta_clock}" if eta_clock else '')
        )
        self._set_progress_label('🔄 ' + line)
        if force_log:
            extra = f" {detail}" if detail else ''
            head = prefix or 'TIẾN ĐỘ'
            self.log(f"[TIẾN ĐỘ] {head}{extra} | {done}/{total} ({pct}%) | đã {self._fmt_duration(elapsed)} | còn ~{self._fmt_duration(rem)}"
                     + (f" | ETA {eta_clock}" if eta_clock else ''))

    def _pre_check(self, need_accounts=True, block_if_running=False):
        """
        Kiểm tra trước khi submit job.
        - block_if_running=True: chặn nếu đang bận (tác vụ cũ không xếp hàng).
        - Mặc định cho phép xếp hàng chờ qua _submit_job.
        - Kiểm tra gói cước còn hạn.
        """
        if not self._require_license('chạy tính năng'):
            return False
        if block_if_running and self.is_running:
            messagebox.showwarning(
                "Cảnh báo",
                "Hệ thống đang bận!\n\nXem panel 'Lệnh đang chạy / đang chờ' hoặc bấm DỪNG TOÀN BỘ.",
            )
            return False
        if need_accounts and not self.accounts:
            messagebox.showerror("Lỗi", "Vui lòng thêm tài khoản Facebook (tab Nick FB)!")
            return False
        return True

    def _build_app_account_tab(self, tab):
        """Tab quản lý tài khoản phần mềm: profile, gói, theme."""
        ttk.Label(tab, text="Tài khoản phần mềm · Gói cước · Giao diện", style='Title.TLabel').pack(anchor='w', padx=16, pady=(14, 4))
        ttk.Label(
            tab,
            text=f"Hotline hỗ trợ 24/7: {HOTLINE}  ·  Dùng thử mặc định {DEFAULT_TRIAL_DAYS} ngày khi đăng ký",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=16, pady=(0, 10))

        # Profile card
        prof = ttk.LabelFrame(tab, text="  Thông tin tài khoản  ")
        prof.pack(fill='x', padx=14, pady=6)
        grid = ttk.Frame(prof)
        grid.pack(fill='x', padx=12, pady=10)

        view = self.auth.public_user_view() if self.auth else {}
        self.var_prof_name = tk.StringVar(value=view.get('full_name') or '')
        self.var_prof_email = tk.StringVar(value=view.get('email') or '')
        self.var_prof_phone = tk.StringVar(value=view.get('phone') or '')
        self.var_prof_user = tk.StringVar(value=view.get('username') or '')

        def row(r, label, var, readonly=False):
            ttk.Label(grid, text=label).grid(row=r, column=0, sticky='w', pady=4, padx=(0, 8))
            e = self._mk_entry(grid, textvariable=var, width=40)
            e.grid(row=r, column=1, sticky='w', pady=4)
            if readonly:
                # tk.Entry: disabled = chỉ xem (chữ vẫn đậm qua disabledforeground)
                e.configure(state='disabled')
            return e

        row(0, "Tên đăng nhập", self.var_prof_user, readonly=True)
        row(1, "Họ tên", self.var_prof_name)
        row(2, "Email", self.var_prof_email)
        row(3, "Số điện thoại", self.var_prof_phone)

        prow = ttk.Frame(prof)
        prow.pack(fill='x', padx=12, pady=(0, 10))
        ttk.Button(prow, text="💾 Lưu thông tin", style='Accent.TButton', command=self._save_profile_ui).pack(side='left', padx=4)
        ttk.Button(prow, text="🔑 Đổi mật khẩu", command=self._change_password_dialog).pack(side='left', padx=4)
        ttk.Button(prow, text="Đăng xuất", style='Danger.TButton', command=self.logout_user).pack(side='left', padx=4)

        # License card
        lic = ttk.LabelFrame(tab, text="  Gói cước & thời hạn  ")
        lic.pack(fill='x', padx=14, pady=8)
        self.lbl_license = ttk.Label(lic, text="", style='Status.TLabel')
        self.lbl_license.pack(anchor='w', padx=12, pady=(10, 4))
        self._refresh_license_label()

        plans_personal = ttk.Frame(lic)
        plans_personal.pack(fill='x', padx=12, pady=(4, 2))
        ttk.Label(plans_personal, text="Cá nhân (1 máy):", style='Accent.TLabel').pack(anchor='w')
        prow1 = ttk.Frame(lic)
        prow1.pack(fill='x', padx=12, pady=4)
        plans_biz = ttk.Frame(lic)
        plans_biz.pack(fill='x', padx=12, pady=(8, 2))
        ttk.Label(plans_biz, text="Business ×5 (không giới hạn máy):", style='Warning.TLabel').pack(anchor='w')
        prow2 = ttk.Frame(lic)
        prow2.pack(fill='x', padx=12, pady=4)

        for key, meta in (PLANS or {}).items():
            price = meta.get('price_vnd', 0)
            price_s = "Miễn phí" if not price else f"{price:,}đ".replace(',', '.')
            days = meta.get('days', 0)
            label = f"{meta.get('label')} · {days}d · {price_s}"
            parent = prow2 if meta.get('tier') == 'business' else prow1
            ttk.Button(
                parent,
                text=label,
                command=lambda k=key: self._activate_plan_ui(k),
            ).pack(side='left', padx=3, pady=3)

        ttk.Label(
            lic,
            text="Gia hạn: Admin web · Thanh toán Zalo 0379376177 · Nick/proxy đồng bộ cloud theo tài khoản (ẩn server URL).",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=(0, 4))

        srv = ttk.Frame(lic)
        srv.pack(fill='x', padx=12, pady=(0, 10))
        ttk.Button(srv, text="☁ Tải cloud → máy (chỉ thêm mới)", command=self._manual_pull_merge).pack(side='left', padx=4)
        ttk.Button(srv, text="☁ Sao lưu máy → cloud", command=self._manual_push_cloud).pack(side='left', padx=4)
        ttk.Button(srv, text="Heartbeat", command=self._manual_heartbeat).pack(side='left', padx=4)

        # Theme card
        th = ttk.LabelFrame(tab, text="  Giao diện  ")
        th.pack(fill='x', padx=14, pady=8)
        trow = ttk.Frame(th)
        trow.pack(fill='x', padx=12, pady=12)
        ttk.Label(trow, text="Chọn theme:").pack(side='left')
        ttk.Radiobutton(trow, text="☀ Sáng (mặc định)", variable=self.var_theme, value='light',
                        command=lambda: self.apply_theme('light')).pack(side='left', padx=10)
        ttk.Radiobutton(trow, text="🌙 Tối (dark)", variable=self.var_theme, value='dark',
                        command=lambda: self.apply_theme('dark')).pack(side='left', padx=10)
        ttk.Button(trow, text="Áp dụng ngay", style='Accent.TButton',
                   command=lambda: self.apply_theme(self.var_theme.get())).pack(side='left', padx=12)

        ttk.Label(
            tab,
            text="Website giới thiệu: mở file website/index.html trong trình duyệt.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=16, pady=12)

    def _refresh_license_label(self):
        if not hasattr(self, 'lbl_license'):
            return
        if not self.auth or not self.auth.current_user:
            self.lbl_license.config(text="Chưa đăng nhập")
            return
        st = self.auth.license_status()
        icon = "✓" if st.get('active') else "✗"
        self.lbl_license.config(
            text=f"{icon} {st.get('message')}  |  Hotline: {HOTLINE}"
        )
        self._refresh_user_header()

    def _save_profile_ui(self):
        if not self.auth:
            return
        ok, msg = self.auth.update_profile(
            full_name=self.var_prof_name.get(),
            email=self.var_prof_email.get(),
            phone=self.var_prof_phone.get(),
        )
        if ok:
            messagebox.showinfo("Tài khoản", msg)
            self._refresh_user_header()
        else:
            messagebox.showerror("Lỗi", msg)

    def _change_password_dialog(self):
        if not self.auth or not self.auth.current_user:
            return
        dlg = tk.Toplevel(self.root)
        dlg.title("Đổi mật khẩu")
        dlg.geometry("360x260")
        dlg.configure(bg=UI['bg'])
        dlg.transient(self.root)
        dlg.grab_set()
        tk.Label(dlg, text="Đổi mật khẩu", font=UI['font_ui_bold'], bg=UI['bg'], fg=UI['text']).pack(pady=12)

        def field(label):
            tk.Label(dlg, text=label, bg=UI['bg'], fg=UI['muted']).pack(anchor='w', padx=24)
            e = self._mk_entry(dlg, show='•')
            e.pack(fill='x', padx=24, pady=4, ipady=5)
            return e

        old = field("Mật khẩu hiện tại")
        new = field("Mật khẩu mới")
        new2 = field("Nhập lại mật khẩu mới")

        def go():
            if new.get() != new2.get():
                messagebox.showerror("Lỗi", "Mật khẩu mới không khớp.", parent=dlg)
                return
            ok, msg = self.auth.change_password(old.get(), new.get())
            if ok:
                messagebox.showinfo("OK", msg, parent=dlg)
                dlg.destroy()
            else:
                messagebox.showerror("Lỗi", msg, parent=dlg)

        tk.Button(dlg, text="Xác nhận", command=go, bg=UI['accent'], fg='#fff', relief='flat',
                  font=UI['font_ui_bold'], pady=6).pack(fill='x', padx=24, pady=14)

    def _activate_plan_ui(self, plan_key):
        meta = (PLANS or {}).get(plan_key) or {}
        url = (self.auth.server_url if self.auth else 'http://127.0.0.1:5055') + '/admin'
        messagebox.showinfo(
            "Gia hạn online",
            f"Gói: {meta.get('label')} — {meta.get('price_vnd', 0):,}đ\n\n"
            f"Chỉ admin web được kích hoạt thuê bao (chống crack offline).\n"
            f"Mở: {url}\n"
            f"Thanh toán / hỗ trợ Zalo: {HOTLINE}",
        )
        self._open_admin_web()

    def _open_admin_web(self):
        import webbrowser
        try:
            from auth_system_v10 import DEFAULT_SERVER
            base = DEFAULT_SERVER
        except Exception:
            base = self.auth.server_url if self.auth else 'https://aurexislab.com/license-api'
        webbrowser.open(base.rstrip('/') + '/admin')

    def _manual_heartbeat(self):
        if not self.auth or not self.auth.token:
            messagebox.showwarning("Online", "Chưa đăng nhập.")
            return
        ok, msg = self.auth.heartbeat()
        if ok:
            messagebox.showinfo("Heartbeat", "Server OK — session còn hiệu lực.")
            self._refresh_license_label()
        else:
            messagebox.showerror("Heartbeat", msg)

    def _manual_pull_merge(self):
        if not self.auth or not self.auth.token:
            messagebox.showwarning("Sync", "Chưa đăng nhập.")
            return
        if not messagebox.askyesno(
            "Tải cloud",
            "Tải nick/proxy từ cloud và CHỈ THÊM cái mới?\n(Không xóa dữ liệu đang có trên máy)",
        ):
            return
        # force merge path even if local empty logic
        ok, data = self.auth.sync_pull()
        if not ok:
            messagebox.showerror("Sync", (data or {}).get('error') or 'Pull lỗi')
            return
        a_add, a_upd = self._merge_accounts_from_cloud(data.get('accounts') or [])
        p_add = self._merge_proxies_from_cloud(data.get('proxies') or [])
        self.save_accounts()
        self.save_proxies()
        try:
            self.refresh_table()
            self.refresh_proxy_table()
        except Exception:
            pass
        messagebox.showinfo("Sync", f"+{a_add} nick, cập nhật {a_upd}, +{p_add} proxy.")

    def _manual_push_cloud(self):
        if not self.auth or not self.auth.token:
            messagebox.showwarning("Sync", "Chưa đăng nhập.")
            return
        self._cloud_push_now(mode='replace', silent=False)
        messagebox.showinfo("Sync", f"Đã sao lưu {len(self.accounts)} nick + {len(self.proxies)} proxy lên cloud.")

    def _selected_account_indices(self):
        selected = self.tree.selection()
        indices = []
        for sel in selected:
            idx = self.tree.item(sel)['values'][0] - 1
            if 0 <= idx < len(self.accounts):
                indices.append(idx)
        return indices

    def _start_task_with_proxy_gate(self, task_type, config, indices=None, task_label=None):
        """
        Chuẩn hóa: inject runtime + lọc nick có proxy + submit job (chạy / xếp hàng).
        """
        label = task_label or task_type
        if indices is None:
            indices = list(range(len(self.accounts)))
        if not self._pre_check(need_accounts=True):
            return False
        ready = self.filter_indices_require_proxy(indices, task_label=label)
        if not ready:
            return False
        config = self.inject_runtime(config if isinstance(config, dict) else {})
        config['indices'] = ready
        config['require_proxy'] = bool(self.var_require_proxy.get()) if hasattr(self, 'var_require_proxy') else True
        # Nhãn job rõ hơn cho LOGIN giữ session
        job_label = self._task_label(task_type)
        if task_type == 'LOGIN':
            if config.get('keep_session') or config.get('preserve_session'):
                if not config.get('force_relogin'):
                    job_label = 'Đăng nhập / giữ session'
                else:
                    job_label = 'Đăng nhập (pass nếu cookie chết)'
        self._submit_job(task_type, config=config, indices=ready, label=job_label, kind='playwright')
        return True

    def start_login_all_keep_session(self):
        """
        Đăng nhập / làm mới session TOÀN BỘ nick — KHÔNG xóa cookie session.
        Cookie LIVE → refresh; cookie chết → mới thử pass (vẫn không clear trước khi biết chắc).
        """
        self.log("▶ ĐĂNG NHẬP TOÀN BỘ — chế độ GIỮ SESSION (không xóa cookie đang sống)")
        if not self._start_task_with_proxy_gate(
            "LOGIN",
            {
                'force_relogin': False,
                'preserve_session': True,
                'keep_session': True,
            },
            indices=list(range(len(self.accounts))),
            task_label='LOGIN',
        ):
            return
        # Đổi nhãn job cho dễ thấy
        try:
            if self.current_job and self.current_job.get('task_type') == 'LOGIN':
                self.current_job['label'] = 'Đăng nhập toàn bộ (giữ session)'
                self._ui_refresh_jobs()
        except Exception:
            pass

    def start_login_selected_keep_session(self):
        """Làm mới session nick đang chọn — không xóa cookie. Nick không proxy = bỏ qua."""
        indices = self._selected_account_indices()
        if not indices:
            messagebox.showwarning("Cảnh báo", "Chọn ít nhất 1 tài khoản ở tab Nick FB!")
            return
        n_sel = len(indices)
        n_no_px = sum(
            1 for i in indices
            if 0 <= i < len(self.accounts) and not self.account_has_proxy(self.accounts[i])
        )
        n_ok = n_sel - n_no_px
        self.log(
            f"▶ LÀM MỚI SESSION chọn {n_sel} nick — GIỮ SESSION | "
            f"có proxy={n_ok} sẽ chạy | chưa proxy={n_no_px} bỏ qua"
        )
        if n_ok == 0:
            messagebox.showwarning(
                "Không có proxy",
                f"Bạn chọn {n_sel} nick nhưng không nick nào đã gán proxy.\n"
                "Nick chưa proxy sẽ KHÔNG được login.\n\n"
                "Hãy gán proxy rồi chọn lại.",
            )
            return
        if not self._start_task_with_proxy_gate(
            "LOGIN",
            {
                'force_relogin': False,
                'preserve_session': True,
                'keep_session': True,
            },
            indices=indices,
            task_label='LOGIN',
        ):
            return
        try:
            if self.current_job and self.current_job.get('task_type') == 'LOGIN':
                self.current_job['label'] = f'Làm mới session (giữ) ×{len(indices)}'
                self._ui_refresh_jobs()
        except Exception:
            pass

    def start_login_all(self):
        """Login toàn bộ — có thể dùng pass nếu cookie chết (theo tick force)."""
        force = bool(self.var_force_relogin.get()) if hasattr(self, 'var_force_relogin') else False
        self.log(
            "▶ Login toàn bộ (cookie chết → pass). "
            + ("force tick bật." if force else "Không ép pass nếu cookie LIVE.")
        )
        if not self._start_task_with_proxy_gate(
            "LOGIN",
            {
                'force_relogin': force,
                'preserve_session': True,  # vẫn ưu tiên giữ session khi LIVE
                'keep_session': True,
            },
            indices=list(range(len(self.accounts))),
            task_label='LOGIN',
        ):
            return

    def start_login_selected(self):
        indices = self._selected_account_indices()
        if not indices:
            messagebox.showwarning("Cảnh báo", "Chọn ít nhất 1 tài khoản ở tab Nick FB!")
            return
        force = bool(self.var_force_relogin.get()) if hasattr(self, 'var_force_relogin') else False
        n_no_px = sum(
            1 for i in indices
            if 0 <= i < len(self.accounts) and not self.account_has_proxy(self.accounts[i])
        )
        if n_no_px:
            self.log(f"▶ Login chọn: {len(indices)} nick | bỏ qua {n_no_px} nick chưa gán proxy")
        if not self._start_task_with_proxy_gate(
            "LOGIN",
            {
                'force_relogin': force,
                'preserve_session': True,
                'keep_session': True,
            },
            indices=indices,
            task_label='LOGIN',
        ):
            return

    def start_verify_checkpoints_assist(self):
        """
        Mở cửa sổ verify checkpoint:
        - Browser HIỆN (không headless)
        - Nạp cookie khi dính CP + 2FA/pass có sẵn
        - Cần captcha/human → hiện hướng dẫn, bạn làm trên màn hình rồi bấm ĐÃ XONG
        """
        cp = [
            a for a in self.accounts
            if 'CHECKPOINT' in str(a.get('status') or '').upper()
            or 'checkpoint' in str(a.get('login_status') or '').lower()
        ]
        if not cp:
            messagebox.showinfo(
                "Verify Checkpoint",
                "Không có nick CHECKPOINT trong danh sách.",
            )
            return
        no_px = sum(1 for a in cp if not (a.get('proxy') or '').strip())
        msg = (
            f"Sẽ verify {len(cp)} nick CHECKPOINT.\n\n"
            "• Browser Chromium HIỆN trên màn hình\n"
            "• Dùng cookie đã lưu + mật khẩu + 2FA có sẵn\n"
            "• Cần CAPTCHA / human / video → làm trên browser,\n"
            "  rồi bấm «ĐÃ XONG» trong cửa sổ hỗ trợ\n"
        )
        if no_px:
            msg += f"\n⚠ {no_px} nick KHÔNG có proxy (dùng IP máy — rủi ro cao)."
        if not messagebox.askyesno("Verify Checkpoint", msg):
            return
        try:
            from verify_checkpoint_assist import run_as_dialog
        except ImportError:
            # fallback path
            import importlib.util
            assist_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'verify_checkpoint_assist.py')
            if not os.path.exists(assist_path):
                messagebox.showerror("Lỗi", f"Không tìm thấy {assist_path}")
                return
            spec = importlib.util.spec_from_file_location('verify_checkpoint_assist', assist_path)
            mod = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)
            run_as_dialog = mod.run_as_dialog

        def on_saved(accounts):
            # đồng bộ list + UI
            self.accounts = accounts
            try:
                self.save_accounts()
            except Exception:
                pass
            try:
                self._ui_call(self.refresh_table)
            except Exception:
                pass

        self.log(f"🛡 Mở Verify Checkpoint assist — {len(cp)} nick")
        run_as_dialog(
            parent=self.root,
            accounts=self.accounts,
            on_saved=on_saved,
            base_dir=os.path.dirname(os.path.abspath(__file__)),
        )

    def show_today_quota(self):
        lines = [f"Quota ngày {self._today()} (đã dùng / hạn theo mode hiện tại):\n"]
        jm = self.var_join_mode.get()
        cm = self.var_comment_mode.get()
        pm = self.var_post_mode.get()
        for acc in self.accounts:
            uid = acc.get('uid', '')
            jr, jl, ju = self.remaining_quota(uid, 'join', jm)
            cr, cl, cu = self.remaining_quota(uid, 'comment', cm)
            pr, pl, pu = self.remaining_quota(uid, 'post', pm)
            fr, fl, fu = self.remaining_quota(uid, 'friend', 'low')
            lines.append(
                f"{uid}: join {ju}/{jl} (còn {jr}) | cmt {cu}/{cl} (còn {cr}) | "
                f"post {pu}/{pl} (còn {pr}) | friend {fu}/{fl}"
            )
        if len(lines) == 1:
            lines.append("(Chưa có tài khoản)")
        messagebox.showinfo("Quota hôm nay", "\n".join(lines[:40]))

    # ------------------------------------------------------------------
    # Kịch bản tuần tự — lifecycle / warm-up / daily shifts
    # ------------------------------------------------------------------
    def _ensure_lifecycle_all(self):
        changed = False
        for acc in self.accounts:
            if self._ensure_lifecycle(acc):
                changed = True
        if changed:
            self.save_accounts()

    def _ensure_lifecycle(self, acc):
        """Đảm bảo lifecycle_start; nếu proxy đổi → reset warm-up."""
        changed = False
        uid = str(acc.get('uid', ''))
        fp = self._ip_port_key(acc.get('proxy') or '')
        if not acc.get('lifecycle_start'):
            acc['lifecycle_start'] = self._today()
            acc['proxy_fingerprint'] = fp
            acc['scenario_note'] = 'Bắt đầu warm-up'
            changed = True
        old_fp = acc.get('proxy_fingerprint') or ''
        if fp and old_fp and fp != old_fp:
            acc['lifecycle_start'] = self._today()
            acc['proxy_fingerprint'] = fp
            acc['scenario_note'] = 'Đổi proxy/IP → reset warm-up 7 ngày'
            # Xóa progress cũ để bắt đầu lại
            if uid in self.scenario_progress:
                self.scenario_progress[uid] = {'days': {}, 'note': acc['scenario_note']}
                self.save_scenario_progress()
            self.log(f"[{uid}] Proxy đổi ({old_fp} → {fp}) — reset warm-up từ hôm nay")
            changed = True
        elif fp and not old_fp:
            acc['proxy_fingerprint'] = fp
            changed = True
        return changed

    def _lifecycle_day(self, acc):
        """Ngày thứ mấy trong lifecycle (1-based)."""
        self._ensure_lifecycle(acc)
        try:
            start = datetime.strptime(acc.get('lifecycle_start') or self._today(), '%Y-%m-%d').date()
            today = datetime.now().date()
            return max(1, (today - start).days + 1)
        except Exception:
            return 1

    def _scenario_phase(self, acc):
        """warmup_silent | warmup_light | daily"""
        day = self._lifecycle_day(acc)
        if day <= 3:
            return 'warmup_silent', day
        if day <= 7:
            return 'warmup_light', day
        return 'daily', day

    def _current_shift_name(self):
        """Xác định ca theo giờ máy (hoặc force từ UI)."""
        force = self.var_scenario_force_shift.get() if hasattr(self, 'var_scenario_force_shift') else 'auto'
        if force and force != 'auto':
            return force
        cfg = self.scenario_cfg
        h = datetime.now().hour

        def in_range(start, end):
            try:
                s, e = int(start), int(end)
            except Exception:
                return False
            if s <= e:
                return s <= h < e
            return h >= s or h < e

        if in_range(cfg.get('morning_start', 8), cfg.get('morning_end', 10)):
            return 'morning'
        if in_range(cfg.get('noon_start', 12), cfg.get('noon_end', 14)):
            return 'noon'
        if in_range(cfg.get('evening_start', 19), cfg.get('evening_end', 22)):
            return 'evening'
        # Ngoài khung giờ: chọn ca gần nhất chưa chạy, mặc định morning
        if h < int(cfg.get('morning_start', 8) or 8):
            return 'morning'
        if h < int(cfg.get('noon_start', 12) or 12):
            return 'morning'
        if h < int(cfg.get('evening_start', 19) or 19):
            return 'noon'
        return 'evening'

    def _scenario_task_list(self, phase, shift='full'):
        """Danh sách task id theo phase/ca."""
        if phase == 'warmup_silent':
            return ['scroll', 'like', 'read_noti']
        if phase == 'warmup_light':
            return ['scroll', 'like', 'friend_suggest', 'friend_accept', 'post_wall_safe', 'join']
        # daily
        if shift == 'morning':
            return ['scroll', 'like', 'comment_friend', 'friend_accept']
        if shift == 'noon':
            return ['join', 'post_wall', 'group_browse_like']
        if shift == 'evening':
            return ['comment_group']
        if shift == 'full':
            return ['scroll', 'like', 'comment_friend', 'friend_accept', 'join', 'post_wall', 'group_browse_like', 'comment_group']
        return ['scroll', 'like']

    def _task_labels(self):
        return {
            'scroll': 'Lướt newsfeed',
            'like': 'Like bài',
            'read_noti': 'Đọc thông báo',
            'friend_suggest': 'Kết bạn đề xuất',
            'friend_accept': 'Accept kết bạn',
            'post_wall_safe': 'Đăng status (không link)',
            'post_wall': 'Đăng tường',
            'join': 'Join group',
            'comment_friend': 'Comment bạn bè',
            'group_browse_like': 'Lướt/Like group',
            'comment_group': 'Comment group',
        }

    def _acc_progress(self, uid):
        uid = str(uid)
        if uid not in self.scenario_progress:
            self.scenario_progress[uid] = {'days': {}, 'note': ''}
        self.scenario_progress[uid].setdefault('days', {})
        return self.scenario_progress[uid]

    def _today_progress(self, uid, phase, shift):
        prog = self._acc_progress(uid)
        today = self._today()
        day_rec = prog['days'].setdefault(today, {
            'phase': phase,
            'shifts': {},
            'status': 'pending',
        })
        day_rec['phase'] = phase
        key = shift if phase == 'daily' else 'full'
        shift_rec = day_rec['shifts'].setdefault(key, {
            'tasks': {},
            'status': 'pending',
            'updated_at': '',
        })
        return day_rec, shift_rec, key

    def get_account_scenario_status(self, acc):
        """Trả dict mô tả trạng thái kịch bản 1 nick (cho bảng UI)."""
        uid = acc.get('uid', '')
        phase, day = self._scenario_phase(acc)
        shift = 'full' if phase != 'daily' else self._current_shift_name()
        if phase != 'daily' and shift in ('morning', 'noon', 'evening'):
            # Warm-up luôn chạy full ngày (1 session/ngày)
            shift = 'full'
        tasks = self._scenario_task_list(phase, shift if phase == 'daily' else 'full')
        _, shift_rec, key = self._today_progress(uid, phase, shift if phase == 'daily' else 'full')
        task_st = shift_rec.get('tasks') or {}
        labels = self._task_labels()
        done, pending = [], []
        for t in tasks:
            st = (task_st.get(t) or 'pending').lower()
            lab = labels.get(t, t)
            if st in ('done', 'success', 'ok', 'skip'):
                done.append(lab if st != 'skip' else f'{lab}(skip)')
            else:
                pending.append(lab)
        overall = shift_rec.get('status') or 'pending'
        if not pending and tasks:
            overall = 'done'
        elif done and pending:
            overall = 'partial'
        elif not done:
            overall = 'pending'
        jr, jl, ju = self.remaining_scenario_quota(uid, 'join')
        cr, cl, cu = self.remaining_scenario_quota(uid, 'comment_group')
        fr, fl, fu = self.remaining_scenario_quota(uid, 'friend')
        phase_label = {
            'warmup_silent': f'Warm-up im lặng (N{day})',
            'warmup_light': f'Warm-up nhẹ (N{day})',
            'daily': f'Daily (N{day})',
        }.get(phase, phase)
        shift_label = {
            'full': 'Cả ngày',
            'morning': 'Sáng',
            'noon': 'Trưa',
            'evening': 'Tối',
        }.get(key, key)
        return {
            'uid': uid,
            'day': day,
            'phase': phase,
            'phase_label': phase_label,
            'shift': key,
            'shift_label': shift_label,
            'done_list': done,
            'pending_list': pending,
            'status': overall,
            'quota': f'J{ju}/{jl} C{cu}/{cl} F{fu}/{fl}',
            'proxy': acc.get('proxy') or 'Chưa gán',
            'tasks': tasks,
        }

    def refresh_scenario_table(self):
        if not hasattr(self, 'scenario_tree'):
            return
        for row in self.scenario_tree.get_children():
            self.scenario_tree.delete(row)
        done_n = partial_n = pending_n = 0
        for idx, acc in enumerate(self.accounts):
            self._ensure_lifecycle(acc)
            info = self.get_account_scenario_status(acc)
            st = info['status']
            if st == 'done':
                done_n += 1
            elif st == 'partial':
                partial_n += 1
            else:
                pending_n += 1
            st_label = {
                'done': '✓ XONG',
                'partial': '◐ MỘT PHẦN',
                'pending': '○ CHƯA',
                'running': '… ĐANG CHẠY',
                'fail': '✗ LỖI',
            }.get(st, st)
            self.scenario_tree.insert(
                '', 'end',
                values=(
                    idx + 1,
                    info['uid'],
                    info['day'],
                    info['phase_label'],
                    info['shift_label'],
                    ', '.join(info['done_list']) or '—',
                    ', '.join(info['pending_list']) or '—',
                    st_label,
                    info['quota'],
                    (info['proxy'][:28] + '…') if len(info['proxy']) > 30 else info['proxy'],
                ),
            )
        total = len(self.accounts)
        if hasattr(self, 'scenario_summary'):
            self.scenario_summary.config(
                text=(
                    f"Hôm nay {self._today()}: Tổng {total} | "
                    f"✓ Xong: {done_n} | ◐ Một phần: {partial_n} | ○ Chưa: {pending_n} | "
                    f"Ca hiện tại: {self._current_shift_name()}"
                )
            )

    def save_scenario_cfg_from_ui(self, silent=False):
        def _int(v, default):
            try:
                return int(str(v).strip())
            except Exception:
                return default
        if not hasattr(self, 'sc_keywords'):
            return
        self.scenario_cfg.update({
            'morning_start': _int(self.sc_morning_start.get(), 8),
            'morning_end': _int(self.sc_morning_end.get(), 10),
            'noon_start': _int(self.sc_noon_start.get(), 12),
            'noon_end': _int(self.sc_noon_end.get(), 14),
            'evening_start': _int(self.sc_evening_start.get(), 19),
            'evening_end': _int(self.sc_evening_end.get(), 22),
            'keywords': self.sc_keywords.get().strip(),
            'promo_link': self.sc_promo_link.get().strip(),
            'status_spin': self.sc_status_text.get('1.0', 'end').strip(),
            'friend_comment_spin': self.sc_friend_cmt.get('1.0', 'end').strip(),
            'group_comment_spin': self.sc_group_cmt.get('1.0', 'end').strip(),
        })
        self.save_scenario_config()
        self.log("Đã lưu cấu hình kịch bản → scenario_config_v9.json")
        if not silent:
            messagebox.showinfo("Kịch bản", "Đã lưu cấu hình kịch bản.")

    def _scenario_indices(self, scope=None):
        scope = scope or (self.var_scenario_scope.get() if hasattr(self, 'var_scenario_scope') else 'all')
        if scope == 'selected':
            indices = self._selected_account_indices()
            if not indices:
                messagebox.showwarning("Cảnh báo", "Chọn ít nhất 1 tài khoản ở tab Quản lý Tài khoản!")
                return []
            return indices
        if scope == 'pending':
            out = []
            for i, acc in enumerate(self.accounts):
                info = self.get_account_scenario_status(acc)
                if info['status'] != 'done':
                    out.append(i)
            return out
        return list(range(len(self.accounts)))

    def _sort_indices_by_proxy(self, indices):
        """Sắp xếp: cùng proxy chạy liền nhau (tuần tự, không song song)."""
        def key(i):
            acc = self.accounts[i]
            return (self._ip_port_key(acc.get('proxy') or '') or 'zzz', acc.get('uid', ''))
        return sorted(indices, key=key)

    def start_scenario_today(self):
        self.save_scenario_cfg_from_ui(silent=True)
        indices = self._scenario_indices()
        if not indices:
            if self.var_scenario_scope.get() == 'pending':
                messagebox.showinfo("Kịch bản", "Tất cả nick đã xong kịch bản hôm nay (theo ca hiện tại).")
            return
        self._launch_scenario(indices)

    def start_scenario_selected(self):
        self.save_scenario_cfg_from_ui(silent=True)
        indices = self._selected_account_indices()
        if not indices:
            messagebox.showwarning("Cảnh báo", "Chọn ít nhất 1 tài khoản ở tab Quản lý Tài khoản!")
            return
        self._launch_scenario(indices)

    def _launch_scenario(self, indices):
        if not self._pre_check(need_accounts=True):
            return
        indices = self._sort_indices_by_proxy(indices)
        ready = self.filter_indices_require_proxy(indices, task_label='SCENARIO')
        if not ready:
            return
        shift = self._current_shift_name()
        force = self.var_scenario_force_shift.get() if hasattr(self, 'var_scenario_force_shift') else 'auto'
        config = self.inject_runtime({
            'indices': ready,
            'force_shift': force,
            'keywords': self.sc_keywords.get().strip(),
            'promo_link': self.sc_promo_link.get().strip(),
            'status_spin': self.sc_status_text.get('1.0', 'end').strip(),
            'friend_comment_spin': self.sc_friend_cmt.get('1.0', 'end').strip(),
            'group_comment_spin': self.sc_group_cmt.get('1.0', 'end').strip(),
            'image': self.post_image_path.get().strip() if hasattr(self, 'post_image_path') else '',
        })
        # Ước lượng tổng thời gian + log kế hoạch
        est_total = 0.0
        phase_counts = {}
        for i in ready:
            acc = self.accounts[i]
            sec, phase, sh, pending = self._estimate_nick_scenario_seconds(acc, force)
            est_total += sec
            phase_counts[phase] = phase_counts.get(phase, 0) + 1
        plan = ', '.join(f"{k}:{v}" for k, v in phase_counts.items()) or '—'
        self.log(
            f">>> KỊCH BẢN HÔM NAY: {len(ready)} nick | ca UI={shift} | force={force} | "
            f"headless={config['headless']} | delay action {SAFE_ACTION_DELAY[0]}-{SAFE_ACTION_DELAY[1]}s"
        )
        self.log(
            f"[TIẾN ĐỘ] Kế hoạch: {plan} | dự kiến ~{self._fmt_duration(est_total)} "
            f"(~{self._fmt_duration(est_total / max(len(ready), 1))}/nick) | "
            f"xong khoảng {(datetime.now() + timedelta(seconds=int(est_total))).strftime('%H:%M')}"
        )
        self.log(
            "[TIẾN ĐỘ] Log sẽ báo từng nick/task, % hoàn thành, thời gian đã chạy và còn lại (ETA)."
        )
        config['_est_total_sec'] = est_total
        self._submit_job('SCENARIO', config=config, indices=ready, label='Kịch bản hôm nay', kind='playwright')

    def reset_lifecycle_selected(self):
        indices = self._selected_account_indices()
        if not indices:
            messagebox.showwarning("Cảnh báo", "Chọn nick cần reset warm-up (sau khi đổi proxy/IP).")
            return
        if not messagebox.askyesno("Xác nhận", f"Reset warm-up 7 ngày cho {len(indices)} nick đang chọn?"):
            return
        today = self._today()
        for i in indices:
            acc = self.accounts[i]
            uid = str(acc.get('uid', ''))
            acc['lifecycle_start'] = today
            acc['proxy_fingerprint'] = self._ip_port_key(acc.get('proxy') or '')
            acc['scenario_note'] = 'Reset warm-up thủ công'
            self.scenario_progress[uid] = {'days': {}, 'note': 'Reset warm-up'}
        self.save_accounts()
        self.save_scenario_progress()
        self.refresh_scenario_table()
        self.refresh_table()
        self.log(f"Đã reset warm-up cho {len(indices)} nick")

    def reset_lifecycle_all(self):
        if not self.accounts:
            return
        if not messagebox.askyesno("Xác nhận", f"Reset warm-up 7 ngày cho TẤT CẢ {len(self.accounts)} nick?"):
            return
        today = self._today()
        for acc in self.accounts:
            uid = str(acc.get('uid', ''))
            acc['lifecycle_start'] = today
            acc['proxy_fingerprint'] = self._ip_port_key(acc.get('proxy') or '')
            acc['scenario_note'] = 'Reset warm-up tất cả'
            self.scenario_progress[uid] = {'days': {}, 'note': 'Reset all'}
        self.save_accounts()
        self.save_scenario_progress()
        self.refresh_scenario_table()
        self.refresh_table()
        self.log("Đã reset warm-up toàn bộ nick")

    def show_scenario_summary(self):
        lines = [f"BÁO CÁO KỊCH BẢN — {self._today()}\n"]
        done = partial = pending = no_proxy = 0
        for acc in self.accounts:
            info = self.get_account_scenario_status(acc)
            if not self.account_has_proxy(acc):
                no_proxy += 1
            if info['status'] == 'done':
                done += 1
                mark = '✓'
            elif info['status'] == 'partial':
                partial += 1
                mark = '◐'
            else:
                pending += 1
                mark = '○'
            pending_s = ', '.join(info['pending_list'][:4]) or '—'
            lines.append(
                f"{mark} {info['uid']} | N{info['day']} {info['phase_label']} | "
                f"{info['shift_label']} | {info['status']} | chưa: {pending_s}"
            )
        lines.insert(1, f"Xong: {done} | Một phần: {partial} | Chưa: {pending} | Không proxy: {no_proxy}\n")
        messagebox.showinfo("Báo cáo kịch bản", "\n".join(lines[:45]))

    def _build_interact_tab(self, tab):
        """Tab đầy đủ: bạn bè, sinh nhật, group phụ, fanpage."""
        ttk.Label(
            tab,
            text="Tương tác Facebook — Bạn bè · Sinh nhật · Group · Fanpage (quota + delay an toàn)",
            style='Title.TLabel',
        ).pack(anchor='w', padx=12, pady=(12, 4))
        ttk.Label(
            tab,
            text="Nick mới: dùng Ít. Mỗi hành động đều headless + proxy. Checkpoint → tab Acc chết.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=(0, 8))

        # ---- Bạn bè ----
        fr = ttk.LabelFrame(tab, text="  👥  Bạn bè  ")
        fr.pack(fill='x', padx=12, pady=6)
        f1 = ttk.Frame(fr)
        f1.pack(fill='x', padx=8, pady=6)
        self.var_friend_limit = tk.StringVar(value='5')
        ttk.Label(f1, text="Số lượng / nick:").pack(side='left')
        self._mk_entry(f1, textvariable=self.var_friend_limit, width=5).pack(side='left', padx=6)
        ttk.Button(f1, text="▶ Accept lời mời", style='Accent.TButton', command=self.start_friend_accept).pack(side='left', padx=4)
        ttk.Button(f1, text="▶ Kết bạn gợi ý", style='Accent.TButton', command=self.start_friend_suggest).pack(side='left', padx=4)
        f2 = ttk.Frame(fr)
        f2.pack(fill='x', padx=8, pady=4)
        ttk.Label(f2, text="Tên / UID / link kết bạn:").pack(side='left')
        self.var_friend_search = tk.StringVar(value='')
        self._mk_entry(f2, textvariable=self.var_friend_search, width=40).pack(side='left', padx=6)
        ttk.Button(f2, text="▶ Kết bạn theo tên/UID", command=self.start_friend_search).pack(side='left', padx=4)
        f3 = ttk.Frame(fr)
        f3.pack(fill='x', padx=8, pady=4)
        ttk.Label(f3, text="Comment tường bạn (spin):").pack(side='left')
        self.var_friend_wall_cmt = tk.StringVar(
            value=DEFAULT_SCENARIO_CFG.get('friend_comment_spin', '{Hay quá|Tuyệt vời}')
        )
        self._mk_entry(f3, textvariable=self.var_friend_wall_cmt, width=45).pack(side='left', padx=6)
        ttk.Button(f3, text="▶ Like/Comment feed bạn", command=self.start_friend_wall).pack(side='left', padx=4)
        ttk.Button(f3, text="▶ Poke bạn bè", command=self.start_friend_poke).pack(side='left', padx=4)

        # ---- Sinh nhật ----
        bd = ttk.LabelFrame(tab, text="  🎂  Chúc sinh nhật  ")
        bd.pack(fill='x', padx=12, pady=6)
        b1 = ttk.Frame(bd)
        b1.pack(fill='x', padx=8, pady=6)
        self.var_birthday_limit = tk.StringVar(value='8')
        ttk.Label(b1, text="Số người chúc / nick:").pack(side='left')
        self._mk_entry(b1, textvariable=self.var_birthday_limit, width=5).pack(side='left', padx=6)
        ttk.Button(b1, text="▶ Chúc sinh nhật hôm nay", style='Accent.TButton', command=self.start_birthday).pack(side='left', padx=8)
        ttk.Label(bd, text="Nội dung chúc (Spin {A|B}):", style='Muted.TLabel').pack(anchor='w', padx=8)
        self.birthday_text = tk.Text(bd, height=2, width=100)
        self.birthday_text.pack(anchor='w', padx=8, pady=4)
        self._style_text(self.birthday_text)
        self.birthday_text.insert('1.0', DEFAULT_BIRTHDAY_SPIN)

        # ---- Group phụ ----
        gr = ttk.LabelFrame(tab, text="  👪  Group (bổ sung)  ")
        gr.pack(fill='x', padx=12, pady=6)
        g1 = ttk.Frame(gr)
        g1.pack(fill='x', padx=8, pady=6)
        self.var_group_browse_likes = tk.StringVar(value='3')
        self.var_group_invite_n = tk.StringVar(value='3')
        self.var_group_leave_n = tk.StringVar(value='1')
        ttk.Label(g1, text="Like/bài khi lướt:").pack(side='left')
        self._mk_entry(g1, textvariable=self.var_group_browse_likes, width=4).pack(side='left', padx=4)
        ttk.Button(g1, text="▶ Lướt + Like group đã join", command=self.start_group_browse).pack(side='left', padx=6)
        ttk.Label(g1, text="Mời:").pack(side='left', padx=(12, 2))
        self._mk_entry(g1, textvariable=self.var_group_invite_n, width=4).pack(side='left', padx=2)
        ttk.Button(g1, text="▶ Mời bạn vào group", command=self.start_group_invite).pack(side='left', padx=4)
        ttk.Label(g1, text="Rời:").pack(side='left', padx=(12, 2))
        self._mk_entry(g1, textvariable=self.var_group_leave_n, width=4).pack(side='left', padx=2)
        ttk.Button(g1, text="▶ Rời group", style='Danger.TButton', command=self.start_group_leave).pack(side='left', padx=4)
        ttk.Label(
            gr,
            text="Join/Comment chính nằm ở tab «Group». Mời/rời: chỉ group đã join.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=8, pady=(0, 6))

        # ---- Fanpage ----
        pg = ttk.LabelFrame(tab, text="  📄  Fanpage  ")
        pg.pack(fill='x', padx=12, pady=6)
        p1 = ttk.Frame(pg)
        p1.pack(fill='x', padx=8, pady=6)
        ttk.Label(p1, text="Tên / link / ID fanpage (cách nhau dấu phẩy):").pack(side='left')
        self.var_page_targets = tk.StringVar(value='')
        self._mk_entry(p1, textvariable=self.var_page_targets, width=50).pack(side='left', padx=6)
        p2 = ttk.Frame(pg)
        p2.pack(fill='x', padx=8, pady=4)
        self.var_page_like_limit = tk.StringVar(value='5')
        self.var_page_post_likes = tk.StringVar(value='3')
        self.var_page_post_cmts = tk.StringVar(value='1')
        ttk.Label(p2, text="Like page:").pack(side='left')
        self._mk_entry(p2, textvariable=self.var_page_like_limit, width=4).pack(side='left', padx=4)
        ttk.Button(p2, text="▶ Like / Follow page", style='Accent.TButton', command=self.start_page_like).pack(side='left', padx=6)
        ttk.Label(p2, text="Like bài:").pack(side='left', padx=(10, 2))
        self._mk_entry(p2, textvariable=self.var_page_post_likes, width=4).pack(side='left', padx=2)
        ttk.Label(p2, text="Comment:").pack(side='left', padx=(8, 2))
        self._mk_entry(p2, textvariable=self.var_page_post_cmts, width=4).pack(side='left', padx=2)
        ttk.Button(p2, text="▶ Tương tác bài page", command=self.start_page_interact).pack(side='left', padx=6)
        ttk.Label(pg, text="Comment page (spin):", style='Muted.TLabel').pack(anchor='w', padx=8)
        self.page_comment_text = tk.Text(pg, height=2, width=100)
        self.page_comment_text.pack(anchor='w', padx=8, pady=4)
        self._style_text(self.page_comment_text)
        self.page_comment_text.insert('1.0', '{Hay quá|Tuyệt vời|Ủng hộ page|Bài hữu ích|Like mạnh}')

        ttk.Label(
            tab,
            text="Gợi ý an toàn: SN ≤10/ngày · Kết bạn ≤5–10/ngày · Page like ≤10/ngày · Delay tự động 5–15s giữa hành động.",
            style='Muted.TLabel',
        ).pack(anchor='w', padx=12, pady=8)

    def _ix_limit(self, var, default=5, lo=1, hi=30):
        try:
            n = int(str(var.get() if hasattr(var, 'get') else var).strip() or default)
        except Exception:
            n = default
        return max(lo, min(hi, n))

    def _start_interact_job(self, task_type, config, label=None):
        if not self._pre_check(need_accounts=True):
            return False
        ready = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label=task_type)
        if not ready:
            return False
        cfg = self.inject_runtime(config if isinstance(config, dict) else {})
        cfg['indices'] = ready
        self._submit_job(
            task_type, config=cfg, indices=ready,
            label=label or self._task_label(task_type), kind='playwright',
        )
        return True

    def start_nurture(self):
        if not self._pre_check(need_accounts=True):
            return
        ready = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label='NURTURE')
        if not ready:
            return
        config = self.inject_runtime({
            "read_post": self.var_read_post.get(),
            "auto_like": self.var_auto_like.get(),
            "accept_friend": self.var_accept_friend.get(),
            "add_suggested": self.var_add_suggested.get(),
            "read_noti": getattr(self, 'var_nurture_noti', tk.BooleanVar(value=True)).get(),
            "birthday": getattr(self, 'var_nurture_birthday', tk.BooleanVar(value=False)).get(),
            "birthday_text": (
                self.birthday_text.get('1.0', 'end').strip()
                if hasattr(self, 'birthday_text') else DEFAULT_BIRTHDAY_SPIN
            ),
            "indices": ready,
        })
        self._submit_job('NURTURE', config=config, indices=ready, label='Nuôi nick', kind='playwright')

    def start_friend_accept(self):
        self._start_interact_job('FRIEND_ACCEPT', {
            'max_n': self._ix_limit(self.var_friend_limit, 5),
        })

    def start_friend_suggest(self):
        self._start_interact_job('FRIEND_SUGGEST', {
            'max_n': self._ix_limit(self.var_friend_limit, 5),
        })

    def start_friend_search(self):
        q = (self.var_friend_search.get() if hasattr(self, 'var_friend_search') else '').strip()
        if not q:
            messagebox.showerror("Lỗi", "Nhập tên / UID / link cần kết bạn!")
            return
        self._start_interact_job('FRIEND_SEARCH', {
            'query': q,
            'max_n': self._ix_limit(self.var_friend_limit, 3, hi=10),
        })

    def start_friend_wall(self):
        self._start_interact_job('FRIEND_WALL', {
            'max_n': self._ix_limit(self.var_friend_limit, 3, hi=10),
            'comment': (self.var_friend_wall_cmt.get() if hasattr(self, 'var_friend_wall_cmt') else ''),
            'do_like': True,
            'do_comment': True,
        })

    def start_friend_poke(self):
        self._start_interact_job('FRIEND_POKE', {
            'max_n': self._ix_limit(self.var_friend_limit, 3, hi=10),
        })

    def start_birthday(self):
        text = self.birthday_text.get('1.0', 'end').strip() if hasattr(self, 'birthday_text') else DEFAULT_BIRTHDAY_SPIN
        if not text:
            messagebox.showerror("Lỗi", "Nhập nội dung chúc sinh nhật!")
            return
        self._start_interact_job('FRIEND_BIRTHDAY', {
            'max_n': self._ix_limit(self.var_birthday_limit, 8, hi=20),
            'message': text,
        })

    def start_group_browse(self):
        self._start_interact_job('GROUP_BROWSE', {
            'like_n': self._ix_limit(
                getattr(self, 'var_group_browse_likes', tk.StringVar(value='3')), 3, hi=15
            ),
            'groups_n': 2,
        })

    def start_group_invite(self):
        self._start_interact_job('GROUP_INVITE', {
            'max_n': self._ix_limit(
                getattr(self, 'var_group_invite_n', tk.StringVar(value='3')), 3, hi=10
            ),
        })

    def start_group_leave(self):
        n = self._ix_limit(getattr(self, 'var_group_leave_n', tk.StringVar(value='1')), 1, hi=5)
        if not messagebox.askyesno(
            "Rời group",
            f"Mỗi nick sẽ rời tối đa {n} group đã join (ngẫu nhiên).\nTiếp tục?",
        ):
            return
        self._start_interact_job('GROUP_LEAVE', {'max_n': n})

    def start_page_like(self):
        targets = (self.var_page_targets.get() if hasattr(self, 'var_page_targets') else '').strip()
        if not targets:
            messagebox.showerror("Lỗi", "Nhập tên / link / ID fanpage (cách nhau dấu phẩy)!")
            return
        self._start_interact_job('PAGE_LIKE', {
            'targets': targets,
            'max_n': self._ix_limit(self.var_page_like_limit, 5, hi=15),
        })

    def start_page_interact(self):
        targets = (self.var_page_targets.get() if hasattr(self, 'var_page_targets') else '').strip()
        if not targets:
            messagebox.showerror("Lỗi", "Nhập fanpage cần tương tác!")
            return
        cmt = ''
        if hasattr(self, 'page_comment_text'):
            cmt = self.page_comment_text.get('1.0', 'end').strip()
        self._start_interact_job('PAGE_INTERACT', {
            'targets': targets,
            'like_n': self._ix_limit(self.var_page_post_likes, 3, hi=15),
            'comment_n': self._ix_limit(self.var_page_post_cmts, 1, hi=5),
            'comment': cmt or '{Hay quá|Tuyệt vời}',
        })

    def start_join(self):
        keywords = self.keyword_entry.get().strip()
        if not keywords:
            messagebox.showerror("Lỗi", "Nhập từ khóa tìm nhóm!")
            return
        if not self._pre_check(need_accounts=True):
            return
        ready = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label='JOIN_GROUP')
        if not ready:
            return
        mode = self.var_join_mode.get()
        preset = self.get_preset(mode)
        try:
            custom = int(self.join_limit_var.get().strip() or '0')
        except ValueError:
            custom = 0
        per_kw = custom if custom > 0 else preset['per_keyword']
        config = self.inject_runtime({
            'keywords': keywords,
            'limit': per_kw,
            'mode': mode,
            'delay': preset['delay'],
            'day_limit': min(preset['join_day'], ABSOLUTE_CAPS['join_day']),
            'indices': ready,
        })
        self.log(
            f"Join mode={preset['label']} | {config['day_limit']}/ngày/nick | "
            f"{per_kw}/từ khóa | delay {preset['delay'][0]}-{preset['delay'][1]}s | "
            f"browser={config['browser']} headless={config['headless']} | nicks={len(ready)}"
        )
        self._submit_job('JOIN_GROUP', config=config, indices=ready, label='Join nhóm', kind='playwright')

    def start_group_comment(self):
        if not self._pre_check(need_accounts=True):
            return
        ready = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label='COMMENT_GROUP')
        if not ready:
            return
        mode = self.var_comment_mode.get()
        preset = self.get_preset(mode)
        comment = self.comment_text.get("1.0", "end").strip()
        config = self.inject_runtime({
            'comment': comment,
            'mode': mode,
            'delay': preset['delay'],
            'day_limit': min(preset['comment_day'], ABSOLUTE_CAPS['comment_day']),
            'indices': ready,
        })
        self.log(
            f"Comment mode={preset['label']} | {config['day_limit']}/ngày/nick | "
            f"browser={config['browser']} headless={config['headless']} | nicks={len(ready)}"
        )
        self._submit_job('COMMENT_GROUP', config=config, indices=ready, label='Comment group', kind='playwright')

    def start_post_now(self):
        content = self.post_content.get("1.0", "end").strip()
        if not content:
            messagebox.showerror("Lỗi", "Nội dung bài đăng trống!")
            return
        if not self.var_post_wall.get() and not self.var_post_groups.get():
            messagebox.showerror("Lỗi", "Chọn đăng tường và/hoặc đăng nhóm!")
            return
        if not self._pre_check(need_accounts=True):
            return
        ready = self.filter_indices_require_proxy(list(range(len(self.accounts))), task_label='POST')
        if not ready:
            return
        mode = self.var_post_mode.get()
        preset = self.get_preset(mode)
        try:
            g_limit = int(self.post_group_limit.get().strip() or '0')
        except ValueError:
            g_limit = 0
        if g_limit <= 0:
            g_limit = max(1, min(preset['post_day'], 5))
        config = self.inject_runtime({
            'content': content,
            'link': self.promo_link.get().strip(),
            'image': self.post_image_path.get().strip(),
            'post_wall': self.var_post_wall.get(),
            'post_groups': self.var_post_groups.get(),
            'group_limit': g_limit,
            'mode': mode,
            'delay': preset['delay'],
            'day_limit': min(preset['post_day'], ABSOLUTE_CAPS['post_day']),
            'indices': ready,
        })
        self.log(
            f"Post mode={preset['label']} | {config['day_limit']}/ngày/nick | "
            f"browser={config['browser']} headless={config['headless']} | nicks={len(ready)}"
        )
        self._submit_job('POST', config=config, indices=ready, label='Đăng bài', kind='playwright')

    def start_post_ai(self):
        # tương thích cũ
        self.generate_local_content()
        self.start_post_now()

    def reload_vn_names(self):
        self.vn_names = self.load_vietnamese_names()
        self.log(f"Đã nạp {len(self.vn_names)} tên từ {NAMES_FILE}")
        messagebox.showinfo("Kho tên", f"Đã nạp {len(self.vn_names)} tên Việt từ file {NAMES_FILE}")
        self.refresh_table()

    def fill_names_entry_sample(self):
        sample = self.pick_random_names(10)
        if isinstance(sample, str):
            sample = [sample]
        self.names_entry.delete(0, tk.END)
        self.names_entry.insert(0, ', '.join(sample))

    def _assign_names_to_accounts(self, targets, force=False):
        """Gán planned_name ngẫu nhiên từ kho VN. Trả về số nick được gán."""
        if not targets:
            return 0
        used = {a.get('planned_name') for a in self.accounts if a.get('planned_name')}
        names = self.pick_random_names(len(targets), exclude=used)
        if isinstance(names, str):
            names = [names]
        count = 0
        for acc, name in zip(targets, names):
            if acc.get('planned_name') and not force:
                continue
            # tránh trùng trong batch
            while name in used and len(used) < len(self.vn_names or []):
                name = self.pick_random_names(1, exclude=used)
            acc['planned_name'] = name
            used.add(name)
            count += 1
        return count

    def assign_random_names_to_new(self):
        """Chọn sẵn tên profile Facebook (chưa đẩy lên FB — bước chuẩn bị)."""
        targets = [a for a in self.accounts if a.get('is_new')]
        if not targets:
            targets = [a for a in self.accounts if not a.get('planned_name')]
            if not targets:
                messagebox.showinfo("Thông báo", "Không có nick MỚI / chưa có tên profile.")
                return
            if not messagebox.askyesno("Xác nhận", f"Không có cờ MỚI. Random tên profile FB cho {len(targets)} nick chưa có tên?"):
                return
        n = self._assign_names_to_accounts(targets, force=True)
        self.save_accounts()
        self.refresh_table()
        self.log(f"Đã random {n} tên profile Facebook (chưa đẩy lên FB). Bấm 'Đổi TÊN profile FB' để áp dụng.")
        messagebox.showinfo(
            "Tên profile đã chọn",
            f"Đã chọn {n} tên cho cột 'Tên profile FB (sẽ đổi)'.\n\n"
            "Đây mới là tên SẼ ĐỔI trên Facebook.\n"
            "Bấm '▶ Đổi TÊN profile FB' để login và đổi thật trên Facebook.",
        )

    def assign_random_names_to_all(self):
        if not self.accounts:
            messagebox.showwarning("Cảnh báo", "Chưa có tài khoản!")
            return
        if not messagebox.askyesno("Xác nhận", f"Random lại tên profile Facebook cho TẤT CẢ {len(self.accounts)} nick?"):
            return
        n = self._assign_names_to_accounts(self.accounts, force=True)
        self.save_accounts()
        self.refresh_table()
        self.log(f"Đã random {n} tên profile FB. Cần bấm Đổi TÊN profile FB để áp dụng lên Facebook.")
        messagebox.showinfo("Xong", f"Đã chọn {n} tên profile.\nBấm 'Đổi TÊN profile FB' để đẩy lên Facebook.")

    def _indices_for_profile(self, only_new=True):
        if only_new:
            indices = [i for i, a in enumerate(self.accounts) if a.get('is_new')]
            if not indices:
                # fallback: tất cả nếu user xác nhận
                if messagebox.askyesno(
                    "Không có nick MỚI",
                    "Không có tài khoản gắn cờ MỚI.\nĐổi profile Facebook cho TẤT CẢ nick?",
                ):
                    return list(range(len(self.accounts)))
                return []
            return indices
        return list(range(len(self.accounts)))

    def _start_profile_job(self, change_name, change_avatar, only_new=True, log_title=''):
        """Chạy automation đổi profile THẬT trên Facebook."""
        indices = self._indices_for_profile(only_new=only_new)
        if not indices:
            return
        indices = self.filter_indices_require_proxy(indices, task_label=log_title or 'UPDATE_PROFILE')
        if not indices:
            return

        # CHỐT WARM-UP: ngày 1–3 tuyệt đối không đổi tên/avatar (dễ checkpoint)
        blocked, allowed = [], []
        for i in indices:
            acc = self.accounts[i]
            phase, day = self._scenario_phase(acc)
            if phase == 'warmup_silent' or day <= 3:
                blocked.append((acc.get('uid'), day))
            else:
                allowed.append(i)
        if blocked:
            preview = ', '.join(f"{u}(N{d})" for u, d in blocked[:8])
            more = '…' if len(blocked) > 8 else ''
            self.log(
                f"[WARM-UP] Chặn đổi profile {len(blocked)} nick ngày 1–3: {preview}{more}"
            )
            if not allowed:
                messagebox.showwarning(
                    "Warm-up — không đổi profile",
                    f"Tất cả {len(blocked)} nick đang ngày 1–3 (warm-up im lặng).\n\n"
                    "Đổi tên/avatar lúc này rất dễ checkpoint.\n"
                    "Chỉ nên đổi profile từ ngày 4–7 trở đi, từng nick, có delay.\n\n"
                    f"Nick: {preview}{more}",
                )
                return
            if not messagebox.askyesno(
                "Warm-up",
                f"{len(blocked)} nick ngày ≤3 sẽ BỎ QUA (tránh checkpoint).\n"
                f"Chỉ chạy {len(allowed)} nick còn lại?\n\n"
                f"Bỏ qua: {preview}{more}",
            ):
                return
            indices = allowed

        if change_name:
            need = [self.accounts[i] for i in indices if not self.accounts[i].get('planned_name')]
            if need:
                self._assign_names_to_accounts(need, force=True)
                self.save_accounts()
                self.refresh_table()
        if change_avatar:
            folder = self.avatar_folder_entry.get().strip() if hasattr(self, 'avatar_folder_entry') else ''
            if not folder or not os.path.isdir(folder):
                messagebox.showerror("Lỗi", "Thư mục avatar không hợp lệ. Chọn thư mục avatars trong app.")
                return
            imgs = [f for f in os.listdir(folder) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif'))]
            if not imgs:
                messagebox.showerror("Lỗi", "Thư mục avatar không có ảnh.")
                return
        if not self._pre_check(need_accounts=True):
            return
        config = self.inject_runtime({
            "change_name": bool(change_name),
            "change_avatar": bool(change_avatar),
            "use_vn_pool": self.var_use_vn_pool.get() if hasattr(self, 'var_use_vn_pool') else True,
            "only_new": only_new,
            "names_list": [n.strip() for n in self.names_entry.get().split(',') if n.strip()] if hasattr(self, 'names_entry') else [],
            "avatar_folder": self.avatar_folder_entry.get().strip() if hasattr(self, 'avatar_folder_entry') else '',
            "indices": indices,
        })
        self.log(
            f">>> {log_title or 'ĐỔI PROFILE FACEBOOK'}: {len(indices)} nick (có proxy) | "
            f"tên={change_name} avatar={change_avatar} | "
            f"headless={config['headless']} browser={config['browser']}"
        )
        self._submit_job(
            'UPDATE_PROFILE',
            config=config,
            indices=indices,
            label=log_title or 'Đổi profile FB',
            kind='playwright',
        )

    def start_bulk_rename_new(self):
        """Đổi TÊN profile Facebook (thật) cho nick mới."""
        only_new = self.var_only_new_accounts.get() if hasattr(self, 'var_only_new_accounts') else True
        self._start_profile_job(change_name=True, change_avatar=False, only_new=only_new, log_title='ĐỔI TÊN PROFILE FACEBOOK')

    def start_bulk_avatar_new(self):
        """Đổi AVATAR profile Facebook (thật) cho nick mới."""
        only_new = self.var_only_new_accounts.get() if hasattr(self, 'var_only_new_accounts') else True
        self._start_profile_job(change_name=False, change_avatar=True, only_new=only_new, log_title='ĐỔI AVATAR PROFILE FACEBOOK')

    def start_bulk_profile_new(self):
        """Đổi TÊN + AVATAR profile Facebook (thật)."""
        only_new = self.var_only_new_accounts.get() if hasattr(self, 'var_only_new_accounts') else True
        self._start_profile_job(change_name=True, change_avatar=True, only_new=only_new, log_title='ĐỔI TÊN+AVATAR PROFILE FACEBOOK')

    def start_update_profile(self):
        """Chạy theo các tick: đổi tên / avatar profile Facebook."""
        only_new = self.var_only_new_accounts.get() if hasattr(self, 'var_only_new_accounts') else False
        change_name = self.var_change_name.get() if hasattr(self, 'var_change_name') else True
        change_avatar = self.var_change_avatar.get() if hasattr(self, 'var_change_avatar') else True
        if not change_name and not change_avatar:
            messagebox.showerror("Lỗi", "Hãy tick Đổi TÊN và/hoặc Đổi AVATAR profile Facebook.")
            return
        self._start_profile_job(
            change_name=change_name,
            change_avatar=change_avatar,
            only_new=only_new,
            log_title='CẬP NHẬT PROFILE FACEBOOK',
        )

    def _run_automation(self, task_type, data):
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        self._active_loop = loop
        try:
            with self._active_browsers_lock:
                self._active_browsers.clear()
        except Exception:
            pass
        stopped = False
        err_msg = ''
        try:
            # Khởi động progress tracker
            cfg0 = data if isinstance(data, dict) else {}
            indices0 = list(cfg0.get('indices') or [])
            est = cfg0.get('_est_total_sec')
            if not est and task_type != 'SCENARIO':
                # ước lượng thô task thường: ~2–4 phút/nick
                est = max(60, len(indices0) * 120)
            label = (self.current_job or {}).get('label') or task_type
            self._progress_start(task_type, label, indices0, est_total_sec=est)
            loop.run_until_complete(self._playwright_logic(task_type, data))
            stopped = bool(self.stop_requested) or self._should_stop()
        except JobStopped as e:
            stopped = True
            err_msg = str(e)
            self.log(f"⏹ Đã dừng: {e}")
        except Exception as e:
            err_msg = str(e)
            self.log(f"Lỗi hệ thống: {str(e)}")
        finally:
            self._active_browser = None
            self._active_loop = None
            try:
                with self._active_browsers_lock:
                    self._active_browsers.clear()
            except Exception:
                pass
            try:
                self.save_accounts()
                self.save_scenario_progress()
            except Exception:
                pass
            self._ui_call(self.refresh_table)
            self._ui_call(self.refresh_groups_table)
            self._ui_call(self.refresh_report_table)
            self._ui_call(self.refresh_scenario_table)
            if task_type == 'SCENARIO' and not stopped:
                self._ui_call(self._log_scenario_end_summary)
            self._progress_finish(stopped=stopped)
            self.log("<<< TIẾN TRÌNH KẾT THÚC >>>" + (" (ĐÃ DỪNG)" if stopped else ""))
            try:
                loop.close()
            except Exception:
                pass
            self._finish_current_job(stopped=stopped, message=err_msg or ('Đã dừng' if stopped else 'Hoàn tất'))

    def _log_scenario_end_summary(self):
        done = partial = pending = 0
        lines = []
        for acc in self.accounts:
            info = self.get_account_scenario_status(acc)
            if info['status'] == 'done':
                done += 1
            elif info['status'] == 'partial':
                partial += 1
            else:
                pending += 1
            if info['status'] != 'done':
                lines.append(
                    f"  ○ {info['uid']}: chưa → {', '.join(info['pending_list'][:5]) or info['status']}"
                )
        self.log(f"[SCENARIO] Kết thúc — ✓ Xong: {done} | ◐ Một phần: {partial} | ○ Chưa: {pending}")
        for line in lines[:30]:
            self.log(line)

    def _cookie_header_to_list(self, cookie_str):
        """Parse cookie header → list Playwright (bỏ presence, chuẩn domain)."""
        cookies_list = []
        raw = (cookie_str or '').replace('\n', ' ').replace('\r', ' ').strip()
        if not raw:
            return cookies_list
        for item in raw.split(';'):
            item = item.strip()
            if not item or '=' not in item:
                continue
            k, v = item.split('=', 1)
            name = k.strip()
            value = v.strip()
            if not name:
                continue
            if name.lower() == 'presence':
                continue
            cookies_list.append({
                "name": name,
                "value": value,
                "domain": ".facebook.com",
                "path": "/",
                "secure": True,
            })
        return cookies_list

    async def _inject_cookies(self, page, cookie_str, uid=''):
        """Nạp cookie an toàn: clear → add theo url facebook."""
        cookies = self._cookie_header_to_list(cookie_str)
        if not cookies:
            return 0
        try:
            await page.context.clear_cookies()
        except Exception:
            pass
        # Playwright: add bằng url ổn định hơn domain-only trên mobile
        for c in cookies:
            c = dict(c)
            c.pop('domain', None)
            c['url'] = 'https://www.facebook.com/'
            try:
                await page.context.add_cookies([c])
            except Exception:
                try:
                    c2 = {
                        'name': c['name'], 'value': c['value'],
                        'domain': '.facebook.com', 'path': '/',
                    }
                    await page.context.add_cookies([c2])
                except Exception:
                    pass
        # verify
        try:
            got = await page.context.cookies()
            n = sum(1 for c in got if c.get('name') == 'c_user')
            return len(cookies) if n or cookies else 0
        except Exception:
            return len(cookies)

    async def _export_cookies(self, context):
        try:
            cookies = await context.cookies()
            seen = {}
            for c in cookies:
                if 'facebook.com' in (c.get('domain') or ''):
                    if (c.get('name') or '').lower() == 'presence':
                        continue
                    seen[c['name']] = c['value']
            return ';'.join(f"{k}={v}" for k, v in seen.items())
        except Exception:
            return ''

    async def _dismiss_fb_popups(self, page):
        """Đóng dialog cookie / Hủy / Allow che nút login."""
        dismiss_sels = (
            'button.cancelButton',
            'button[data-sigil="dialog-cancel-button"]',
            'a[data-sigil="dialog-cancel-button"]',
            'button:has-text("Hủy")',
            'button:has-text("Cancel")',
            'button:has-text("Not Now")',
            'button:has-text("Để sau")',
            'button:has-text("Accept")',
            'button:has-text("Allow")',
            'button:has-text("Chấp nhận")',
            'button:has-text("OK")',
            'div[role="dialog"] button:has-text("Close")',
            '[aria-label="Close"]',
            '[aria-label="Đóng"]',
        )
        for sel in dismiss_sels:
            try:
                loc = page.locator(sel)
                n = await loc.count()
                for i in range(min(n, 3)):
                    el = loc.nth(i)
                    try:
                        if await el.is_visible(timeout=300):
                            await el.click(timeout=1500, force=True)
                            await asyncio.sleep(0.35)
                    except Exception:
                        continue
            except Exception:
                continue

    async def _has_login_form(self, page):
        try:
            email_n = await page.locator('input[name="email"], input#email').count()
            pass_n = await page.locator('input[name="pass"], input#pass').count()
            return email_n > 0 and pass_n > 0
        except Exception:
            return False

    async def _is_logged_in(self, page):
        """
        LIVE/DIE/CHECKPOINT — không quét full HTML JS (chữ checkpoint trong bundle gây sai).
        """
        try:
            url = (page.url or '')
            url_l = url.lower()
            if any(x in url_l for x in (
                '/checkpoint', 'checkpoint/', 'two_step', 'security_checkup',
                'confirmemail', '/disabled', 'account_status',
            )):
                return 'CHECKPOINT'

            cookies = await page.context.cookies()
            c_users = [c for c in cookies if c.get('name') == 'c_user' and (c.get('value') or '').strip()]
            has_user = bool(c_users)
            has_xs = any(c.get('name') == 'xs' and (c.get('value') or '').strip() for c in cookies)

            try:
                body_text = (await page.locator('body').inner_text(timeout=4000) or '')[:4000].lower()
            except Exception:
                body_text = ''
            if any(s in body_text for s in (
                'confirm your identity', 'we suspended your account',
                'your account has been disabled', 'upload a photo of your id',
                'xác nhận danh tính', 'tài khoản của bạn đã bị vô hiệu',
            )):
                return 'CHECKPOINT'

            has_login = await self._has_login_form(page)

            if has_login and not has_user:
                return 'DIE'
            if has_login and any(x in url_l for x in ('/login', 'login.php', 'identify', 'recover')):
                return 'DIE'

            if has_user and not has_login:
                return 'LIVE'
            if has_user and has_xs:
                return 'LIVE'

            for sel in (
                'div[role="feed"]',
                'div[role="navigation"]',
                '[aria-label="Account"]',
                '[aria-label="Your profile"]',
            ):
                try:
                    if await page.locator(sel).count() > 0 and has_user:
                        return 'LIVE'
                except Exception:
                    pass

            if has_login:
                return 'DIE'
            if has_user:
                return 'LIVE'
            return 'UNKNOWN'
        except Exception:
            return 'UNKNOWN'

    async def _click_login_submit(self, page):
        """
        Bấm đúng nút Đăng nhập — TRÁNH button Hủy (cancelButton type=submit).
        Bug cũ: selector button[type=submit] match nút Hủy → timeout 30s.
        """
        await self._dismiss_fb_popups(page)
        candidates = [
            'button[name="login"]',
            'button#loginbutton',
            'input[name="login"]',
            'button[type="submit"][name="login"]',
            'button[data-testid="royal-login-button"]',
            'button:has-text("Log in")',
            'button:has-text("Log In")',
            'button:has-text("Đăng nhập")',
            'form[method="post"] button[type="submit"]:not(.cancelButton)',
        ]
        for sel in candidates:
            try:
                loc = page.locator(sel)
                n = await loc.count()
                for i in range(n):
                    el = loc.nth(i)
                    try:
                        if not await el.is_visible(timeout=400):
                            continue
                        txt = ((await el.inner_text()) or '').strip().lower()
                        val = (await el.get_attribute('value') or '').strip().lower()
                        cls = (await el.get_attribute('class') or '').lower()
                        if any(x in txt or x in val for x in ('hủy', 'huy', 'cancel', 'not now')):
                            continue
                        if 'cancel' in cls:
                            continue
                        await el.click(timeout=8000)
                        return True
                    except Exception:
                        continue
            except Exception:
                continue
        try:
            await page.locator('input[name="pass"], input#pass').first.press('Enter')
            return True
        except Exception:
            pass
        return False

    async def _login_account(self, page, acc, force=False, preserve_session=True):
        """
        Đăng nhập / làm mới session.
        - preserve_session=True (mặc định): KHÔNG xóa cookie đang sống; LIVE → chỉ refresh.
        - force=True chỉ ép login pass khi cookie KHÔNG LIVE.
        - Bug cũ: force luôn True vì `force or task_type==LOGIN` + clear cookie → hỏng session.
        """
        uid = acc.get('uid', '')
        self.log(
            f"[{uid}] Làm mới session... "
            f"(giữ_session={bool(preserve_session)} | ép_pass_nếu_chết={bool(force)})"
        )

        # --- Phase A: thử cookie (luôn, trừ khi không có cookie) ---
        status = 'UNKNOWN'
        if acc.get('cookie'):
            n = await self._inject_cookies(page, acc.get('cookie'), uid=uid)
            self.log(f"[{uid}] Đã nạp {n} cookie vào browser")
            try:
                await page.goto("https://www.facebook.com/", timeout=55000, wait_until="domcontentloaded")
                await asyncio.sleep(2.5)
                await self._dismiss_fb_popups(page)
            except Exception as ex:
                self.add_report(uid, 'LOGIN', 'facebook.com', 'FAIL', f'Không mở được FB: {ex}')
                return 'ERROR'

            status = await self._is_logged_in(page)
            if status == 'LIVE':
                # Xác nhận /me + refresh cookie — không đụng password
                try:
                    await page.goto("https://www.facebook.com/me", timeout=40000, wait_until="domcontentloaded")
                    await asyncio.sleep(1.5)
                    await self._dismiss_fb_popups(page)
                    st2 = await self._is_logged_in(page)
                    if st2 == 'LIVE':
                        status = 'LIVE'
                    elif st2 in ('CHECKPOINT', 'DIE'):
                        status = st2
                except Exception:
                    pass
                if status == 'LIVE':
                    cookie = await self._export_cookies(page.context)
                    if cookie and 'c_user=' in cookie:
                        acc['cookie'] = cookie
                    acc['status'] = 'LIVE'
                    acc['login_status'] = 'Session cookie còn sống (đã làm mới)'
                    if acc.get('pool') == 'dead' or acc.get('is_dead'):
                        acc['pool'] = ''
                        acc['is_dead'] = False
                        acc['quarantine'] = False
                    self.add_report(uid, 'LOGIN', 'cookie', 'SUCCESS', 'Session cookie còn sống')
                    if force:
                        self.log(f"[{uid}] ✓ Session LIVE — cookie còn tốt, bỏ qua ép login pass (an toàn)")
                    else:
                        self.log(f"[{uid}] ✓ Session LIVE — đã refresh cookie")
                    return 'LIVE'

            if status == 'CHECKPOINT':
                acc['status'] = 'CHECKPOINT'
                acc['login_status'] = 'Checkpoint'
                self.add_report(uid, 'LOGIN', 'checkpoint', 'FAIL', 'Dính checkpoint')
                try:
                    self.move_account_to_dead(
                        acc,
                        reason='CHECKPOINT khi login (không cascade proxy)',
                        quarantine_related=False,
                    )
                except Exception:
                    pass
                self.log(f"[{uid}] ⚠ CHECKPOINT login — chỉ nick này vào Acc chết")
                return 'CHECKPOINT'
        else:
            self.log(f"[{uid}] Không có cookie — sẽ login mật khẩu")
            status = 'NO COOKIE'
            try:
                await page.goto("https://www.facebook.com/login", timeout=55000, wait_until="domcontentloaded")
                await asyncio.sleep(1.5)
            except Exception as ex:
                self.add_report(uid, 'LOGIN', 'facebook.com', 'FAIL', f'Không mở được FB: {ex}')
                return 'ERROR'

        # --- Phase B: password (chỉ khi cookie không LIVE) ---
        password = acc.get('password') or ''
        login_id = acc.get('email') or uid
        if 'Device=' in str(login_id) or len(str(login_id)) > 80:
            login_id = uid
        if not password:
            acc['status'] = 'DIE?' if status != 'LIVE' else 'LIVE'
            acc['login_status'] = 'Cookie không LIVE + thiếu mật khẩu'
            self.add_report(uid, 'LOGIN', 'password', 'WARN', 'Không có mật khẩu để login — giữ nick')
            self.log(f"[{uid}] ⚠ Cookie không LIVE và thiếu pass — giữ nick")
            return 'DIE?'

        self.log(f"[{uid}] Cookie không LIVE (status={status}) — đăng nhập mật khẩu...")
        try:
            # Chỉ clear cookie khi: không preserve HOẶC đã chắc cookie chết (DIE/NO COOKIE)
            # Giữ session mode: không clear — login form sẽ ghi đè session mới
            if not preserve_session or status in ('DIE', 'NO COOKIE', 'DIE?', 'UNKNOWN'):
                if not preserve_session:
                    try:
                        await page.context.clear_cookies()
                        self.log(f"[{uid}] Đã clear cookie (preserve_session=False)")
                    except Exception:
                        pass
                # preserve + cookie chết: vẫn không clear thô — add empty login clean
                elif status in ('DIE', 'NO COOKIE'):
                    try:
                        await page.context.clear_cookies()
                    except Exception:
                        pass
            await page.goto("https://www.facebook.com/login", timeout=55000, wait_until="domcontentloaded")
            await asyncio.sleep(2)
            await self._dismiss_fb_popups(page)

            email_sel = 'input[name="email"], input#email'
            pass_sel = 'input[name="pass"], input#pass'
            try:
                await page.wait_for_selector(email_sel, timeout=12000)
            except Exception:
                await self._dismiss_fb_popups(page)
                await page.wait_for_selector(email_sel, timeout=8000)

            await page.fill(email_sel, str(login_id))
            await page.fill(pass_sel, password)
            await asyncio.sleep(0.3)
            clicked = await self._click_login_submit(page)
            if not clicked:
                self.log(f"[{uid}] ⚠ Không bấm được nút login — đã thử Enter")
            await asyncio.sleep(4)
            await self._dismiss_fb_popups(page)

            url = (page.url or '').lower()
            try:
                body_text = (await page.locator('body').inner_text(timeout=3000) or '')[:3000].lower()
            except Exception:
                body_text = ''
            need_2fa = any(x in url or x in body_text for x in (
                'two_factor', 'two-factor', 'approvals', 'authentication',
                'login code', 'mã đăng nhập', 'nhập mã',
            ))

            if need_2fa or 'approvals' in url:
                code = self.generate_totp(acc.get('two_fa', ''))
                if code:
                    self.log(f"[{uid}] Nhập mã 2FA: {code}")
                    filled = False
                    for sel in (
                        'input[name="approvals_code"]',
                        'input#approvals_code',
                        'input[name="code"]',
                        'input[autocomplete="one-time-code"]',
                        'input[type="tel"]',
                        'input[type="text"]',
                    ):
                        try:
                            el = page.locator(sel).first
                            if await el.count() > 0 and await el.is_visible(timeout=500):
                                await el.fill(code)
                                filled = True
                                break
                        except Exception:
                            continue
                    if filled:
                        for btn_sel in (
                            'button[name="submit"]',
                            'button[type="submit"]:not(.cancelButton)',
                            'button:has-text("Continue")',
                            'button:has-text("Tiếp")',
                            'button:has-text("Confirm")',
                            'button:has-text("Submit")',
                        ):
                            try:
                                btn = page.locator(btn_sel).first
                                if await btn.count() > 0 and await btn.is_visible(timeout=500):
                                    txt = ((await btn.inner_text()) or '').lower()
                                    if 'hủy' in txt or 'cancel' in txt:
                                        continue
                                    await btn.click(timeout=5000)
                                    break
                            except Exception:
                                continue
                    await asyncio.sleep(4)
                else:
                    self.add_report(uid, 'LOGIN', '2FA', 'FAIL', 'Cần 2FA nhưng không tạo được mã')
                    acc['status'] = '2FA_REQUIRED'
                    acc['login_status'] = 'Cần 2FA — bổ sung secret'
                    self.log(f"[{uid}] ⚠ Cần 2FA nhưng không có secret — giữ nick")
                    return '2FA_REQUIRED'

            for btn_sel in (
                'button:has-text("Trust")',
                'button:has-text("This was me")',
                'button:has-text("Continue")',
                'button:has-text("OK")',
                'button:has-text("Tiếp tục")',
            ):
                try:
                    btn = page.locator(btn_sel).first
                    if await btn.count() > 0 and await btn.is_visible(timeout=400):
                        await btn.click(timeout=3000)
                        await asyncio.sleep(1.2)
                except Exception:
                    pass

            # Chờ redirect sau login/2FA
            await asyncio.sleep(2)
            for _ in range(3):
                await self._dismiss_fb_popups(page)
                st_mid = await self._is_logged_in(page)
                if st_mid == 'LIVE':
                    break
                url_m = (page.url or '').lower()
                if 'login' not in url_m and 'checkpoint' not in url_m:
                    break
                await asyncio.sleep(1.5)

            try:
                await page.goto("https://www.facebook.com/", timeout=55000, wait_until="domcontentloaded")
            except Exception:
                pass
            await asyncio.sleep(2.5)
            await self._dismiss_fb_popups(page)
            status = await self._is_logged_in(page)
            if status != 'LIVE':
                try:
                    await page.goto("https://www.facebook.com/me", timeout=40000, wait_until="domcontentloaded")
                    await asyncio.sleep(2)
                    status = await self._is_logged_in(page)
                except Exception:
                    pass

            if status == 'LIVE':
                cookie = await self._export_cookies(page.context)
                if cookie and 'c_user=' in cookie:
                    acc['cookie'] = cookie
                acc['status'] = 'LIVE'
                acc['login_status'] = 'Đăng nhập thành công — đã làm mới cookie'
                if acc.get('pool') == 'dead' or acc.get('is_dead'):
                    acc['pool'] = ''
                    acc['is_dead'] = False
                self.add_report(uid, 'LOGIN', 'password+2FA', 'SUCCESS', 'Đăng nhập OK, đã lưu cookie')
                self.log(f"[{uid}] ✓ Login pass OK — cookie mới đã lưu")
                return 'LIVE'

            # Log URL để debug
            self.log(f"[{uid}] Sau pass/2FA: status={status} url={(page.url or '')[:100]}")
            acc['status'] = status if status not in ('DIE',) else 'DIE?'
            acc['login_status'] = f'Login fail: {status} @ {(page.url or "")[:60]}'
            self.add_report(uid, 'LOGIN', 'password', 'FAIL', f'Kết quả: {status}')
            if status == 'CHECKPOINT':
                try:
                    self.move_account_to_dead(
                        acc,
                        reason='CHECKPOINT sau login mật khẩu (không cascade)',
                        quarantine_related=False,
                    )
                except Exception:
                    pass
            elif status == 'DIE':
                self.log(f"[{uid}] Login pass chưa thành công — giữ nick, không Acc chết vội")
                acc['status'] = 'DIE?'
                return 'DIE?'
            else:
                self.log(f"[{uid}] Login kết quả {status} — giữ nick")
            return status
        except Exception as ex:
            acc['login_status'] = f'Lỗi: {ex}'
            self.add_report(uid, 'LOGIN', 'password', 'FAIL', str(ex))
            self.log(f"[{uid}] Lỗi login: {ex}")
            return 'ERROR'

    async def _task_join_groups(self, page, acc, config):
        uid = acc.get('uid', '')
        keywords = [k.strip() for k in (config.get('keywords') or '').split(',') if k.strip()]
        limit = int(config.get('limit') or 3)
        mode = config.get('mode') or 'medium'
        delay = config.get('delay') or self.get_preset(mode)['delay']
        remain, day_limit, used = self.remaining_quota(uid, 'join', mode)
        if remain <= 0:
            self.add_report(uid, 'JOIN_GROUP', 'quota', 'FAIL', f'Hết quota join hôm nay ({used}/{day_limit})')
            self.log(f"[{uid}] DỪNG join — hết quota {used}/{day_limit}")
            return
        self.log(f"[{uid}] Quota join còn {remain}/{day_limit} (mode={mode})")
        acc.setdefault('joined_groups', [])
        joined_urls = {g.get('url') for g in acc['joined_groups']}
        total_joined_session = 0

        for kw in keywords:
            remain, day_limit, used = self.remaining_quota(uid, 'join', mode)
            if remain <= 0:
                self.log(f"[{uid}] Hết quota join — dừng các từ khóa còn lại")
                break
            self.log(f"[{uid}] Tìm nhóm theo từ khóa: {kw}")
            search_url = f"https://mbasic.facebook.com/search/groups/?q={urllib.parse.quote(kw)}"
            try:
                await page.goto(search_url, timeout=45000, wait_until="domcontentloaded")
                await asyncio.sleep(2)
            except Exception as ex:
                self.add_report(uid, 'JOIN_GROUP', kw, 'FAIL', f'Lỗi search: {ex}')
                continue

            # Collect group links
            links = await page.query_selector_all('a[href*="/groups/"]')
            candidates = []
            for a in links:
                try:
                    href = await a.get_attribute('href') or ''
                    name = (await a.inner_text() or '').strip()
                    if not href or not name:
                        continue
                    if href.startswith('/'):
                        href = 'https://mbasic.facebook.com' + href
                    # normalize
                    m = re.search(r'/groups/(\d+)', href)
                    if not m:
                        continue
                    gid = m.group(1)
                    url = f'https://www.facebook.com/groups/{gid}'
                    if url in joined_urls:
                        continue
                    candidates.append({'id': gid, 'name': name[:80], 'url': url, 'href': href})
                except Exception:
                    continue

            # unique by id
            seen = set()
            uniq = []
            for c in candidates:
                if c['id'] in seen:
                    continue
                seen.add(c['id'])
                uniq.append(c)

            count = 0
            for g in uniq:
                remain, day_limit, used = self.remaining_quota(uid, 'join', mode)
                if remain <= 0 or count >= limit:
                    break
                self.log(f"[{uid}] Join: {g['name']} ({g['id']}) | quota còn {remain}")
                try:
                    await page.goto(f"https://mbasic.facebook.com/groups/{g['id']}", timeout=45000, wait_until="domcontentloaded")
                    await asyncio.sleep(2)
                    body = (await page.content()).lower()
                    status = 'FAIL'
                    msg = ''
                    # already member
                    if any(x in body for x in ('leave group', 'rời nhóm', 'rời khỏi nhóm', 'member')) and 'join' not in body[:3000]:
                        status = 'JOINED'
                        msg = 'Đã là thành viên'
                    else:
                        clicked = False
                        for sel in (
                            'input[value*="Join"]',
                            'input[value*="Tham gia"]',
                            'a[href*="join"]',
                            'form[action*="join"] input[type="submit"]',
                        ):
                            el = await page.query_selector(sel)
                            if el:
                                await el.click()
                                clicked = True
                                break
                        if not clicked:
                            # try text buttons via evaluate
                            clicked = await page.evaluate('''() => {
                                const els = [...document.querySelectorAll('a, input, button')];
                                for (const e of els) {
                                    const t = (e.value || e.innerText || '').toLowerCase();
                                    if (t.includes('join') || t.includes('tham gia')) { e.click(); return true; }
                                }
                                return false;
                            }''')
                        await asyncio.sleep(2.5)
                        body2 = (await page.content()).lower()
                        url2 = (page.url or '').lower()
                        if 'pending' in body2 or 'chờ' in body2 or 'request' in body2:
                            status = 'PENDING'
                            msg = 'Đã gửi yêu cầu (chờ duyệt)'
                        elif clicked or any(x in body2 for x in ('leave group', 'rời nhóm', 'member')):
                            status = 'SUCCESS'
                            msg = 'Join thành công / đã vào nhóm'
                        else:
                            status = 'FAIL'
                            msg = 'Không tìm thấy nút Join hoặc bị chặn'
                        if 'checkpoint' in url2:
                            status = 'FAIL'
                            msg = 'Checkpoint khi join'

                    record = {
                        'id': g['id'],
                        'name': g['name'],
                        'url': g['url'],
                        'status': status,
                        'joined_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                        'keyword': kw,
                    }
                    # update or append
                    existing = next((x for x in acc['joined_groups'] if x.get('id') == g['id']), None)
                    if existing:
                        existing.update(record)
                    else:
                        acc['joined_groups'].append(record)
                    joined_urls.add(g['url'])
                    self.add_report(uid, 'JOIN_GROUP', g['name'], status, msg, g['url'])
                    count += 1
                    if status in ('SUCCESS', 'PENDING', 'JOINED'):
                        self.inc_usage(uid, 'join', 1)
                        total_joined_session += 1
                    d0, d1 = delay[0], delay[1]
                    await asyncio.sleep(random.uniform(d0, d1))
                except Exception as ex:
                    self.add_report(uid, 'JOIN_GROUP', g.get('name', ''), 'FAIL', str(ex), g.get('url', ''))
        self.log(f"[{uid}] Join session xong: +{total_joined_session} | quota còn {self.remaining_quota(uid, 'join', mode)[0]}")

    async def _task_post(self, page, acc, config):
        uid = acc.get('uid', '')
        mode = config.get('mode') or 'medium'
        delay = config.get('delay') or self.get_preset(mode)['delay']
        remain, day_limit, used = self.remaining_quota(uid, 'post', mode)
        if remain <= 0:
            self.add_report(uid, 'POST', 'quota', 'FAIL', f'Hết quota đăng bài hôm nay ({used}/{day_limit})')
            return

        raw = config.get('content') or ''
        link = config.get('link') or ''
        image = config.get('image') or ''
        text = self.spin_text(raw)
        if link and link not in text:
            text = text.rstrip() + '\n' + link

        targets = []
        if config.get('post_wall'):
            targets.append({'type': 'wall', 'name': 'Timeline (tường)', 'url': 'https://mbasic.facebook.com/'})
        if config.get('post_groups'):
            limit = int(config.get('group_limit') or 2)
            groups = [g for g in (acc.get('joined_groups') or []) if str(g.get('status', '')).upper() in ('SUCCESS', 'JOINED', 'PENDING', 'OK')]
            for g in groups[:limit]:
                targets.append({
                    'type': 'group',
                    'name': g.get('name', g.get('id', 'group')),
                    'url': f"https://mbasic.facebook.com/groups/{g.get('id')}",
                    'id': g.get('id'),
                })

        # cắt targets theo quota còn lại
        targets = targets[:remain]
        acc.setdefault('posts', [])
        for t in targets:
            remain, day_limit, used = self.remaining_quota(uid, 'post', mode)
            if remain <= 0:
                self.log(f"[{uid}] Hết quota post — dừng")
                break
            self.log(f"[{uid}] Đăng bài → {t['type']}: {t['name']} | quota còn {remain}")
            try:
                ok, post_link, msg = await self._post_to_target(page, text, image, t)
                status = 'SUCCESS' if ok else 'FAIL'
                rec = {
                    'time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                    'target': t['name'],
                    'type': t['type'],
                    'status': status,
                    'link': post_link or '',
                    'message': msg,
                }
                acc['posts'].append(rec)
                self.add_report(uid, f"POST_{t['type'].upper()}", t['name'], status, msg, post_link or '')
                if ok:
                    self.inc_usage(uid, 'post', 1)
                await asyncio.sleep(random.uniform(delay[0], delay[1]))
            except Exception as ex:
                self.add_report(uid, f"POST_{t['type'].upper()}", t['name'], 'FAIL', str(ex))

    async def _post_to_target(self, page, text, image_path, target):
        """Đăng text (+ảnh) lên wall hoặc group qua mbasic. Trả (ok, link, msg)."""
        try:
            if target['type'] == 'wall':
                await page.goto('https://mbasic.facebook.com/composer/?text=', timeout=45000, wait_until='domcontentloaded')
            else:
                gid = target.get('id')
                await page.goto(f'https://mbasic.facebook.com/groups/{gid}', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)

            # find textarea
            area = await page.query_selector('textarea[name="xc_message"], textarea[name="body"], textarea')
            if not area:
                # try open composer
                comp = await page.query_selector('a[href*="composer"], a[href*="/mbasic/composer"]')
                if comp:
                    await comp.click()
                    await asyncio.sleep(2)
                    area = await page.query_selector('textarea[name="xc_message"], textarea[name="body"], textarea')
            if not area:
                return False, '', 'Không tìm thấy ô soạn thảo'

            await area.fill(text)

            # image upload if available
            if image_path and os.path.isfile(image_path):
                file_input = await page.query_selector('input[type="file"]')
                if file_input:
                    await file_input.set_input_files(image_path)
                    await asyncio.sleep(2)
                else:
                    self.log(f"Không thấy input file — bỏ qua ảnh trên target {target['name']}")

            # submit
            submitted = False
            for sel in (
                'input[name="view_post"]',
                'input[type="submit"]',
                'button[type="submit"]',
                'input[value*="Post"]',
                'input[value*="Đăng"]',
            ):
                btn = await page.query_selector(sel)
                if btn:
                    await btn.click()
                    submitted = True
                    break
            if not submitted:
                submitted = await page.evaluate('''() => {
                    const els = [...document.querySelectorAll('input, button')];
                    for (const e of els) {
                        const t = (e.value || e.innerText || '').toLowerCase();
                        if (t.includes('post') || t.includes('đăng') || t.includes('share')) { e.click(); return true; }
                    }
                    return false;
                }''')
            await asyncio.sleep(3)

            url = page.url or ''
            content = await page.content()
            # try find story link
            post_link = ''
            m = re.search(r'href="(https?://[^"]*(?:story\.php|posts/|permalink\.php)[^"]*)"', content)
            if m:
                post_link = m.group(1).replace('&amp;', '&')
            if not post_link:
                m2 = re.search(r'href="(/story\.php[^"]+)"', content)
                if m2:
                    post_link = 'https://mbasic.facebook.com' + m2.group(1).replace('&amp;', '&')
            if not post_link and target['type'] == 'group' and target.get('id'):
                post_link = f"https://www.facebook.com/groups/{target['id']}"
            if not post_link and target['type'] == 'wall':
                post_link = 'https://www.facebook.com/me'

            if submitted and 'login' not in url.lower():
                return True, post_link, 'Đăng thành công' + (f' | link: {post_link}' if post_link else '')
            return False, '', 'Gửi bài không xác nhận được (có thể fail UI FB)'
        except Exception as ex:
            return False, '', str(ex)

    async def _task_nurture(self, page, acc, config):
        uid = acc.get('uid', '')
        try:
            await page.goto('https://www.facebook.com/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            if config.get('read_post', True):
                scroll_range = config.get('scroll_sec') or SAFE_SCROLL_SHORT
                await self._action_scroll_feed(page, acc, scroll_range)
            if config.get('auto_like', True):
                like_n = int(config.get('like_count') or random.randint(2, 3))
                await self._action_like_feed(page, acc, like_n)
            if config.get('read_noti', True):
                await self._action_read_notifications(page, acc)
            if config.get('accept_friend'):
                await self._action_friend_accept(page, acc, max_n=5)
            if config.get('add_suggested'):
                await self._action_friend_suggest(page, acc, max_n=3)
            if config.get('birthday'):
                await self._action_birthday_wish(
                    page, acc,
                    message=config.get('birthday_text') or DEFAULT_BIRTHDAY_SPIN,
                    max_n=int(config.get('birthday_max') or 5),
                )
            self.add_report(uid, 'NURTURE', 'newsfeed', 'SUCCESS', 'Đã lướt / tương tác newsfeed')
        except Exception as ex:
            self.add_report(uid, 'NURTURE', 'newsfeed', 'FAIL', str(ex))

    # ------------------------------------------------------------------
    # Tương tác: sinh nhật / bạn bè / group phụ / fanpage
    # ------------------------------------------------------------------
    async def _click_text_buttons(self, page, keywords, max_clicks=1):
        """Click phần tử a/button/input có text khớp keywords (mbasic-friendly)."""
        kws = [k.lower() for k in keywords]
        clicked = 0
        try:
            n = await page.evaluate(
                '''(kws, maxC) => {
                    let c = 0;
                    const els = [...document.querySelectorAll('a, button, input[type="submit"], input[type="button"]')];
                    for (const e of els) {
                        if (c >= maxC) break;
                        const t = ((e.value || e.innerText || e.getAttribute('aria-label') || '') + '').toLowerCase().trim();
                        if (!t) continue;
                        if (kws.some(k => t.includes(k) || t === k)) {
                            try { e.click(); c++; } catch (err) {}
                        }
                    }
                    return c;
                }''',
                kws,
                max_clicks,
            )
            clicked = int(n or 0)
        except Exception:
            pass
        return clicked

    async def _action_birthday_wish(self, page, acc, message='', max_n=8):
        """Chúc sinh nhật qua mbasic birthdays."""
        uid = acc.get('uid', '')
        max_n = max(1, min(20, int(max_n or 8)))
        done = 0
        urls = [
            'https://mbasic.facebook.com/events/calendar/birthdays/',
            'https://mbasic.facebook.com/browse/birthdays/',
            'https://mbasic.facebook.com/friends/center/birthdays/',
            'https://www.facebook.com/events/birthdays/',
        ]
        try:
            opened = False
            for url in urls:
                try:
                    await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                    await asyncio.sleep(2.2)
                    # có form chúc?
                    has = await page.query_selector('textarea, input[name="message"], form')
                    if has or 'birthday' in (page.url or '').lower():
                        opened = True
                        break
                except Exception:
                    continue
            if not opened:
                self.log(f"[{uid}] Không mở được trang sinh nhật")
                self.add_report(uid, 'FRIEND_BIRTHDAY', '-', 'FAIL', 'Không mở trang SN')
                return 0

            for i in range(max_n):
                text = self.spin_text(message or DEFAULT_BIRTHDAY_SPIN)
                filled = False
                for sel in (
                    'textarea[name="message"]',
                    'textarea[name="birthday_message"]',
                    'textarea',
                    'input[name="message"]',
                ):
                    try:
                        el = await page.query_selector(sel)
                        if el and await el.is_visible():
                            await el.fill(text)
                            filled = True
                            break
                    except Exception:
                        continue
                if not filled:
                    # hết form
                    break
                # submit
                sub = await self._click_text_buttons(
                    page,
                    ['post', 'đăng', 'share', 'gửi', 'send', 'chúc'],
                    max_clicks=1,
                )
                if not sub:
                    try:
                        btn = await page.query_selector('input[type="submit"], button[type="submit"]')
                        if btn:
                            await btn.click()
                            sub = 1
                    except Exception:
                        pass
                if sub:
                    done += 1
                    self.inc_usage(uid, 'comment', 1)
                    self.add_report(uid, 'FRIEND_BIRTHDAY', text[:40], 'SUCCESS', f'Chúc SN #{done}')
                    await self._safe_sleep(SAFE_ACTION_DELAY, 'sau chúc SN')
                else:
                    break
                # reload list
                try:
                    await page.reload(wait_until='domcontentloaded')
                    await asyncio.sleep(1.5)
                except Exception:
                    break

            if done:
                self.log(f"[{uid}] Đã chúc sinh nhật {done} người")
            else:
                self.log(f"[{uid}] Không chúc được SN (hết / UI đổi)")
            return done
        except Exception as ex:
            self.add_report(uid, 'FRIEND_BIRTHDAY', '-', 'FAIL', str(ex))
            return 0

    async def _action_friend_search_add(self, page, acc, query, max_n=3):
        """Tìm theo tên/UID và gửi kết bạn."""
        uid = acc.get('uid', '')
        remain, limit, used = self.remaining_scenario_quota(uid, 'friend')
        if remain <= 0:
            self.log(f"[{uid}] Hết quota friend ({used}/{limit})")
            return 0
        max_n = min(max_n, remain)
        sent = 0
        q = (query or '').strip()
        if not q:
            return 0
        try:
            # UID số thuần → profile trực tiếp
            if re.fullmatch(r'\d{5,}', q):
                targets = [
                    f'https://mbasic.facebook.com/profile.php?id={q}',
                    f'https://mbasic.facebook.com/{q}',
                ]
            elif 'facebook.com' in q:
                targets = [q.replace('www.facebook.com', 'mbasic.facebook.com')]
            else:
                from urllib.parse import quote
                targets = [
                    f'https://mbasic.facebook.com/search/people/?q={quote(q)}',
                    f'https://mbasic.facebook.com/search/?q={quote(q)}&search=people',
                ]
            for url in targets:
                try:
                    await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                    await asyncio.sleep(2)
                except Exception:
                    continue
                for _ in range(max_n - sent):
                    n = await self._click_text_buttons(
                        page,
                        ['add friend', 'thêm bạn', 'kết bạn', 'add'],
                        max_clicks=1,
                    )
                    if not n:
                        break
                    sent += 1
                    self.inc_usage(uid, 'friend', 1)
                    await self._safe_sleep(SAFE_ACTION_DELAY, 'sau kết bạn search')
                if sent >= max_n:
                    break
            if sent:
                self.add_report(uid, 'FRIEND_SEARCH', q[:40], 'SUCCESS', f'Gửi {sent} lời mời')
            else:
                self.add_report(uid, 'FRIEND_SEARCH', q[:40], 'FAIL', 'Không tìm/gửi được')
            return sent
        except Exception as ex:
            self.add_report(uid, 'FRIEND_SEARCH', q[:40], 'FAIL', str(ex))
            return 0

    async def _action_friend_poke(self, page, acc, max_n=3):
        uid = acc.get('uid', '')
        done = 0
        try:
            await page.goto('https://mbasic.facebook.com/pokes/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            for _ in range(max_n):
                n = await self._click_text_buttons(page, ['poke', 'chọc'], max_clicks=1)
                if not n:
                    break
                done += 1
                await self._safe_sleep(SAFE_ACTION_DELAY, 'sau poke')
            if done:
                self.add_report(uid, 'FRIEND_POKE', f'{done}', 'SUCCESS', f'Poke {done}')
            else:
                self.log(f"[{uid}] Không poke được (hết gợi ý / UI)")
            return done
        except Exception as ex:
            self.add_report(uid, 'FRIEND_POKE', '-', 'FAIL', str(ex))
            return 0

    async def _action_group_invite(self, page, acc, max_n=3):
        """Mời bạn vào 1 group đã join (mbasic)."""
        uid = acc.get('uid', '')
        groups = [g for g in (acc.get('joined_groups') or []) if g.get('id')]
        if not groups:
            self.add_report(uid, 'GROUP_INVITE', '-', 'FAIL', 'Chưa có group đã join')
            return 0
        g = random.choice(groups)
        gid = g.get('id')
        invited = 0
        try:
            urls = [
                f'https://mbasic.facebook.com/groups/members/search/?group_id={gid}',
                f'https://mbasic.facebook.com/groups/{gid}/members',
                f'https://mbasic.facebook.com/groups/{gid}',
            ]
            for url in urls:
                try:
                    await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                    await asyncio.sleep(2)
                except Exception:
                    continue
                for _ in range(max_n):
                    n = await self._click_text_buttons(
                        page,
                        ['invite', 'mời', 'add to group', 'thêm vào nhóm'],
                        max_clicks=1,
                    )
                    if not n:
                        break
                    invited += 1
                    await self._safe_sleep(SAFE_ACTION_DELAY, 'sau mời group')
                if invited:
                    break
            if invited:
                self.add_report(uid, 'GROUP_INVITE', g.get('name', gid), 'SUCCESS', f'Mời {invited}')
            else:
                self.add_report(uid, 'GROUP_INVITE', g.get('name', gid), 'FAIL', 'Không mời được (UI/quyền)')
            return invited
        except Exception as ex:
            self.add_report(uid, 'GROUP_INVITE', str(gid), 'FAIL', str(ex))
            return 0

    async def _action_group_leave(self, page, acc, max_n=1):
        uid = acc.get('uid', '')
        groups = [g for g in (acc.get('joined_groups') or []) if g.get('id')]
        if not groups:
            self.add_report(uid, 'GROUP_LEAVE', '-', 'FAIL', 'Không có group để rời')
            return 0
        random.shuffle(groups)
        left = 0
        for g in groups[:max_n]:
            gid = g.get('id')
            try:
                await page.goto(
                    f'https://mbasic.facebook.com/group/leave/?group_id={gid}',
                    timeout=45000,
                    wait_until='domcontentloaded',
                )
                await asyncio.sleep(2)
                n = await self._click_text_buttons(
                    page,
                    ['leave', 'rời', 'confirm', 'xác nhận', 'leave group'],
                    max_clicks=2,
                )
                if n:
                    left += 1
                    # gỡ khỏi local list
                    acc['joined_groups'] = [
                        x for x in (acc.get('joined_groups') or [])
                        if str(x.get('id')) != str(gid)
                    ]
                    self.add_report(uid, 'GROUP_LEAVE', g.get('name', gid), 'SUCCESS', 'Đã rời group')
                else:
                    self.add_report(uid, 'GROUP_LEAVE', g.get('name', gid), 'FAIL', 'Không xác nhận rời')
                await self._safe_sleep(SAFE_ACTION_DELAY, 'sau rời group')
            except Exception as ex:
                self.add_report(uid, 'GROUP_LEAVE', str(gid), 'FAIL', str(ex))
        return left

    async def _action_page_like(self, page, acc, targets, max_n=5):
        """Like/Follow fanpage theo tên hoặc URL."""
        uid = acc.get('uid', '')
        items = [t.strip() for t in re.split(r'[,;\n]+', targets or '') if t.strip()]
        if not items:
            return 0
        liked = 0
        from urllib.parse import quote
        for t in items[:max_n]:
            try:
                if 'facebook.com' in t:
                    url = t.replace('www.facebook.com', 'mbasic.facebook.com')
                elif re.fullmatch(r'\d{5,}', t):
                    url = f'https://mbasic.facebook.com/{t}'
                else:
                    url = f'https://mbasic.facebook.com/search/pages/?q={quote(t)}'
                await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                await asyncio.sleep(2)
                # nếu search: vào page đầu
                if 'search' in (page.url or ''):
                    link = await page.query_selector('a[href*="profile.php"], a[href*="/pages/"], a[href*="facebook.com/"]')
                    if link:
                        try:
                            await link.click()
                            await asyncio.sleep(2)
                        except Exception:
                            pass
                n = await self._click_text_buttons(
                    page,
                    ['like', 'thích', 'follow', 'theo dõi', 'liked'],
                    max_clicks=1,
                )
                if n:
                    liked += 1
                    self.inc_usage(uid, 'like', 1)
                    self.add_report(uid, 'PAGE_LIKE', t[:40], 'SUCCESS', 'Like/Follow page')
                    # nhớ page
                    pages = acc.setdefault('liked_pages', [])
                    if t not in pages:
                        pages.append(t)
                else:
                    self.add_report(uid, 'PAGE_LIKE', t[:40], 'FAIL', 'Không bấm Like được')
                await self._safe_sleep(SAFE_ACTION_DELAY, 'sau like page')
            except Exception as ex:
                self.add_report(uid, 'PAGE_LIKE', t[:40], 'FAIL', str(ex))
        return liked

    async def _action_page_interact(self, page, acc, targets, like_n=3, comment_n=1, comment=''):
        """Like + comment bài trên fanpage."""
        uid = acc.get('uid', '')
        items = [t.strip() for t in re.split(r'[,;\n]+', targets or '') if t.strip()]
        if not items:
            # fallback liked_pages
            items = list(acc.get('liked_pages') or [])[:3]
        if not items:
            self.add_report(uid, 'PAGE_INTERACT', '-', 'FAIL', 'Chưa có fanpage target')
            return 0
        total = 0
        from urllib.parse import quote
        t = random.choice(items)
        try:
            if 'facebook.com' in t:
                url = t.replace('www.facebook.com', 'mbasic.facebook.com')
            elif re.fullmatch(r'\d{5,}', t):
                url = f'https://mbasic.facebook.com/{t}'
            else:
                url = f'https://mbasic.facebook.com/search/pages/?q={quote(t)}'
            await page.goto(url, timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            if 'search' in (page.url or ''):
                link = await page.query_selector('a[href*="profile.php"], a[href*="/pages/"]')
                if link:
                    await link.click()
                    await asyncio.sleep(2)
            # scroll + like
            for _ in range(3):
                await page.mouse.wheel(0, random.randint(300, 800))
                await asyncio.sleep(1)
            liked = await self._action_like_feed(page, acc, like_n)
            total += liked
            # comment vài story
            cmt_done = 0
            stories = await page.query_selector_all('a[href*="story.php"], a[href*="/posts/"], a[href*="permalink"]')
            for a in (stories or [])[: max(1, comment_n * 2)]:
                if cmt_done >= comment_n:
                    break
                try:
                    href = await a.get_attribute('href') or ''
                    if not href:
                        continue
                    if href.startswith('/'):
                        href = 'https://mbasic.facebook.com' + href
                    await page.goto(href, timeout=30000, wait_until='domcontentloaded')
                    await asyncio.sleep(1.5)
                    area = await page.query_selector('textarea[name="comment_text"], textarea')
                    if not area:
                        continue
                    text = self.spin_text(comment or '{Hay quá|Tuyệt vời}')
                    await area.fill(text)
                    btn = await page.query_selector('input[type="submit"], button[type="submit"]')
                    if btn:
                        await btn.click()
                        cmt_done += 1
                        total += 1
                        self.inc_usage(uid, 'comment', 1)
                        self.add_report(uid, 'PAGE_INTERACT', t[:30], 'SUCCESS', f'Comment: {text[:40]}')
                        await self._safe_sleep(SAFE_ACTION_DELAY, 'sau cmt page')
                except Exception:
                    continue
            if total:
                self.log(f"[{uid}] Page interact: like/cmt ~{total} trên {t[:40]}")
            else:
                self.add_report(uid, 'PAGE_INTERACT', t[:40], 'FAIL', 'Không tương tác được')
            return total
        except Exception as ex:
            self.add_report(uid, 'PAGE_INTERACT', t[:40], 'FAIL', str(ex))
            return 0

    async def _task_friend_accept(self, page, acc, config):
        await self._action_friend_accept(page, acc, max_n=int(config.get('max_n') or 5))

    async def _task_friend_suggest(self, page, acc, config):
        await self._action_friend_suggest(page, acc, max_n=int(config.get('max_n') or 5))

    async def _task_friend_search(self, page, acc, config):
        await self._action_friend_search_add(
            page, acc, config.get('query') or '', max_n=int(config.get('max_n') or 3),
        )

    async def _task_friend_wall(self, page, acc, config):
        max_n = int(config.get('max_n') or 3)
        if config.get('do_like', True):
            await self._action_like_feed(page, acc, max_n)
        if config.get('do_comment', True):
            await self._action_comment_friend_wall(
                page, acc, config.get('comment') or '{Hay quá|Tuyệt vời}', max_n=max_n,
            )

    async def _task_friend_poke(self, page, acc, config):
        await self._action_friend_poke(page, acc, max_n=int(config.get('max_n') or 3))

    async def _task_birthday(self, page, acc, config):
        await self._action_birthday_wish(
            page, acc,
            message=config.get('message') or DEFAULT_BIRTHDAY_SPIN,
            max_n=int(config.get('max_n') or 8),
        )

    async def _task_group_browse(self, page, acc, config):
        n_groups = int(config.get('groups_n') or 2)
        like_n = int(config.get('like_n') or 3)
        for _ in range(max(1, n_groups)):
            await self._action_group_browse_like(page, acc, like_n=like_n)

    async def _task_group_invite(self, page, acc, config):
        await self._action_group_invite(page, acc, max_n=int(config.get('max_n') or 3))

    async def _task_group_leave(self, page, acc, config):
        await self._action_group_leave(page, acc, max_n=int(config.get('max_n') or 1))

    async def _task_page_like(self, page, acc, config):
        await self._action_page_like(
            page, acc, config.get('targets') or '', max_n=int(config.get('max_n') or 5),
        )

    async def _task_page_interact(self, page, acc, config):
        await self._action_page_interact(
            page, acc,
            targets=config.get('targets') or '',
            like_n=int(config.get('like_n') or 3),
            comment_n=int(config.get('comment_n') or 1),
            comment=config.get('comment') or '',
        )

    async def _action_scroll_feed(self, page, acc, sec_range=None):
        uid = acc.get('uid', '')
        d0, d1 = sec_range or SAFE_SCROLL_SHORT
        total = random.uniform(float(d0), float(d1))
        self.log(f"[{uid}] Lướt newsfeed ~{total:.0f}s...")
        try:
            await page.goto('https://www.facebook.com/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            end = time.time() + total
            while time.time() < end:
                await page.mouse.wheel(0, random.randint(350, 1000))
                await asyncio.sleep(random.uniform(1.5, 3.5))
            return True
        except Exception as ex:
            self.log(f"[{uid}] Scroll lỗi: {ex}")
            return False

    async def _action_like_feed(self, page, acc, count=3):
        uid = acc.get('uid', '')
        try:
            liked = await page.evaluate('''(n) => {
                const sels = [
                    '[aria-label="Like"]', '[aria-label="Thích"]',
                    '[aria-label*="Like"]', '[aria-label*="Thích"]',
                    'div[aria-label="Like"]', 'div[aria-label="Thích"]',
                ];
                let btns = [];
                for (const s of sels) {
                    document.querySelectorAll(s).forEach(b => btns.push(b));
                }
                // unique
                btns = [...new Set(btns)];
                let c = 0;
                for (const b of btns) {
                    if (c >= n) break;
                    try { b.click(); c++; } catch(e) {}
                }
                return c;
            }''', count)
            liked = int(liked or 0)
            if liked:
                self.inc_usage(uid, 'like', liked)
            self.log(f"[{uid}] Like: {liked}/{count}")
            await self._safe_sleep(SAFE_ACTION_DELAY, 'sau like')
            return liked
        except Exception as ex:
            self.log(f"[{uid}] Like lỗi: {ex}")
            return 0

    async def _action_read_notifications(self, page, acc):
        uid = acc.get('uid', '')
        try:
            await page.goto('https://mbasic.facebook.com/notifications.php', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(random.uniform(2, 4))
            # mở 1-2 noti
            links = await page.query_selector_all('a[href*="notification"]')
            for a in (links or [])[:2]:
                try:
                    await a.click()
                    await asyncio.sleep(random.uniform(2, 4))
                    await page.go_back()
                    await asyncio.sleep(1)
                except Exception:
                    break
            self.log(f"[{uid}] Đã đọc thông báo")
            return True
        except Exception as ex:
            self.log(f"[{uid}] Đọc noti lỗi: {ex}")
            return False

    async def _action_friend_accept(self, page, acc, max_n=5):
        uid = acc.get('uid', '')
        remain, limit, used = self.remaining_scenario_quota(uid, 'friend')
        if remain <= 0:
            self.log(f"[{uid}] Hết quota friend ({used}/{limit})")
            return 0
        max_n = min(max_n, remain)
        accepted = 0
        try:
            await page.goto('https://mbasic.facebook.com/friends/center/requests/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            for _ in range(max_n):
                clicked = await page.evaluate('''() => {
                    const els = [...document.querySelectorAll('a, input, button')];
                    for (const e of els) {
                        const t = (e.value || e.innerText || '').toLowerCase();
                        if (t.includes('confirm') || t.includes('xác nhận') || t.includes('chấp nhận') || t === 'accept') {
                            e.click(); return true;
                        }
                    }
                    return false;
                }''')
                if not clicked:
                    break
                accepted += 1
                self.inc_usage(uid, 'friend', 1)
                await self._safe_sleep(SAFE_ACTION_DELAY, 'sau accept friend')
            if accepted:
                self.add_report(uid, 'FRIEND_ACCEPT', f'{accepted}', 'SUCCESS', f'Đã accept {accepted} lời mời')
            else:
                self.log(f"[{uid}] Không có lời mời kết bạn")
            return accepted
        except Exception as ex:
            self.add_report(uid, 'FRIEND_ACCEPT', '-', 'FAIL', str(ex))
            return 0

    async def _action_friend_suggest(self, page, acc, max_n=3):
        uid = acc.get('uid', '')
        remain, limit, used = self.remaining_scenario_quota(uid, 'friend')
        if remain <= 0:
            self.log(f"[{uid}] Hết quota kết bạn ({used}/{limit})")
            return 0
        max_n = min(max_n, remain)
        sent = 0
        try:
            urls = [
                'https://mbasic.facebook.com/friends/center/suggestions/',
                'https://mbasic.facebook.com/findfriends/',
            ]
            for url in urls:
                try:
                    await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                    await asyncio.sleep(2)
                    break
                except Exception:
                    continue
            for _ in range(max_n):
                clicked = await page.evaluate('''() => {
                    const els = [...document.querySelectorAll('a, input, button')];
                    for (const e of els) {
                        const t = (e.value || e.innerText || '').toLowerCase();
                        if (t.includes('add friend') || t.includes('thêm bạn') || t.includes('kết bạn') || t === 'add') {
                            e.click(); return true;
                        }
                    }
                    return false;
                }''')
                if not clicked:
                    break
                sent += 1
                self.inc_usage(uid, 'friend', 1)
                await self._safe_sleep(SAFE_ACTION_DELAY, 'sau gửi kết bạn')
            if sent:
                self.add_report(uid, 'FRIEND_SUGGEST', f'{sent}', 'SUCCESS', f'Đã gửi {sent} lời mời kết bạn')
            else:
                self.log(f"[{uid}] Không gửi được kết bạn đề xuất")
            return sent
        except Exception as ex:
            self.add_report(uid, 'FRIEND_SUGGEST', '-', 'FAIL', str(ex))
            return 0

    async def _action_comment_friend_wall(self, page, acc, spin_text, max_n=2):
        """Comment nhẹ trên feed (bài bạn bè) — dùng spin text."""
        uid = acc.get('uid', '')
        done = 0
        try:
            await page.goto('https://mbasic.facebook.com/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            # tìm link comment trên story
            links = await page.query_selector_all('a[href*="comment"], a[href*="story.php"]')
            seen = set()
            for a in links or []:
                if done >= max_n:
                    break
                try:
                    href = await a.get_attribute('href') or ''
                    if not href or href in seen:
                        continue
                    if 'story.php' not in href and 'comment' not in href:
                        continue
                    seen.add(href)
                    if href.startswith('/'):
                        href = 'https://mbasic.facebook.com' + href
                    await page.goto(href, timeout=30000, wait_until='domcontentloaded')
                    await asyncio.sleep(1.5)
                    area = await page.query_selector('textarea[name="comment_text"], textarea')
                    if not area:
                        continue
                    text = self.spin_text(spin_text or '{Hay quá|Tuyệt vời|Đẹp quá}')
                    await area.fill(text)
                    btn = await page.query_selector('input[type="submit"], button[type="submit"]')
                    if btn:
                        await btn.click()
                        done += 1
                        self.inc_usage(uid, 'comment', 1)
                        self.add_report(uid, 'COMMENT_FRIEND', text[:40], 'SUCCESS', 'Comment bạn bè/feed')
                        await self._safe_sleep(SAFE_ACTION_DELAY, 'sau comment bạn bè')
                except Exception:
                    continue
            if not done:
                self.log(f"[{uid}] Không comment được bài bạn bè (UI/mbasic hạn chế)")
            return done
        except Exception as ex:
            self.add_report(uid, 'COMMENT_FRIEND', '-', 'FAIL', str(ex))
            return 0

    async def _action_group_browse_like(self, page, acc, like_n=2):
        uid = acc.get('uid', '')
        groups = [g for g in (acc.get('joined_groups') or []) if g.get('id')]
        if not groups:
            self.log(f"[{uid}] Chưa có group để lướt")
            return 0
        g = random.choice(groups)
        try:
            await page.goto(f"https://www.facebook.com/groups/{g.get('id')}", timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            for _ in range(4):
                await page.mouse.wheel(0, random.randint(400, 900))
                await asyncio.sleep(random.uniform(1.2, 2.5))
            liked = await self._action_like_feed(page, acc, like_n)
            self.add_report(uid, 'GROUP_BROWSE', g.get('name', ''), 'SUCCESS', f'Lướt group + like {liked}')
            return liked
        except Exception as ex:
            self.add_report(uid, 'GROUP_BROWSE', g.get('name', ''), 'FAIL', str(ex))
            return 0

    async def _task_comment_groups(self, page, acc, config):
        uid = acc.get('uid', '')
        mode = config.get('mode') or 'medium'
        # Kịch bản: delay 3–5 phút; task thường: delay preset
        scenario_mode = bool(config.get('scenario_mode'))
        delay = config.get('delay') or (SAFE_COMMENT_DELAY if scenario_mode else self.get_preset(mode)['delay'])
        if scenario_mode:
            remain, day_limit, used = self.remaining_scenario_quota(uid, 'comment_group')
        else:
            remain, day_limit, used = self.remaining_quota(uid, 'comment', mode)
        if remain <= 0:
            self.add_report(uid, 'COMMENT_GROUP', 'quota', 'FAIL', f'Hết quota comment hôm nay ({used}/{day_limit})')
            return
        raw_comment = config.get('comment') or ''
        groups = acc.get('joined_groups') or []
        if not groups:
            self.add_report(uid, 'COMMENT_GROUP', '-', 'FAIL', 'Chưa có nhóm đã join')
            return
        # số comment trong session
        want = int(config.get('comment_count') or 0)
        if want <= 0:
            want = min(remain, len(groups), max(1, day_limit // 3))
        else:
            want = min(want, remain, len(groups))
        max_this_run = want
        # randomize group order
        groups = list(groups)
        random.shuffle(groups)
        for g in groups[:max_this_run]:
            if scenario_mode:
                remain, day_limit, used = self.remaining_scenario_quota(uid, 'comment_group')
            else:
                remain, day_limit, used = self.remaining_quota(uid, 'comment', mode)
            if remain <= 0:
                self.log(f"[{uid}] Hết quota comment — dừng")
                break
            try:
                comment = self.spin_text(raw_comment)  # mỗi lần spin khác nhau
                self.log(f"[{uid}] Comment nhóm: {g.get('name')} | quota còn {remain}")
                await page.goto(f"https://mbasic.facebook.com/groups/{g.get('id')}", timeout=45000, wait_until='domcontentloaded')
                await asyncio.sleep(2)
                # Ưu tiên comment vào bài viết trong group (story)
                story = await page.query_selector('a[href*="story.php"], a[href*="/permalink/"]')
                if story:
                    try:
                        await story.click()
                        await asyncio.sleep(2)
                    except Exception:
                        pass
                area = await page.query_selector('textarea[name="comment_text"], textarea')
                if not area:
                    self.add_report(uid, 'COMMENT_GROUP', g.get('name', ''), 'FAIL', 'Không có ô comment', g.get('url', ''))
                    continue
                await area.fill(comment)
                btn = await page.query_selector('input[type="submit"], button[type="submit"]')
                if btn:
                    await btn.click()
                    self.inc_usage(uid, 'comment', 1)
                    self.add_report(uid, 'COMMENT_GROUP', g.get('name', ''), 'SUCCESS', f'Comment: {comment[:60]}', g.get('url', ''))
                    await self._safe_sleep(delay, 'sau comment group (3-5 phút nếu kịch bản)')
                else:
                    self.add_report(uid, 'COMMENT_GROUP', g.get('name', ''), 'FAIL', 'Không có nút gửi', g.get('url', ''))
            except Exception as ex:
                self.add_report(uid, 'COMMENT_GROUP', g.get('name', ''), 'FAIL', str(ex))

    async def _task_scenario(self, page, acc, config):
        """
        Chạy 1 session kịch bản cho 1 nick theo phase (warm-up / daily ca).
        Cập nhật scenario_progress + báo cáo từng task.
        """
        self._raise_if_stopped()
        uid = str(acc.get('uid', ''))
        self._ensure_lifecycle(acc)
        phase, day = self._scenario_phase(acc)
        force = (config.get('force_shift') or 'auto')
        if phase == 'daily':
            if force in ('morning', 'noon', 'evening', 'full'):
                shift = force
            else:
                shift = self._current_shift_name()
                if shift == 'full':
                    shift = 'morning'
        else:
            shift = 'full'

        tasks = self._scenario_task_list(phase, shift if phase == 'daily' else 'full')
        day_rec, shift_rec, shift_key = self._today_progress(uid, phase, shift if phase == 'daily' else 'full')
        shift_rec['status'] = 'running'
        shift_rec['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        self.save_scenario_progress()
        try:
            self._ui_call(self.refresh_scenario_table)
        except Exception:
            pass

        labels = self._task_labels()
        task_names = [labels.get(t, t) for t in tasks]
        self.log(
            f"[{uid}] KỊCH BẢN phase={phase} day={day} ca={shift_key} | "
            f"{len(tasks)} task: {', '.join(task_names)}"
        )
        self.add_report(
            uid, 'SCENARIO', f'{phase}/{shift_key}', 'OK',
            f'Bắt đầu ngày {day}: {", ".join(tasks)}',
        )

        results = {}

        async def mark(task_id, status, msg=''):
            shift_rec['tasks'][task_id] = status
            shift_rec['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            results[task_id] = status
            self.save_scenario_progress()
            self.add_report(uid, 'SCENARIO_TASK', labels.get(task_id, task_id), status.upper(), msg)
            try:
                self._ui_call(self.refresh_scenario_table)
            except Exception:
                pass

        # --- CẤM hành động nguy hiểm trong warm-up silent ---
        if phase == 'warmup_silent':
            for banned in ('friend_suggest', 'comment_friend', 'comment_group', 'join', 'post_wall', 'post_wall_safe'):
                if banned in tasks:
                    tasks = [t for t in tasks if t != banned]

        # Chỉ các task còn pending (để % task chính xác)
        run_tasks = []
        for task_id in tasks:
            prev = (shift_rec.get('tasks') or {}).get(task_id)
            if prev in ('done', 'success', 'skip'):
                self.log(f"[{uid}] Bỏ qua {labels.get(task_id, task_id)} (đã {prev})")
            else:
                run_tasks.append(task_id)
        if not run_tasks:
            self.log(f"[{uid}] Không còn task pending trong ca — đánh dấu xong")
            run_tasks = []

        try:
            for ti, task_id in enumerate(run_tasks, start=1):
                self._raise_if_stopped()
                tlabel = labels.get(task_id, task_id)
                est_t = self._estimate_task_seconds(task_id, phase, shift_key)
                self._progress_task(ti, len(run_tasks), tlabel)
                self.log(
                    f"[{uid}] → Task {ti}/{len(run_tasks)}: {tlabel} "
                    f"(ước lượng ~{self._fmt_duration(est_t)})"
                )
                try:
                    if task_id == 'scroll':
                        sec = SAFE_SCROLL_SEC if phase == 'warmup_silent' else SAFE_SCROLL_SHORT
                        if phase == 'daily' and shift_key == 'morning':
                            sec = (120, 130)
                        ok = await self._action_scroll_feed(page, acc, sec)
                        await mark(task_id, 'done' if ok else 'fail', 'Lướt newsfeed')

                    elif task_id == 'like':
                        n = random.randint(2, 3) if phase.startswith('warmup') else 3
                        liked = await self._action_like_feed(page, acc, n)
                        await mark(task_id, 'done' if liked >= 0 else 'fail', f'Like {liked}')

                    elif task_id == 'read_noti':
                        ok = await self._action_read_notifications(page, acc)
                        await mark(task_id, 'done' if ok else 'fail', 'Đọc thông báo')

                    elif task_id == 'friend_suggest':
                        n = random.randint(2, 3)
                        sent = await self._action_friend_suggest(page, acc, max_n=n)
                        await mark(task_id, 'done' if sent >= 0 else 'fail', f'Gửi {sent} kết bạn')

                    elif task_id == 'friend_accept':
                        n = await self._action_friend_accept(page, acc, max_n=5)
                        await mark(task_id, 'done', f'Accept {n}')

                    elif task_id in ('post_wall_safe', 'post_wall'):
                        remain, _, _ = self.remaining_scenario_quota(uid, 'post')
                        if remain <= 0:
                            await mark(task_id, 'skip', 'Hết quota post')
                        else:
                            content = self.spin_text(
                                config.get('status_spin')
                                or self.scenario_cfg.get('status_spin')
                                or DEFAULT_SCENARIO_CFG['status_spin']
                            )
                            link = ''
                            if task_id == 'post_wall' and phase == 'daily':
                                link = (config.get('promo_link') or self.scenario_cfg.get('promo_link') or '').strip()
                            # warm-up: tuyệt đối không chèn link ads
                            if task_id == 'post_wall_safe':
                                link = ''
                            img = ''
                            if task_id == 'post_wall_safe':
                                # có thể đính 1 ảnh phong cảnh nếu user đã chọn
                                img = (config.get('image') or '').strip()
                            post_cfg = {
                                'content': content,
                                'link': link,
                                'image': img,
                                'post_wall': True,
                                'post_groups': False,
                                'group_limit': 0,
                                'mode': 'low',
                                'delay': SAFE_ACTION_DELAY,
                            }
                            await self._task_post(page, acc, post_cfg)
                            await mark(task_id, 'done', content[:50])
                            await self._safe_sleep(SAFE_ACTION_DELAY, 'sau đăng tường')

                    elif task_id == 'join':
                        remain, limit, used = self.remaining_scenario_quota(uid, 'join')
                        if remain <= 0:
                            await mark(task_id, 'skip', f'Hết quota join {used}/{limit}')
                        else:
                            if phase == 'warmup_light':
                                limit_session = min(remain, random.randint(1, 2))
                            else:
                                limit_session = min(remain, random.randint(2, 3))
                            kw = config.get('keywords') or self.scenario_cfg.get('keywords') or 'cộng đồng'
                            join_cfg = {
                                'keywords': kw,
                                'limit': limit_session,
                                'mode': 'low',
                                'delay': SAFE_JOIN_DELAY,
                            }
                            # cap day limit soft: inc_usage already tracks
                            await self._task_join_groups(page, acc, join_cfg)
                            await mark(task_id, 'done', f'Join max {limit_session}')

                    elif task_id == 'comment_friend':
                        spin = config.get('friend_comment_spin') or self.scenario_cfg.get('friend_comment_spin')
                        n = random.randint(1, 2)
                        await self._action_comment_friend_wall(page, acc, spin, max_n=n)
                        await mark(task_id, 'done', f'Comment bạn bè x{n}')

                    elif task_id == 'group_browse_like':
                        await self._action_group_browse_like(page, acc, like_n=random.randint(1, 2))
                        await mark(task_id, 'done', 'Lướt group')

                    elif task_id == 'comment_group':
                        remain, limit, used = self.remaining_scenario_quota(uid, 'comment_group')
                        if remain <= 0:
                            await mark(task_id, 'skip', f'Hết quota cmt {used}/{limit}')
                        else:
                            n = min(remain, random.randint(3, 5))
                            spin = config.get('group_comment_spin') or self.scenario_cfg.get('group_comment_spin')
                            cmt_cfg = {
                                'comment': spin,
                                'mode': 'low',
                                'delay': SAFE_COMMENT_DELAY,
                                'scenario_mode': True,
                                'comment_count': n,
                            }
                            await self._task_comment_groups(page, acc, cmt_cfg)
                            await mark(task_id, 'done', f'Comment group x{n}')

                    else:
                        await mark(task_id, 'skip', 'Task chưa hỗ trợ')

                except Exception as ex:
                    await mark(task_id, 'fail', str(ex))
                    self.log(f"[{uid}] Task {task_id} lỗi: {ex}")
                else:
                    # log trạng thái sau task
                    st_now = (shift_rec.get('tasks') or {}).get(task_id, '')
                    self.log(f"[{uid}] ← Task {ti}/{len(run_tasks)} {tlabel}: {st_now or 'xong'}")

            # tổng kết ca
            task_states = [shift_rec['tasks'].get(t, 'pending') for t in tasks]
            if tasks and all(s in ('done', 'success', 'skip') for s in task_states):
                shift_rec['status'] = 'done'
                overall = 'SUCCESS'
            elif any(s in ('done', 'success') for s in task_states):
                shift_rec['status'] = 'partial'
                overall = 'PARTIAL'
            else:
                shift_rec['status'] = 'fail'
                overall = 'FAIL'

            # daily: đánh dấu ngày done nếu đủ 3 ca
            if phase == 'daily':
                shifts = day_rec.get('shifts') or {}
                needed = ('morning', 'noon', 'evening')
                if all((shifts.get(s) or {}).get('status') == 'done' for s in needed):
                    day_rec['status'] = 'done'
                elif any((shifts.get(s) or {}).get('status') in ('done', 'partial') for s in needed):
                    day_rec['status'] = 'partial'
                else:
                    day_rec['status'] = shift_rec['status']
            else:
                day_rec['status'] = shift_rec['status']

            shift_rec['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            self.save_scenario_progress()
            done_n = sum(1 for s in task_states if s in ('done', 'success', 'skip'))
            pending_n = len(tasks) - done_n
            self.add_report(
                uid, 'SCENARIO', f'{phase}/{shift_key}', overall,
                f'Ngày {day}: xong {done_n}/{len(tasks)} | còn {pending_n} | {results}',
            )
            acc['scenario_note'] = f"{self._today()} {phase}/{shift_key}: {shift_rec['status']}"
            self.log(
                f"[{uid}] Tóm tắt ca: {overall} | xong {done_n}/{len(tasks)} task | "
                f"status={shift_rec['status']}"
            )
        except Exception as ex:
            shift_rec['status'] = 'fail'
            self.save_scenario_progress()
            self.add_report(uid, 'SCENARIO', f'{phase}/{shift_key}', 'FAIL', str(ex))
            self.log(f"[{uid}] Kịch bản FAIL: {ex}")
        finally:
            try:
                self._ui_call(self.refresh_scenario_table)
            except Exception:
                pass

    def _split_vn_name(self, full_name):
        """Tách họ / tên đệm / tên từ họ tên VN."""
        parts = [p for p in (full_name or '').strip().split() if p]
        if not parts:
            return 'Nguyễn', '', 'An'
        if len(parts) == 1:
            return parts[0], '', parts[0]
        if len(parts) == 2:
            return parts[0], '', parts[1]
        return parts[0], ' '.join(parts[1:-1]), parts[-1]

    async def _task_update_profile(self, page, acc, config):
        uid = acc.get('uid', '')
        if config.get('change_name', True):
            await self._change_fb_name(page, acc, config)
        if config.get('change_avatar'):
            await self._change_fb_avatar(page, acc, config)

    async def _change_fb_name(self, page, acc, config):
        uid = acc.get('uid', '')
        full_name = (acc.get('planned_name') or '').strip()
        if not full_name:
            if config.get('use_vn_pool', True) and self.vn_names:
                full_name = self.pick_random_names(1)
            else:
                names = config.get('names_list') or []
                full_name = random.choice(names) if names else ''
            if full_name:
                acc['planned_name'] = full_name
        if not full_name:
            self.add_report(uid, 'CHANGE_NAME', '-', 'FAIL', 'Không có tên để gán')
            return

        first, middle, last = self._split_vn_name(full_name)
        # Facebook form: first_name = tên, last_name = họ (tùy locale)
        # Dùng: last_name=Họ, first_name=Tên, middle_name=Tên đệm
        self.log(f"[{uid}] ĐỔI TÊN PROFILE FACEBOOK → {full_name} (Họ={first}, Đệm={middle}, Tên={last})")

        try:
            # Ưu tiên mbasic name form
            urls = [
                'https://mbasic.facebook.com/settings/account/?name',
                'https://mbasic.facebook.com/profile/edit/info/name/',
                'https://www.facebook.com/settings?tab=account&section=name',
            ]
            opened = False
            for url in urls:
                try:
                    await page.goto(url, timeout=45000, wait_until='domcontentloaded')
                    await asyncio.sleep(2.5)
                    # tìm input name
                    has = await page.query_selector(
                        'input[name="primary_first_name"], input[name="first_name"], input[name="lastname"], input[name="last_name"]'
                    )
                    if has:
                        opened = True
                        break
                except Exception:
                    continue

            if not opened:
                self.add_report(uid, 'CHANGE_NAME', full_name, 'FAIL', 'Không mở được form đổi tên (FB chặn / UI đổi)')
                return

            # Fill các field có thể có
            field_map = {
                'primary_last_name': first,
                'last_name': first,
                'lastname': first,
                'primary_middle_name': middle,
                'middle_name': middle,
                'middlename': middle,
                'primary_first_name': last,
                'first_name': last,
                'firstname': last,
            }
            filled = 0
            for name_attr, value in field_map.items():
                try:
                    el = await page.query_selector(f'input[name="{name_attr}"]')
                    if el:
                        await el.fill(value or '')
                        filled += 1
                except Exception:
                    pass

            # fallback generic 3 text inputs
            if filled == 0:
                inputs = await page.query_selector_all('input[type="text"]')
                vals = [first, middle, last]
                for i, el in enumerate(inputs[:3]):
                    try:
                        await el.fill(vals[i] if i < len(vals) else '')
                        filled += 1
                    except Exception:
                        pass

            if filled == 0:
                self.add_report(uid, 'CHANGE_NAME', full_name, 'FAIL', 'Không tìm thấy ô nhập tên')
                return

            # password confirm if present
            pwd = acc.get('password') or ''
            for sel in ('input[name="save_password"]', 'input[name="ajax_password"]', 'input[type="password"]'):
                try:
                    el = await page.query_selector(sel)
                    if el and pwd:
                        await el.fill(pwd)
                        break
                except Exception:
                    pass

            submitted = False
            for sel in (
                'input[name="save"]',
                'input[type="submit"]',
                'button[type="submit"]',
                'button:has-text("Save")',
                'button:has-text("Lưu")',
                'button:has-text("Review")',
            ):
                try:
                    btn = await page.query_selector(sel)
                    if btn:
                        await btn.click()
                        submitted = True
                        break
                except Exception:
                    continue

            if not submitted:
                submitted = await page.evaluate('''() => {
                    const els = [...document.querySelectorAll('input, button')];
                    for (const e of els) {
                        const t = (e.value || e.innerText || '').toLowerCase();
                        if (t.includes('save') || t.includes('lưu') || t.includes('review') || t.includes('continue') || t.includes('tiếp')) {
                            e.click(); return true;
                        }
                    }
                    return false;
                }''')

            await asyncio.sleep(3)
            body = (await page.content()).lower()
            url = (page.url or '').lower()

            if any(x in body or x in url for x in ('60 day', '60 ngày', 'cannot change', 'không thể đổi', 'name change limit')):
                acc['status'] = 'RESTRICT'
                acc['login_status'] = 'FB hạn chế đổi tên'
                self.add_report(uid, 'CHANGE_NAME', full_name, 'FAIL', 'FB giới hạn đổi tên (thường 60 ngày/lần)')
                return
            if 'checkpoint' in url or 'checkpoint' in body[:8000]:
                acc['status'] = 'CHECKPOINT'
                acc['login_status'] = 'Checkpoint khi đổi tên'
                self.add_report(uid, 'CHANGE_NAME', full_name, 'FAIL', 'Checkpoint khi đổi tên')
                self.on_checkpoint_detected(acc, reason='CHECKPOINT khi đổi tên profile')
                return
            if submitted:
                acc['name_changed'] = True
                acc['is_new'] = False
                acc['display_name'] = full_name
                self.add_report(uid, 'CHANGE_NAME', full_name, 'SUCCESS', 'Đã gửi yêu cầu đổi tên', f'https://www.facebook.com/{uid}')
            else:
                # Không mở/bấm form — thường là UI chặn hoặc restricted
                acc['status'] = 'RESTRICT'
                acc['login_status'] = 'FB chặn form đổi tên (nghi restrict)'
                acc['scenario_note'] = 'Nghi hạn chế — nghỉ 48–72h, chỉ lướt/like nhẹ'
                self.add_report(
                    uid, 'CHANGE_NAME', full_name, 'FAIL',
                    'Không mở được form đổi tên (FB chặn / UI / nghi restrict-checkpoint)',
                )
        except Exception as ex:
            self.add_report(uid, 'CHANGE_NAME', full_name, 'FAIL', str(ex))

    async def _change_fb_avatar(self, page, acc, config):
        uid = acc.get('uid', '')
        folder = (config.get('avatar_folder') or '').strip()
        if not folder or not os.path.isdir(folder):
            self.add_report(uid, 'CHANGE_AVATAR', '-', 'FAIL', f'Thư mục avatar không hợp lệ: {folder}')
            return
        images = [
            os.path.join(folder, f) for f in os.listdir(folder)
            if f.lower().endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'))
        ]
        if not images:
            self.add_report(uid, 'CHANGE_AVATAR', folder, 'FAIL', 'Không có ảnh trong thư mục')
            return
        img = random.choice(images)
        self.log(f"[{uid}] ĐỔI AVATAR PROFILE FACEBOOK ← {os.path.basename(img)}")
        try:
            await page.goto('https://mbasic.facebook.com/profile_picture/', timeout=45000, wait_until='domcontentloaded')
            await asyncio.sleep(2)
            file_input = await page.query_selector('input[type="file"]')
            if not file_input:
                await page.goto('https://mbasic.facebook.com/me', timeout=45000, wait_until='domcontentloaded')
                await asyncio.sleep(1.5)
                file_input = await page.query_selector('input[type="file"]')
            if not file_input:
                self.add_report(uid, 'CHANGE_AVATAR', os.path.basename(img), 'FAIL', 'Không thấy input upload ảnh')
                return
            await file_input.set_input_files(img)
            await asyncio.sleep(2)
            for sel in ('input[type="submit"]', 'button[type="submit"]', 'input[name="submit"]'):
                btn = await page.query_selector(sel)
                if btn:
                    await btn.click()
                    break
            await asyncio.sleep(3)
            acc['avatar_file'] = os.path.basename(img)
            self.add_report(uid, 'CHANGE_AVATAR', os.path.basename(img), 'SUCCESS', 'Đã upload avatar')
        except Exception as ex:
            self.add_report(uid, 'CHANGE_AVATAR', os.path.basename(img), 'FAIL', str(ex))

    async def _playwright_logic(self, task_type, config):
        try:
            from playwright.async_api import async_playwright
        except ImportError:
            self.log("LỖI: Chưa cài Playwright!")
            return

        if isinstance(config, str):
            # backward compat
            config = {'keywords': config} if task_type == 'JOIN_GROUP' else {'comment': config}
        if not isinstance(config, dict):
            config = {}
        # Luôn inject runtime (headless / mobile)
        config = self.inject_runtime(config)

        headless = bool(config.get('headless', True))
        mobile_mode = bool(config.get('mobile')) and not headless
        mobile_profile = self._get_mobile_profile(config.get('mobile_profile'))
        tile_windows = bool(config.get('tile_windows', True))
        browser_name = (config.get('browser') or 'chromium').lower()
        require_proxy = bool(config.get('require_proxy', True))
        indices = config.get('indices')
        if indices is None:
            indices = list(range(len(self.accounts)))

        allow_dead = bool(config.get('allow_dead'))
        # Bảo vệ: loại Acc chết / proxy cách ly (trừ force allow_dead)
        if not allow_dead:
            safe = []
            for idx in indices:
                if idx < 0 or idx >= len(self.accounts):
                    continue
                acc = self.accounts[idx]
                if self._is_dead_pool(acc) or self._is_proxy_quarantined(acc.get('proxy')):
                    self.log(f"[{acc.get('uid')}] 🛡 BỎ QUA — Acc chết / proxy cách ly")
                    self.add_report(
                        acc.get('uid'), task_type, 'protect', 'WAIT',
                        'Acc chết hoặc proxy cách ly — không chạy',
                    )
                    continue
                safe.append(idx)
            indices = safe

        # An toàn lần 2: bỏ nick không proxy (CHỜ)
        if require_proxy:
            filtered = []
            for idx in indices:
                if 0 <= idx < len(self.accounts) and self.account_has_proxy(self.accounts[idx]):
                    # proxy đã quarantine thì không chạy
                    if self._is_proxy_quarantined(self.accounts[idx].get('proxy')) and not allow_dead:
                        continue
                    filtered.append(idx)
                elif 0 <= idx < len(self.accounts):
                    uid = self.accounts[idx].get('uid')
                    self.log(f"[{uid}] BỎ QUA — chưa có proxy (chờ gán proxy)")
                    self.add_report(uid, task_type, 'proxy', 'WAIT', 'Không có proxy — chờ, không login/chạy')
            indices = filtered

        if not indices:
            self.log(f"[{task_type}] Không còn nick nào có proxy để chạy.")
            return

        mode_txt = 'headless' if headless else f"mobile:{config.get('mobile_profile')}"
        try:
            workers = int(config.get('parallel_workers') or 1)
        except Exception:
            workers = 1
        workers = max(1, min(20, workers))
        try:
            same_px_max = int(config.get('parallel_same_proxy_max') or 2)
        except Exception:
            same_px_max = 2
        same_px_max = max(1, min(2, same_px_max))  # cứng: tối đa 2 cùng proxy

        self.log(
            f"Runtime: browser={browser_name} | mode={mode_txt} | "
            f"require_proxy={require_proxy} | task={task_type} | nicks={len(indices)} | "
            f"parallel={workers} (cùng proxy ≤{same_px_max})"
        )
        if workers >= 3 and not headless:
            self.log("⚠ Parallel ≥3 + mobile hiện cửa sổ: máy nặng — khuyến nghị headless.")
        self._ui_refresh_jobs()

        # Khóa global + per-proxy
        global_sem = asyncio.Semaphore(workers)
        proxy_sems = {}
        proxy_sems_lock = asyncio.Lock()
        done_lock = asyncio.Lock()
        pos_counter = {'n': 0}

        async def _proxy_sem(px_key):
            async with proxy_sems_lock:
                if px_key not in proxy_sems:
                    proxy_sems[px_key] = asyncio.Semaphore(same_px_max)
                return proxy_sems[px_key]

        async def _process_one_nick(p, pos, idx):
            """Chạy 1 nick (độc lập) — được giới hạn bởi semaphore."""
            if self._should_stop():
                return
            if idx < 0 or idx >= len(self.accounts):
                return
            acc = self.accounts[idx]
            uid = acc.get('uid')
            px_raw = acc.get('proxy') or ''
            px_key = self._normalize_proxy_key(px_raw) or f'__nopx__{idx}'

            async with global_sem:
                if self._should_stop():
                    return
                psem = await _proxy_sem(px_key)
                async with psem:
                    if self._should_stop():
                        return
                    await self._playwright_run_single_nick(
                        p, task_type, config, pos, idx, acc, uid,
                        headless=headless,
                        mobile_mode=mobile_mode,
                        mobile_profile=mobile_profile,
                        tile_windows=tile_windows,
                        browser_name=browser_name,
                        require_proxy=require_proxy,
                        workers=workers,
                    )

        async with async_playwright() as p:
            if workers <= 1:
                # Tuần tự — giữ hành vi nghỉ giữa nick (scenario)
                for pos, idx in enumerate(indices):
                    if self._should_stop():
                        self.log(f"⏹ Dừng — bỏ qua {len(indices) - pos} nick còn lại")
                        if self.current_job:
                            for rest in indices[pos:]:
                                if 0 <= rest < len(self.accounts):
                                    uid_left = str(self.accounts[rest].get('uid', rest))
                                    if uid_left not in (self.current_job.get('cancelled_uids') or []):
                                        self.current_job.setdefault('cancelled_uids', []).append(uid_left)
                            self.current_job['waiting_uids'] = []
                            self._ui_refresh_jobs()
                        break
                    try:
                        await _process_one_nick(p, pos, idx)
                    except JobStopped:
                        self.log("⏹ Dừng job (tuần tự)")
                        if self.current_job:
                            for rest in indices[pos + 1:]:
                                if 0 <= rest < len(self.accounts):
                                    uid_left = str(self.accounts[rest].get('uid', rest))
                                    if uid_left not in (self.current_job.get('cancelled_uids') or []):
                                        self.current_job.setdefault('cancelled_uids', []).append(uid_left)
                            self.current_job['waiting_uids'] = []
                            self._ui_refresh_jobs()
                        break
                    if self._should_stop():
                        break
                    # Nghỉ giữa nick (chỉ sequential)
                    if pos < len(indices) - 1:
                        pause = random.uniform(8, 20) if task_type == 'SCENARIO' else 1
                        self.log(f"Nghỉ {pause:.0f}s trước nick kế (tuần tự)...")
                        try:
                            await self._interruptible_async_sleep(pause)
                        except JobStopped:
                            break
            else:
                # Song song: stagger nhẹ khi start để giảm spike
                async def _staggered(pos, idx):
                    if pos > 0:
                        try:
                            await asyncio.sleep(min(0.35 * pos, 3.0))
                        except Exception:
                            pass
                    try:
                        await _process_one_nick(p, pos, idx)
                    except JobStopped:
                        return 'stopped'
                    except Exception as ex:
                        self.log(f"Lỗi parallel nick idx={idx}: {ex}")
                        return 'error'
                    return 'ok'

                tasks = [
                    asyncio.create_task(_staggered(pos, idx))
                    for pos, idx in enumerate(indices)
                ]
                results = await asyncio.gather(*tasks, return_exceptions=True)
                if self._should_stop() or any(r == 'stopped' for r in results if not isinstance(r, Exception)):
                    if self.current_job:
                        # đánh dấu chưa xong
                        done = set(self.current_job.get('done_uids') or [])
                        failed = set(self.current_job.get('failed_uids') or [])
                        for idx in indices:
                            if 0 <= idx < len(self.accounts):
                                u = str(self.accounts[idx].get('uid', idx))
                                if u not in done and u not in failed:
                                    if u not in (self.current_job.get('cancelled_uids') or []):
                                        self.current_job.setdefault('cancelled_uids', []).append(u)
                        self.current_job['waiting_uids'] = []
                        self._ui_refresh_jobs()
                    self.log("⏹ Parallel job kết thúc (đã dừng / hết task)")


    async def _playwright_run_single_nick(
        self, p, task_type, config, pos, idx, acc, uid,
        *, headless, mobile_mode, mobile_profile, tile_windows, browser_name, require_proxy, workers=1,
    ):
        """Chạy 1 nick trong 1 browser (dùng cho sequential & parallel)."""
        self._job_mark_account_start(uid)
        extra = ''
        if task_type == 'SCENARIO':
            try:
                sec_e, phase_e, sh_e, pending_e = self._estimate_nick_scenario_seconds(
                    acc, config.get('force_shift') or 'auto'
                )
                extra = (
                    f"phase={phase_e} ca={sh_e} | ~{len(pending_e)} task | "
                    f"ước lượng nick ~{self._fmt_duration(sec_e)}"
                )
            except Exception:
                extra = ''
        self._progress_nick_start(pos, uid, extra=extra)
        self.log(f"========== [{task_type}] Nick: {uid} ({pos + 1}) workers={workers} ==========")
        if extra:
            self.log(f"[{uid}] Kế hoạch nick: {extra}")

        if require_proxy and not self.account_has_proxy(acc):
            self.log(f"[{uid}] CHỜ — không có proxy")
            self.add_report(uid, task_type, 'proxy', 'WAIT', 'Không có proxy')
            self._job_mark_account_done(uid, ok=False)
            self._progress_nick_done(uid, ok=False)
            return

        proxy_config = self._build_playwright_proxy(acc.get('proxy'))
        browser = None
        account_ok = False

        if mobile_mode:
            vp = dict(mobile_profile['viewport'])
            ua = mobile_profile['user_agent']
            win_w, win_h = mobile_profile.get('window') or (vp['width'] + 20, vp['height'] + 80)
            wx, wy = self._mobile_window_pos(pos, win_w, win_h, tile=tile_windows)
            self.log(
                f"[{uid}] 📱 Mobile window {win_w}x{win_h} @ ({wx},{wy}) · "
                f"{mobile_profile.get('label', '')}"
            )
        else:
            vp = {'width': 1280, 'height': 800}
            ua = (
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                'AppleWebKit/537.36 (KHTML, like Gecko) '
                'Chrome/120.0.0.0 Safari/537.36'
            )
            win_w = win_h = wx = wy = None

        try:
            if browser_name == 'firefox':
                launcher = p.firefox
                browser = await launcher.launch(headless=headless)
                if not mobile_mode:
                    ua = (
                        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) '
                        'Gecko/20100101 Firefox/121.0'
                    )
            else:
                launcher = p.chromium
                launch_args = [
                    '--disable-blink-features=AutomationControlled',
                    '--no-sandbox',
                    '--disable-dev-shm-usage',
                    '--disable-infobars',
                ]
                if headless:
                    launch_args.append('--window-position=-2400,-2400')
                elif mobile_mode and win_w:
                    launch_args.append(f'--window-size={win_w},{win_h}')
                    launch_args.append(f'--window-position={wx},{wy}')
                    launch_args.extend([
                        '--disable-extensions',
                        '--disable-background-networking',
                        '--disable-default-apps',
                        '--disable-sync',
                        '--metrics-recording-only',
                        '--mute-audio',
                    ])
                browser = await launcher.launch(headless=headless, args=launch_args)

            self._register_active_browser(browser)
            ctx_kwargs = {
                'proxy': proxy_config,
                'user_agent': ua,
                'locale': 'vi-VN',
                'viewport': vp,
            }
            if mobile_mode:
                ctx_kwargs.update({
                    'is_mobile': bool(mobile_profile.get('is_mobile', True)),
                    'has_touch': bool(mobile_profile.get('has_touch', True)),
                    'device_scale_factor': float(mobile_profile.get('device_scale_factor') or 2),
                })
            context = await browser.new_context(**ctx_kwargs)
            page = await context.new_page()
            if mobile_mode and not headless and browser_name != 'firefox':
                try:
                    await page.set_viewport_size(vp)
                except Exception:
                    pass

            self._raise_if_stopped()
            force = bool(config.get('force_relogin')) if task_type == 'LOGIN' else False
            preserve = bool(config.get('preserve_session', True)) if task_type == 'LOGIN' else True
            login_st = await self._login_account(
                page, acc, force=force, preserve_session=preserve,
            )
            self._raise_if_stopped()
            if task_type == 'LOGIN':
                account_ok = login_st == 'LIVE'
            elif login_st != 'LIVE':
                self.add_report(uid, task_type, '-', 'FAIL', f'Chưa đăng nhập được ({login_st}) — bỏ qua tác vụ')
                account_ok = False
            else:
                if task_type == 'JOIN_GROUP':
                    await self._task_join_groups(page, acc, config)
                elif task_type == 'POST':
                    await self._task_post(page, acc, config)
                elif task_type == 'POST_AI':
                    await self._task_post(page, acc, {
                        'content': config.get('prompt') or self.post_content.get('1.0', 'end'),
                        'link': config.get('link', ''),
                        'image': '',
                        'post_wall': config.get('random_wall', True),
                        'post_groups': False,
                        'group_limit': 1,
                    })
                elif task_type == 'NURTURE':
                    await self._task_nurture(page, acc, config)
                elif task_type == 'COMMENT_GROUP':
                    await self._task_comment_groups(page, acc, config)
                elif task_type == 'UPDATE_PROFILE':
                    await self._task_update_profile(page, acc, config)
                elif task_type == 'SCENARIO':
                    await self._task_scenario(page, acc, config)
                elif task_type == 'FRIEND_ACCEPT':
                    await self._task_friend_accept(page, acc, config)
                elif task_type == 'FRIEND_SUGGEST':
                    await self._task_friend_suggest(page, acc, config)
                elif task_type == 'FRIEND_SEARCH':
                    await self._task_friend_search(page, acc, config)
                elif task_type == 'FRIEND_WALL':
                    await self._task_friend_wall(page, acc, config)
                elif task_type == 'FRIEND_POKE':
                    await self._task_friend_poke(page, acc, config)
                elif task_type == 'FRIEND_BIRTHDAY':
                    await self._task_birthday(page, acc, config)
                elif task_type == 'GROUP_BROWSE':
                    await self._task_group_browse(page, acc, config)
                elif task_type == 'GROUP_INVITE':
                    await self._task_group_invite(page, acc, config)
                elif task_type == 'GROUP_LEAVE':
                    await self._task_group_leave(page, acc, config)
                elif task_type == 'PAGE_LIKE':
                    await self._task_page_like(page, acc, config)
                elif task_type == 'PAGE_INTERACT':
                    await self._task_page_interact(page, acc, config)
                else:
                    self.log(f"[{uid}] Task chưa hỗ trợ đầy đủ: {task_type}")
                account_ok = True

            if login_st == 'LIVE':
                cookie = await self._export_cookies(context)
                if cookie:
                    acc['cookie'] = cookie
        except JobStopped:
            self.log(f"[{uid}] ⏹ Dừng giữa chừng")
            self.add_report(uid, task_type, '-', 'STOPPED', 'Người dùng dừng')
            self._job_mark_account_done(uid, ok=False)
            self._progress_nick_done(uid, ok=False)
            try:
                if browser:
                    await browser.close()
            except Exception:
                pass
            self._unregister_active_browser(browser)
            if workers <= 1:
                raise
            return
        except Exception as ex:
            self.log(f"[{uid}] Lỗi: {ex}")
            self.add_report(uid, task_type, '-', 'FAIL', str(ex))
            account_ok = False
        finally:
            try:
                if browser:
                    await browser.close()
            except Exception:
                pass
            self._unregister_active_browser(browser)
            if not self._should_stop():
                self._job_mark_account_done(uid, ok=account_ok)
                self._progress_nick_done(uid, ok=account_ok)
            try:
                self.save_accounts()
                self.save_scenario_progress()
            except Exception:
                pass
            self._ui_call(self.refresh_table)
            self._ui_call(self.refresh_groups_table)
            self._ui_call(self.refresh_scenario_table)
            self._ui_refresh_jobs()

        if workers <= 1 and not self._should_stop():
            # nghỉ chỉ khi sequential đã xử lý ở vòng ngoài; parallel không nghỉ ở đây
            pass


def main():
    """Entry cho chạy trực tiếp hoặc bản đóng gói (PyInstaller)."""
    root = tk.Tk()
    try:
        # HiDPI / scaling gợi ý trên Windows
        from ctypes import windll
        windll.shcore.SetProcessDpiAwareness(1)
    except Exception:
        pass
    try:
        root.tk.call('tk', 'scaling', 1.15)
    except Exception:
        pass
    try:
        root.title("AI Maketing Facebook")
    except Exception:
        pass
    app = FacebookAutomationAppV9(root)
    root.mainloop()
    return app


if __name__ == "__main__":
    main()
