import configparser
import dataclasses
import os
import re
import secrets
import string
import subprocess
import threading
import time
from typing import Dict, List, Optional, Tuple

import cv2
import numpy as np
import requests

try:
    import pytesseract
except Exception:
    pytesseract = None


# -------------------- CONFIG MODELS --------------------

@dataclasses.dataclass
class GlobalConfig:
    adb_path: str
    screenshot_interval_sec: float
    match_threshold: float
    state_cooldown_sec: float
    base_resolution: Tuple[int, int]
    templates_dir: str
    log_dir: str
    backend_url: str
    backend_status_url: str
    backend_info_url: str
    backend_secret: str
    ocr_engine: str
    tesseract_cmd: str
    room_code_regex: str
    room_code_region: Optional[Tuple[int, int, int, int]]
    scale_templates: bool
    post_action_delay_sec: float
    room_code_dedupe_sec: float
    debug_console: bool
    room_created_block_sec: float
    post_match_rename_enabled: bool
    rename_step_delay_sec: float
    rename_clear_count: int
    rename_prefix: str
    rename_random_length: int
    rename_home_avatar_tap: Optional[Tuple[int, int]]
    rename_edit_profile_tap: Optional[Tuple[int, int]]
    rename_name_field_tap: Optional[Tuple[int, int]]
    rename_name_ok_tap: Optional[Tuple[int, int]]
    rename_continue_tap: Optional[Tuple[int, int]]


@dataclasses.dataclass
class AppConfig:
    package: str
    activity: str
    open_method: str


@dataclasses.dataclass
class StateConfig:
    name: str
    priority: int
    templates: List[str]
    match_mode: str
    threshold: float
    fallback_threshold: Optional[float]
    action: str
    cooldown_sec: float
    tap: Optional[Tuple[int, int]]
    tap_offset: Optional[Tuple[int, int]]
    keyevent: Optional[str]
    sleep_sec: Optional[float]
    copy_button_template: Optional[str]
    room_code_region: Optional[Tuple[int, int, int, int]]
    empty_slot_template: Optional[str]
    empty_slot_expected: Optional[int]
    empty_slot_threshold: Optional[float]
    empty_slot_region: Optional[Tuple[int, int, int, int]]
    empty_slot_suppression: Optional[float]
    empty_slot_columns: Optional[int]


def parse_tuple_int(value: str, count: int) -> Optional[Tuple[int, ...]]:
    if not value:
        return None
    parts = [p.strip() for p in value.split(',') if p.strip()]
    if len(parts) != count:
        return None
    try:
        return tuple(int(p) for p in parts)
    except ValueError:
        return None


def parse_point_or_region(value: str) -> Optional[Tuple[int, int]]:
    if not value:
        return None
    point = parse_tuple_int(value, 2)
    if point:
        return int(point[0]), int(point[1])
    region = parse_tuple_int(value, 4)
    if region:
        x, y, w, h = region
        return int(x + (w / 2)), int(y + (h / 2))
    return None


def parse_bool(value: str, default: bool = False) -> bool:
    if value is None:
        return default
    text = str(value).strip().lower()
    if not text:
        return default
    return text in ('1', 'true', 'yes', 'on')


def load_config(path: str) -> Tuple[GlobalConfig, AppConfig, List[StateConfig]]:
    cp = configparser.ConfigParser()
    read = cp.read(path, encoding='utf-8')
    if not read:
        raise FileNotFoundError(f"Config not found: {path}")

    config_dir = os.path.dirname(path)

    def resolve_path(value: str) -> str:
        if not value:
            return value
        if os.path.isabs(value):
            return value
        return os.path.normpath(os.path.join(config_dir, value))

    g = cp['global']
    base_res = parse_tuple_int(g.get('base_resolution', '1280,720'), 2) or (1280, 720)
    room_code_region = parse_tuple_int(g.get('room_code_region', ''), 4)

    global_cfg = GlobalConfig(
        adb_path=g.get('adb_path', '..\\adb.exe'),
        screenshot_interval_sec=float(g.get('screenshot_interval_sec', '3')),
        match_threshold=float(g.get('match_threshold', '0.88')),
        state_cooldown_sec=float(g.get('state_cooldown_sec', '2')),
        base_resolution=(int(base_res[0]), int(base_res[1])),
        templates_dir=resolve_path(g.get('templates_dir', 'templates')),
        log_dir=resolve_path(g.get('log_dir', 'logs')),
        backend_url=g.get('backend_url', ''),
        backend_status_url=g.get('backend_status_url', ''),
        backend_info_url=g.get('backend_info_url', ''),
        backend_secret=g.get('backend_secret', ''),
        ocr_engine=g.get('ocr_engine', 'clipboard_then_tesseract'),
        tesseract_cmd=g.get('tesseract_cmd', ''),
        room_code_regex=g.get('room_code_regex', r'^[A-Z0-9]{4,8}$'),
        room_code_region=room_code_region,
        scale_templates=g.get('scale_templates', 'true').lower() == 'true',
        post_action_delay_sec=float(g.get('post_action_delay_sec', '0.4')),
        room_code_dedupe_sec=float(g.get('room_code_dedupe_sec', '3600')),
        debug_console=parse_bool(g.get('debug_console', 'false')),
        room_created_block_sec=float(g.get('room_created_block_sec', '20')),
        post_match_rename_enabled=parse_bool(g.get('post_match_rename_enabled', 'false')),
        rename_step_delay_sec=float(g.get('rename_step_delay_sec', '1.1')),
        rename_clear_count=max(1, int(g.get('rename_clear_count', '20'))),
        rename_prefix=g.get('rename_prefix', 'lk').strip() or 'lk',
        rename_random_length=max(1, int(g.get('rename_random_length', '6'))),
        rename_home_avatar_tap=parse_point_or_region(g.get('rename_home_avatar_tap', '')),
        rename_edit_profile_tap=parse_point_or_region(g.get('rename_edit_profile_tap', '')),
        rename_name_field_tap=parse_point_or_region(g.get('rename_name_field_tap', '')),
        rename_name_ok_tap=parse_point_or_region(g.get('rename_name_ok_tap', '')),
        rename_continue_tap=parse_point_or_region(g.get('rename_continue_tap', '')),
    )

    app = cp['app'] if 'app' in cp else {}
    app_cfg = AppConfig(
        package=app.get('package', ''),
        activity=app.get('activity', ''),
        open_method=app.get('open_method', 'monkey'),
    )

    states: List[StateConfig] = []
    for section in cp.sections():
        if not section.startswith('state.'):
            continue
        s = cp[section]
        name = section.split('state.', 1)[1]
        templates = [t.strip() for t in s.get('templates', '').split(',') if t.strip()]
        threshold = float(s.get('threshold', global_cfg.match_threshold))
        fallback_threshold = s.get('fallback_threshold', '').strip()
        fallback_threshold_val = float(fallback_threshold) if fallback_threshold else None
        cooldown_sec = float(s.get('cooldown_sec', global_cfg.state_cooldown_sec))
        tap = parse_tuple_int(s.get('tap', ''), 2)
        tap_offset = parse_tuple_int(s.get('tap_offset', ''), 2)
        room_code_region2 = parse_tuple_int(s.get('room_code_region', ''), 4)

        empty_slot_expected = s.get('empty_slot_expected', '').strip()
        empty_slot_expected_int = int(empty_slot_expected) if empty_slot_expected else None

        empty_slot_threshold = s.get('empty_slot_threshold', '').strip()
        empty_slot_threshold_val = float(empty_slot_threshold) if empty_slot_threshold else None

        empty_slot_region = parse_tuple_int(s.get('empty_slot_region', ''), 4)

        empty_slot_suppression = s.get('empty_slot_suppression', '').strip()
        empty_slot_suppression_val = float(empty_slot_suppression) if empty_slot_suppression else None

        empty_slot_columns = s.get('empty_slot_columns', '').strip()
        empty_slot_columns_val = int(empty_slot_columns) if empty_slot_columns else None

        states.append(StateConfig(
            name=name,
            priority=int(s.get('priority', '0')),
            templates=templates,
            match_mode=s.get('match_mode', 'any').lower(),
            threshold=threshold,
            fallback_threshold=fallback_threshold_val,
            action=s.get('action', '').strip(),
            cooldown_sec=cooldown_sec,
            tap=tap,
            tap_offset=tap_offset,
            keyevent=s.get('keyevent', '').strip() or None,
            sleep_sec=float(s.get('sleep_sec', '0')) if s.get('sleep_sec', '').strip() else None,
            copy_button_template=s.get('copy_button_template', '').strip() or None,
            room_code_region=room_code_region2,
            empty_slot_template=s.get('empty_slot_template', '').strip() or None,
            empty_slot_expected=empty_slot_expected_int,
            empty_slot_threshold=empty_slot_threshold_val,
            empty_slot_region=empty_slot_region,
            empty_slot_suppression=empty_slot_suppression_val,
            empty_slot_columns=empty_slot_columns_val,
        ))

    states.sort(key=lambda st: st.priority, reverse=True)
    return global_cfg, app_cfg, states


# -------------------- ADB --------------------

class Adb:
    def __init__(self, adb_path: str):
        self.adb_path = adb_path

    def run(self, args: List[str], device: Optional[str] = None, timeout: int = 15) -> subprocess.CompletedProcess:
        cmd = [self.adb_path]
        if device:
            cmd += ['-s', device]
        cmd += args
        return subprocess.run(cmd, capture_output=True, timeout=timeout)

    def shell(self, device: str, command: str, timeout: int = 15) -> str:
        result = self.run(['shell', command], device=device, timeout=timeout)
        return (result.stdout or b'').decode('utf-8', errors='ignore').strip()

    def screencap(self, device: str) -> Optional[np.ndarray]:
        try:
            result = self.run(['exec-out', 'screencap', '-p'], device=device, timeout=20)
        except subprocess.TimeoutExpired:
            return None
        if result.returncode != 0 or not result.stdout:
            return None
        png = np.frombuffer(result.stdout, dtype=np.uint8)
        img = cv2.imdecode(png, cv2.IMREAD_COLOR)
        return img

    def restart_server(self) -> None:
        self.run(['kill-server'])
        time.sleep(0.5)
        self.run(['start-server'])

    def reconnect(self, device: str) -> None:
        self.run(['reconnect', 'device'])

    def tap(self, device: str, x: int, y: int) -> None:
        self.shell(device, f'input tap {x} {y}')

    def press(self, device: str, x: int, y: int, duration_ms: int = 120) -> None:
        self.shell(device, f'input swipe {x} {y} {x} {y} {duration_ms}')

    def keyevent(self, device: str, key: str) -> None:
        self.shell(device, f'input keyevent {key}')

    def text(self, device: str, text: str) -> None:
        safe_text = re.sub(r'[^A-Za-z0-9]', '', text)
        if not safe_text:
            return
        self.shell(device, f'input text {safe_text}')


# -------------------- MATCHER --------------------

class TemplateCache:
    def __init__(self, templates_dir: str):
        self.templates_dir = templates_dir
        self.cache: Dict[Tuple[str, int, int], np.ndarray] = {}

    def load(self, filename: str, scale_x: float, scale_y: float, scale_templates: bool) -> Optional[np.ndarray]:
        key = (filename, int(scale_x * 1000), int(scale_y * 1000))
        if key in self.cache:
            return self.cache[key]
        path = os.path.join(self.templates_dir, filename)
        if not os.path.exists(path):
            return None
        img = cv2.imread(path)
        if img is None:
            return None
        if scale_templates:
            h, w = img.shape[:2]
            new_w = max(1, int(w * scale_x))
            new_h = max(1, int(h * scale_y))
            img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
        self.cache[key] = img
        return img


class StateMatcher:
    def __init__(self, global_cfg: GlobalConfig, states: List[StateConfig]):
        self.global_cfg = global_cfg
        self.states = states
        self.template_cache = TemplateCache(global_cfg.templates_dir)

    def match_state(
        self,
        img: np.ndarray,
        scale_x: float,
        scale_y: float,
        skip_names: Optional[set] = None
    ) -> Tuple[Optional[StateConfig], Optional[Tuple[int, int]], Optional[str]]:
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        for state in self.states:
            if skip_names and state.name in skip_names:
                continue
            matched, loc, matched_template = self._match_state(state, gray, scale_x, scale_y)
            if matched:
                return state, loc, matched_template
        return None, None, None

    def _match_state(
        self,
        state: StateConfig,
        gray: np.ndarray,
        scale_x: float,
        scale_y: float
    ) -> Tuple[bool, Optional[Tuple[int, int]], Optional[str]]:
        if not state.templates:
            return False, None, None

        thresholds = [state.threshold]
        if state.fallback_threshold is not None:
            thresholds.append(state.fallback_threshold)

        # Ordered fallback for pages that have seasonal/variant layouts.
        if state.name in ('home_friends', 'friends_next', 'game_board'):
            for tmpl_name in state.templates:
                tmpl = self.template_cache.load(tmpl_name, scale_x, scale_y, self.global_cfg.scale_templates)
                if tmpl is None:
                    continue
                tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
                res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
                _, max_val, _, max_loc = cv2.minMaxLoc(res)
                if any(max_val >= threshold for threshold in thresholds):
                    center = (max_loc[0] + tmpl.shape[1] // 2, max_loc[1] + tmpl.shape[0] // 2)
                    return True, center, tmpl_name
            return False, None, None

        matches = []
        for tmpl_name in state.templates:
            tmpl = self.template_cache.load(tmpl_name, scale_x, scale_y, self.global_cfg.scale_templates)
            if tmpl is None:
                continue
            tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
            res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
            _, max_val, _, max_loc = cv2.minMaxLoc(res)
            if any(max_val >= threshold for threshold in thresholds):
                matches.append((tmpl_name, max_val, max_loc, tmpl.shape[1], tmpl.shape[0]))

        if state.match_mode == 'all':
            if len(matches) != len(state.templates):
                return False, None, None
            best = max(matches, key=lambda m: m[1])
            return True, (best[2][0] + best[3] // 2, best[2][1] + best[4] // 2), best[0]

        if matches:
            best = max(matches, key=lambda m: m[1])
            return True, (best[2][0] + best[3] // 2, best[2][1] + best[4] // 2), best[0]

        return False, None, None

    def count_template(
        self,
        img: np.ndarray,
        template_name: str,
        threshold: float,
        scale_x: float,
        scale_y: float,
        suppress_scale: float = 1.0
    ) -> int:
        tmpl = self.template_cache.load(template_name, scale_x, scale_y, self.global_cfg.scale_templates)
        if tmpl is None:
            return 0
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
        res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
        h, w = tmpl_gray.shape[:2]
        count = 0
        while True:
            _, max_val, _, max_loc = cv2.minMaxLoc(res)
            if max_val < threshold:
                break
            count += 1
            x, y = max_loc
            sup_w = int(w * max(1.0, suppress_scale))
            sup_h = int(h * max(1.0, suppress_scale))
            cx = x + w // 2
            cy = y + h // 2
            x0 = max(0, cx - sup_w // 2)
            y0 = max(0, cy - sup_h // 2)
            x1 = min(res.shape[1], x0 + sup_w)
            y1 = min(res.shape[0], y0 + sup_h)
            res[y0:y1, x0:x1] = 0
        return count

    def best_matches(self, img: np.ndarray, scale_x: float, scale_y: float, top_n: int = 3) -> List[Tuple[float, str, str]]:
        results: List[Tuple[float, str, str]] = []
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        for state in self.states:
            for tmpl_name in state.templates:
                tmpl = self.template_cache.load(tmpl_name, scale_x, scale_y, self.global_cfg.scale_templates)
                if tmpl is None:
                    continue
                tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
                res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
                _, max_val, _, _ = cv2.minMaxLoc(res)
                results.append((float(max_val), state.name, tmpl_name))
        results.sort(key=lambda x: x[0], reverse=True)
        return results[:top_n]

    def template_score(self, img: np.ndarray, template_name: str, scale_x: float, scale_y: float) -> float:
        tmpl = self.template_cache.load(template_name, scale_x, scale_y, self.global_cfg.scale_templates)
        if tmpl is None or img.size == 0:
            return 0.0
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
        res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
        _, max_val, _, _ = cv2.minMaxLoc(res)
        return float(max_val)


# -------------------- BOT WORKER --------------------

class BotWorker(threading.Thread):
    def __init__(self, device: str, adb: Adb, global_cfg: GlobalConfig, app_cfg: AppConfig, states: List[StateConfig]):
        super().__init__(daemon=True)
        self.device = device
        self.adb = adb
        self.global_cfg = global_cfg
        self.app_cfg = app_cfg
        self.states = states
        self.matcher = StateMatcher(global_cfg, states)

        self.last_action_time: Dict[str, float] = {}
        self.last_room_code: Optional[str] = None
        self.last_room_code_time: float = 0.0
        self.last_seen_state: Optional[str] = None
        self.last_seen_state_time: float = 0.0
        self.room_created_block_until: float = 0.0
        self.consecutive_screencap_failures: int = 0
        self.stop_event = threading.Event()

        # started status only once per room code
        self.last_started_sent_code: Optional[str] = None
        # closed status only once per room code
        self.last_closed_sent_code: Optional[str] = None

        # FORCE EXIT flag (set by watcher)
        self.force_exit_until: float = 0.0
        self.force_exit_code: Optional[str] = None
        self.force_exit_back_sent: bool = False
        # room presence hint (updated by main loop)
        self.in_room_until: float = 0.0
        # throttle "not in room" logs per code
        self.last_closed_not_in_room_code: Optional[str] = None
        self.last_closed_not_in_room_time: float = 0.0
        self.rename_pending: bool = False
        self.recent_game_board_hint_until: float = 0.0
        self.manual_profile_resume_until: float = 0.0
        self.rename_continue_retry_until: float = 0.0
        self.rename_requeue_block_until: float = 0.0
        self.current_host_name: str = ''

        os.makedirs(global_cfg.log_dir, exist_ok=True)
        safe_name = device.replace(':', '_')
        self.log_path = os.path.join(global_cfg.log_dir, f'{safe_name}.log')
        self.host_name_state_path = os.path.join(global_cfg.log_dir, f'{safe_name}.host_name.txt')
        self.current_host_name = self.load_current_host_name()

    def log(self, message: str) -> None:
        timestamp = time.strftime('%Y-%m-%d %H:%M:%S')
        line = f'[{timestamp}] {message}\n'
        with open(self.log_path, 'a', encoding='utf-8') as f:
            f.write(line)
        if self.global_cfg.debug_console:
            print(f'[BOT:{self.device}] {message}', flush=True)

    def normalize_host_name(self, value: str) -> str:
        return re.sub(r'[^A-Za-z]', '', str(value or '')).strip()[:64]

    def load_current_host_name(self) -> str:
        try:
            if os.path.exists(self.host_name_state_path):
                with open(self.host_name_state_path, 'r', encoding='utf-8') as f:
                    return self.normalize_host_name(f.read())
        except Exception:
            pass
        return ''

    def persist_current_host_name(self) -> None:
        host_name = self.normalize_host_name(self.current_host_name)
        if not host_name:
            return
        try:
            with open(self.host_name_state_path, 'w', encoding='utf-8') as f:
                f.write(host_name)
        except Exception as e:
            self.log(f'host_name persist failed: {e}')

    def run(self) -> None:
        self.log('worker started')

        # backend room_info watcher thread
        watcher = threading.Thread(target=self.room_info_watcher, daemon=True)
        watcher.start()

        while not self.stop_event.is_set():
            try:
                # ✅ IF FORCE EXIT ACTIVE: handle it first (very high priority)
                if self.force_exit_until > time.time():
                    self.handle_force_exit()
                    time.sleep(0.6)
                    continue

                img = self.adb.screencap(self.device)
                if img is None:
                    self.log('screencap failed')
                    self.consecutive_screencap_failures += 1
                    if self.consecutive_screencap_failures >= 3:
                        self.log('screencap failed 3x, restarting adb server')
                        try:
                            self.adb.restart_server()
                            self.adb.reconnect(self.device)
                        except Exception as e:
                            self.log(f'adb restart error: {e}')
                        self.consecutive_screencap_failures = 0
                    time.sleep(self.global_cfg.screenshot_interval_sec)
                    continue
                else:
                    self.consecutive_screencap_failures = 0

                h, w = img.shape[:2]
                base_w, base_h = self.global_cfg.base_resolution
                scale_x = w / base_w if base_w else 1.0
                scale_y = h / base_h if base_h else 1.0
                self.refresh_recent_game_board_hint(img, scale_x, scale_y)
                if self.try_retry_pending_continue(img, scale_x, scale_y, time.time()):
                    time.sleep(self.global_cfg.post_action_delay_sec)
                    continue

                skip = set()
                if self.room_created_block_until > time.time():
                    skip.add('room_created')

                state, loc, matched_template = self.matcher.match_state(img, scale_x, scale_y, skip_names=skip)
                if state:
                    now = time.time()
                    if self.last_seen_state != state.name or (now - self.last_seen_state_time) > 5:
                        self.log(f'state: {state.name}')
                        self.last_seen_state = state.name
                        self.last_seen_state_time = now
                    if state.name == 'home_friends':
                        self.manual_profile_resume_until = now + 15.0
                    # room presence heuristic
                    room_states = {'room_created', 'play_ready', 'game_board', 'confirm_quit'}
                    non_room_states = {'home_friends', 'friends_next', 'create_room', 'ldplayer_home'}
                    if state.name in room_states:
                        self.in_room_until = time.time() + 15.0
                    elif state.name in non_room_states:
                        if self.room_presence_score(img, scale_x, scale_y) >= 0.88:
                            self.in_room_until = time.time() + 15.0
                        else:
                            self.in_room_until = 0.0
                            self.room_created_block_until = 0.0
                            if self.force_exit_until > time.time():
                                self.force_exit_until = 0.0
                                self.force_exit_code = None
                                self.force_exit_back_sent = False
                                self.log('force_exit: canceled (non-room state)')

                    last = self.last_action_time.get(state.name, 0.0)
                    if (now - last) >= state.cooldown_sec:
                        acted = self.execute_state(state, img, loc, matched_template, scale_x, scale_y)
                        if acted:
                            self.last_action_time[state.name] = time.time()
                            time.sleep(self.global_cfg.post_action_delay_sec)
                            continue
                else:
                    now = time.time()
                    # refresh room presence even if state was skipped/not found
                    if self.room_presence_score(img, scale_x, scale_y) >= 0.88:
                        self.in_room_until = time.time() + 15.0
                    if self.try_resume_manual_profile_rename(scale_x, scale_y, now):
                        time.sleep(self.global_cfg.post_action_delay_sec)
                        continue
                    if (now - self.last_seen_state_time) > 10:
                        self.log('state: none')
                        self.last_seen_state = None
                        self.last_seen_state_time = now
                        if self.global_cfg.debug_console:
                            top = self.matcher.best_matches(img, scale_x, scale_y, top_n=3)
                            if top:
                                msg = '; '.join([f'{s}:{t}={v:.2f}' for v, s, t in top])
                                self.log(f'best matches: {msg}')
            except Exception as e:
                self.log(f'error: {e}')

            time.sleep(self.global_cfg.screenshot_interval_sec)

    def stop(self) -> None:
        self.stop_event.set()

    def execute_state(self, state: StateConfig, img: np.ndarray, loc: Optional[Tuple[int, int]], matched_template: Optional[str], scale_x: float, scale_y: float) -> bool:
        action = state.action

        if (
            state.name == 'home_friends'
            and self.global_cfg.post_match_rename_enabled
            and not self.rename_pending
            and self.recent_game_board_hint_until > time.time()
            and self.rename_requeue_block_until <= time.time()
        ):
            self.rename_pending = True
            self.recent_game_board_hint_until = 0.0
            self.log('rename: queued after returning home')

        if state.name == 'home_friends' and self.rename_pending:
            return self.run_post_match_name_change(scale_x, scale_y)

        if action == 'tap_template':
            if loc is None:
                return False
            x, y = loc
            if state.tap_offset:
                x += int(state.tap_offset[0] * scale_x)
                y += int(state.tap_offset[1] * scale_y)
            self.adb.tap(self.device, x, y)
            self.log(f'{state.name}: tap_template {matched_template} at {x},{y}')
            if state.name == 'confirm_quit':
                self.room_created_block_until = 0.0
            if state.name in ('confirm_quit', 'confirm_quit_game') and self.global_cfg.post_match_rename_enabled:
                self.rename_pending = True
                self.recent_game_board_hint_until = 0.0
                self.log('rename: queued after quit confirm')
            return True

        if action == 'tap':
            if not state.tap:
                return False
            x = int(state.tap[0] * scale_x)
            y = int(state.tap[1] * scale_y)
            self.adb.tap(self.device, x, y)
            self.log(f'{state.name}: tap {x},{y}')
            return True

        if action == 'back':
            self.adb.keyevent(self.device, '4')
            self.log(f'{state.name}: back')
            if state.name == 'game_board' and self.global_cfg.post_match_rename_enabled:
                self.rename_pending = True
                self.recent_game_board_hint_until = time.time() + 25.0
                self.log('rename: queued after game_board back')
            return True

        if action == 'keyevent':
            if not state.keyevent:
                return False
            self.adb.keyevent(self.device, state.keyevent)
            self.log(f'{state.name}: keyevent {state.keyevent}')
            return True

        if action == 'open_app':
            self.open_app()
            self.log(f'{state.name}: open_app')
            return True

        if action == 'force_stop':
            if not self.app_cfg.package:
                return False
            self.adb.shell(self.device, f'am force-stop {self.app_cfg.package}')
            self.log(f'{state.name}: force_stop {self.app_cfg.package}')
            return True

        if action == 'sleep':
            if state.sleep_sec:
                time.sleep(state.sleep_sec)
                self.log(f'{state.name}: sleep {state.sleep_sec}')
                return True
            return False

        if action == 'copy_room_code':
            return self.copy_room_code(state, img, scale_x, scale_y)

        if action == 'play_if_empty_slots':
            return self.play_if_empty_slots(state, img, loc, scale_x, scale_y)

        return False

    def open_app(self) -> None:
        if not self.app_cfg.package:
            return
        if self.app_cfg.activity:
            self.adb.shell(self.device, f'am start -n {self.app_cfg.package}/{self.app_cfg.activity}')
            return
        if self.app_cfg.open_method == 'monkey':
            self.adb.shell(self.device, f'monkey -p {self.app_cfg.package} -c android.intent.category.LAUNCHER 1')

    def tap_base_point(self, point: Optional[Tuple[int, int]], scale_x: float, scale_y: float, label: str) -> bool:
        if not point:
            self.log(f'rename: missing point for {label}')
            return False
        x = int(point[0] * scale_x)
        y = int(point[1] * scale_y)
        self.adb.tap(self.device, x, y)
        self.log(f'rename: {label} tap at {x},{y}')
        return True

    def press_continue_button(self, scale_x: float, scale_y: float, label: str) -> bool:
        point = self.global_cfg.rename_continue_tap
        if not point:
            self.log(f'rename: missing point for {label}')
            return False

        base_x = int(point[0] * scale_x)
        base_y = int(point[1] * scale_y)
        offsets = (
            (0, 0),
            (0, 0),
            (0, 0),
        )
        for idx, (dx, dy) in enumerate(offsets):
            x = base_x + int(dx * scale_x)
            y = base_y + int(dy * scale_y)
            self.adb.press(self.device, x, y, 140)
            self.log(f'rename: {label} press {idx + 1}/{len(offsets)} at {x},{y}')
            time.sleep(0.12)
        return True

    def clear_active_text(self, backspaces: int) -> None:
        self.adb.keyevent(self.device, '123')
        time.sleep(0.1)
        for _ in range(max(1, backspaces)):
            self.adb.keyevent(self.device, '67')
            time.sleep(0.03)

    def generate_random_name(self) -> str:
        prefix = re.sub(r'[^A-Za-z]', '', self.global_cfg.rename_prefix).lower() or 'lk'
        alphabet = string.ascii_lowercase
        suffix = ''.join(secrets.choice(alphabet) for _ in range(self.global_cfg.rename_random_length))
        return f'{prefix}{suffix}'

    def refresh_recent_game_board_hint(self, img: np.ndarray, scale_x: float, scale_y: float) -> None:
        board_score = 0.0
        for template_name in (
            'game_board.png',
            'game_board_2_0.png',
            'game_board_3_0.png',
            'game_board_4_0.png',
            'game_board_5_0.png',
        ):
            score = self.matcher.template_score(img, template_name, scale_x, scale_y)
            if score > board_score:
                board_score = score

        confirm_score = max(
            self.matcher.template_score(img, 'confirm_yes.png', scale_x, scale_y),
            self.matcher.template_score(img, 'confirm_yes_game.png', scale_x, scale_y),
        )

        if board_score >= 0.72 or confirm_score >= 0.48:
            self.recent_game_board_hint_until = max(self.recent_game_board_hint_until, time.time() + 25.0)

    def home_friends_score(self, img: np.ndarray, scale_x: float, scale_y: float) -> float:
        score = 0.0
        for template_name in ('home_friends.png', 'home_friends_2_0.png', 'home_friends_3_0.png'):
            score = max(score, self.matcher.template_score(img, template_name, scale_x, scale_y))
        return score

    def rename_name_box_visible(self, img: np.ndarray, scale_x: float, scale_y: float) -> bool:
        point = self.global_cfg.rename_name_field_tap
        if not point or img.size == 0:
            return False
        cx = int(point[0] * scale_x)
        cy = int(point[1] * scale_y)
        half_w = max(35, int(75 * scale_x))
        half_h = max(14, int(28 * scale_y))
        x0 = max(0, cx - half_w)
        y0 = max(0, cy - half_h)
        x1 = min(img.shape[1], cx + half_w)
        y1 = min(img.shape[0], cy + half_h)
        crop = img[y0:y1, x0:x1]
        if crop.size == 0:
            return False
        white_mask = (
            (crop[:, :, 0] >= 185)
            & (crop[:, :, 1] >= 185)
            & (crop[:, :, 2] >= 185)
        )
        white_ratio = float(np.count_nonzero(white_mask)) / float(white_mask.size)
        return white_ratio >= 0.28

    def try_retry_pending_continue(self, img: np.ndarray, scale_x: float, scale_y: float, now: float) -> bool:
        if self.rename_continue_retry_until <= now:
            return False
        if not self.rename_name_box_visible(img, scale_x, scale_y):
            self.rename_continue_retry_until = 0.0
            return False
        if self.room_presence_score(img, scale_x, scale_y) >= 0.88:
            return False
        if not self.press_continue_button(scale_x, scale_y, 'continue retry'):
            self.rename_continue_retry_until = 0.0
            return False
        self.log('rename: retrying continue from edit screen')
        return True

    def try_resume_manual_profile_rename(self, scale_x: float, scale_y: float, now: float) -> bool:
        if not self.global_cfg.post_match_rename_enabled or self.rename_pending:
            return False
        if self.manual_profile_resume_until <= now:
            return False
        if now < self.in_room_until:
            return False
        self.manual_profile_resume_until = 0.0
        self.log('rename: manual avatar trigger -> resume from profile')
        return self.run_post_match_name_change(scale_x, scale_y, start_from_profile=True)

    def run_post_match_name_change(self, scale_x: float, scale_y: float, start_from_profile: bool = False) -> bool:
        required_points = {
            'edit profile': self.global_cfg.rename_edit_profile_tap,
            'name field': self.global_cfg.rename_name_field_tap,
            'continue': self.global_cfg.rename_continue_tap,
        }
        if not start_from_profile:
            required_points['home avatar'] = self.global_cfg.rename_home_avatar_tap
        missing = [name for name, point in required_points.items() if not point]
        if missing:
            self.log(f'rename: config incomplete ({", ".join(missing)})')
            self.rename_pending = False
            return False

        delay = max(0.5, self.global_cfg.rename_step_delay_sec)
        new_name = self.generate_random_name()

        try:
            self.rename_requeue_block_until = time.time() + 45.0
            self.log(f'rename: start name={new_name}')

            if not start_from_profile:
                if not self.tap_base_point(self.global_cfg.rename_home_avatar_tap, scale_x, scale_y, 'home avatar'):
                    self.rename_pending = False
                    return False
                time.sleep(delay + 0.2)

            if not self.tap_base_point(self.global_cfg.rename_edit_profile_tap, scale_x, scale_y, 'edit profile'):
                self.rename_pending = False
                return False
            time.sleep(delay + 0.4)

            if not self.tap_base_point(self.global_cfg.rename_name_field_tap, scale_x, scale_y, 'name field'):
                self.rename_pending = False
                return False
            time.sleep(delay)

            self.clear_active_text(self.global_cfg.rename_clear_count)
            self.adb.text(self.device, new_name)
            self.log(f'rename: typed {new_name}')
            time.sleep(delay)

            if self.global_cfg.rename_name_ok_tap:
                self.tap_base_point(self.global_cfg.rename_name_ok_tap, scale_x, scale_y, 'name ok')
            else:
                self.adb.keyevent(self.device, '66')
                self.log('rename: ok via enter')
            self.current_host_name = self.normalize_host_name(new_name)
            self.persist_current_host_name()
            time.sleep(delay)

            home_detected = False
            for attempt in range(3):
                if not self.press_continue_button(scale_x, scale_y, 'continue'):
                    self.rename_pending = False
                    return False
                self.log(f'rename: continue attempt {attempt + 1}/3')
                time.sleep(delay)

                img = self.adb.screencap(self.device)
                if img is None:
                    continue
                h, w = img.shape[:2]
                base_w, base_h = self.global_cfg.base_resolution
                next_scale_x = w / base_w if base_w else 1.0
                next_scale_y = h / base_h if base_h else 1.0
                if self.home_friends_score(img, next_scale_x, next_scale_y) >= 0.60:
                    home_detected = True
                    break

            if not home_detected:
                self.rename_continue_retry_until = time.time() + 20.0
                self.log('rename: continue did not exit, retry mode armed')
                self.rename_pending = False
                return True

            self.rename_continue_retry_until = 0.0
            self.rename_pending = False
            self.log(f'rename: done name={new_name}')
            return True
        except Exception as e:
            self.rename_continue_retry_until = 0.0
            self.rename_pending = False
            self.log(f'rename error: {e}')
            return False

    # -------------------- ROOM CODE (OCR) --------------------

    def copy_room_code(self, state: StateConfig, img: np.ndarray, scale_x: float, scale_y: float) -> bool:
        if state.copy_button_template:
            loc = self.find_template_center(img, state.copy_button_template, state.threshold, scale_x, scale_y)
            if loc:
                self.adb.tap(self.device, loc[0], loc[1])
                time.sleep(0.3)

        code = ''
        if self.global_cfg.ocr_engine in ('clipboard_then_tesseract', 'clipboard_only'):
            code = self.get_clipboard_text()

        if not code and self.global_cfg.ocr_engine in ('clipboard_then_tesseract', 'tesseract_only'):
            code = self.ocr_room_code(img, state, scale_x, scale_y)

        if self.global_cfg.debug_console and code:
            self.log(f'{state.name}: room code raw={code}')

        if not code:
            self.log(f'{state.name}: room code not found')
            return False

        if not re.match(self.global_cfg.room_code_regex, code):
            self.log(f'{state.name}: room code failed regex: {code}')
            return False

        if self.last_room_code == code:
            dedupe_sec = self.global_cfg.room_code_dedupe_sec
            if dedupe_sec <= 0 or (time.time() - self.last_room_code_time) <= dedupe_sec:
                self.log(f'{state.name}: duplicate room code skipped: {code}')
                self.room_created_block_until = time.time() + self.global_cfg.room_created_block_sec
                return True

        sent = self.send_room_code(code, screenshot_img=img)
        if sent:
            host_name = self.normalize_host_name(self.current_host_name)
            self.log(f'{state.name}: sent room code {code} host={host_name or "-"} screenshot=yes')
            self.last_room_code = code
            self.last_room_code_time = time.time()
            self.room_created_block_until = time.time() + self.global_cfg.room_created_block_sec

            # reset per-room flags
            self.last_started_sent_code = None
            self.last_closed_sent_code = None
            self.force_exit_code = None
            self.force_exit_until = 0.0
            self.force_exit_back_sent = False
            return True

        self.log(f'{state.name}: failed to send room code {code}')
        return False

    def get_clipboard_text(self) -> str:
        try:
            out = self.adb.shell(self.device, 'cmd clipboard get')
        except Exception:
            return ''
        if not out or 'Clipboard is empty' in out:
            return ''
        m = re.search(r'Clipboard\s*\(text\):\s*"?(.+?)"?$', out)
        return (m.group(1).strip() if m else out.strip())

    def ocr_room_code(self, img: np.ndarray, state: StateConfig, scale_x: float, scale_y: float) -> str:
        if pytesseract is None:
            self.log('pytesseract not available')
            return ''
        if self.global_cfg.tesseract_cmd:
            pytesseract.pytesseract.tesseract_cmd = self.global_cfg.tesseract_cmd

        region = state.room_code_region or self.global_cfg.room_code_region
        if not region:
            self.log('room_code_region not set')
            return ''

        x, y, w, h = region
        x = int(x * scale_x)
        y = int(y * scale_y)
        w = int(w * scale_x)
        h = int(h * scale_y)

        crop = img[y:y + h, x:x + w]
        if crop.size == 0:
            return ''

        gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
        gray = cv2.resize(gray, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
        _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

        config = '--psm 7 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
        text = pytesseract.image_to_string(thresh, config=config)
        text = re.sub(r'[^A-Z0-9]', '', text.upper())
        return text

    # -------------------- BACKEND CALLS --------------------

    def send_room_code(self, code: str, screenshot_img: Optional[np.ndarray] = None) -> bool:
        url = self.global_cfg.backend_url
        if not url:
            return False
        payload = {'room_code': code, 'instance_id': self.device}
        host_name = self.normalize_host_name(self.current_host_name)
        if host_name:
            payload['host_name'] = host_name
        files = None
        if screenshot_img is not None and getattr(screenshot_img, 'size', 0):
            try:
                ok, encoded = cv2.imencode('.png', screenshot_img)
                if ok:
                    files = {'screenshot': (f'room_{code}.png', encoded.tobytes(), 'image/png')}
            except Exception:
                files = None
        if self.global_cfg.backend_secret:
            payload['secret'] = self.global_cfg.backend_secret
        try:
            res = requests.post(url, data=payload, files=files, timeout=20)
            return res.status_code == 200
        except Exception:
            return False

    def send_room_status(self, code: str, status: str) -> bool:
        url = self.global_cfg.backend_status_url
        if not url:
            return False
        payload = {'room_code': code, 'status': status, 'instance_id': self.device}
        if self.global_cfg.backend_secret:
            payload['secret'] = self.global_cfg.backend_secret
        try:
            res = requests.post(url, data=payload, timeout=10)
            return res.status_code == 200
        except Exception:
            return False

    def fetch_room_info(self) -> Optional[dict]:
        url = self.global_cfg.backend_info_url
        try:
            res = requests.get(url, params={'instance_id': self.device}, timeout=10)
            if res.status_code != 200:
                return None
            js = res.json()
            if not js.get('ok'):
                return None
            return js.get('data')
        except Exception:
            return None

    # -------------------- PLAY READY --------------------

    def play_if_empty_slots(self, state: StateConfig, img: np.ndarray, loc: Optional[Tuple[int, int]], scale_x: float, scale_y: float) -> bool:
        if not state.empty_slot_template or state.empty_slot_expected is None:
            return False

        threshold = state.empty_slot_threshold if state.empty_slot_threshold is not None else state.threshold
        search_img = img

        if state.empty_slot_region:
            x, y, w, h = state.empty_slot_region
            x = int(x * scale_x)
            y = int(y * scale_y)
            w = int(w * scale_x)
            h = int(h * scale_y)
            x = max(0, min(x, img.shape[1] - 1))
            y = max(0, min(y, img.shape[0] - 1))
            w = max(1, min(w, img.shape[1] - x))
            h = max(1, min(h, img.shape[0] - y))
            search_img = img[y:y + h, x:x + w]

        if state.empty_slot_region and state.empty_slot_columns:
            cols = max(1, state.empty_slot_columns)
            _, w_img = search_img.shape[:2]
            cell_w = max(1, w_img // cols)
            count = 0
            scores = []
            for i in range(cols):
                x0 = i * cell_w
                x1 = (i + 1) * cell_w if i < cols - 1 else w_img
                sub = search_img[:, x0:x1]
                score = self.matcher.template_score(sub, state.empty_slot_template, scale_x, scale_y)
                scores.append(score)
                if score >= threshold:
                    count += 1
            self.log(f'{state.name}: slot_scores = {",".join([f"{s:.2f}" for s in scores])}')
        else:
            suppress_scale = state.empty_slot_suppression if state.empty_slot_suppression is not None else 1.0
            count = self.matcher.count_template(search_img, state.empty_slot_template, threshold, scale_x, scale_y, suppress_scale=suppress_scale)

        self.log(f'{state.name}: empty slots = {count}')
        if count != state.empty_slot_expected:
            return False

        if loc is None:
            loc = self.find_template_center(img, state.templates[0], state.threshold, scale_x, scale_y)
        if loc is None:
            return False

        self.adb.tap(self.device, loc[0], loc[1])
        self.log(f'{state.name}: tap play at {loc[0]},{loc[1]}')

        # started status once per room
        if self.last_room_code and self.last_started_sent_code != self.last_room_code:
            ok = self.send_room_status(self.last_room_code, 'started')
            self.last_started_sent_code = self.last_room_code
            self.log(f'{state.name}: status started sent={ok} code={self.last_room_code}')

        return True

    def find_template_center(self, img: np.ndarray, template_name: str, threshold: float, scale_x: float, scale_y: float) -> Optional[Tuple[int, int]]:
        tmpl = self.matcher.template_cache.load(template_name, scale_x, scale_y, self.global_cfg.scale_templates)
        if tmpl is None:
            return None
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        tmpl_gray = cv2.cvtColor(tmpl, cv2.COLOR_BGR2GRAY)
        res = cv2.matchTemplate(gray, tmpl_gray, cv2.TM_CCOEFF_NORMED)
        _, max_val, _, max_loc = cv2.minMaxLoc(res)
        if max_val < threshold:
            return None
        h, w = tmpl_gray.shape[:2]
        return (max_loc[0] + w // 2, max_loc[1] + h // 2)

    def room_presence_score(self, img: np.ndarray, scale_x: float, scale_y: float) -> float:
        score = 0.0
        score = max(score, self.matcher.template_score(img, 'room_code_box.png', scale_x, scale_y))

        # Try game_board primary first, then ordered backups.
        board_score = self.matcher.template_score(img, 'game_board.png', scale_x, scale_y)
        if board_score < 0.88:
            board_score_2 = self.matcher.template_score(img, 'game_board_2_0.png', scale_x, scale_y)
            if board_score_2 > board_score:
                board_score = board_score_2
            if board_score < 0.88:
                board_score_3 = self.matcher.template_score(img, 'game_board_3_0.png', scale_x, scale_y)
                if board_score_3 > board_score:
                    board_score = board_score_3
                if board_score < 0.88:
                    board_score_4 = self.matcher.template_score(img, 'game_board_4_0.png', scale_x, scale_y)
                    if board_score_4 > board_score:
                        board_score = board_score_4
                    if board_score < 0.88:
                        board_score_5 = self.matcher.template_score(img, 'game_board_5_0.png', scale_x, scale_y)
                        if board_score_5 > board_score:
                            board_score = board_score_5

        score = max(score, board_score)
        return score

    # -------------------- FORCE EXIT (FIXED) --------------------

    def handle_force_exit(self) -> None:
        """
        Strong exit loop:
        - Try to detect confirm_yes.png and tap it.
        - If not visible, press back once to open quit dialog.
        Repeat few cycles while force_exit_until active.
        """
        img = self.adb.screencap(self.device)
        if img is None:
            if not self.force_exit_back_sent:
                self.adb.keyevent(self.device, '4')
                self.force_exit_back_sent = True
                self.log('force_exit: screencap none -> back (once)')
            return

        h, w = img.shape[:2]
        base_w, base_h = self.global_cfg.base_resolution
        scale_x = w / base_w if base_w else 1.0
        scale_y = h / base_h if base_h else 1.0

        # ✅ first try: click YES if dialog already visible
        yes_loc = (
            self.find_template_center(img, 'confirm_yes.png', threshold=0.55, scale_x=scale_x, scale_y=scale_y)
            or self.find_template_center(img, 'confirm_yes_game.png', threshold=0.55, scale_x=scale_x, scale_y=scale_y)
        )
        if yes_loc:
            self.adb.tap(self.device, yes_loc[0], yes_loc[1])
            self.log(f'force_exit: tapped YES at {yes_loc[0]},{yes_loc[1]}')
            self.room_created_block_until = 0.0
            return

        # if not visible -> only press back when room screen is likely present
        if time.time() > self.in_room_until and self.room_presence_score(img, scale_x, scale_y) < 0.88:
            self.force_exit_until = 0.0
            self.force_exit_code = None
            self.force_exit_back_sent = False
            self.log('force_exit: canceled (not in room)')
            return

        # if not visible -> press back ONCE per force-exit session
        if not self.force_exit_back_sent:
            self.adb.keyevent(self.device, '4')
            self.force_exit_back_sent = True
            self.log('force_exit: back pressed (open quit dialog)')
        return

    def room_info_watcher(self) -> None:
        """
        Poll backend_info_url:
        If backend says room is closed/expired -> activate FORCE EXIT + send closed status once.
        """
        if not self.global_cfg.backend_info_url:
            self.log('room_info_watcher: backend_info_url not set, watcher disabled')
            return

        while not self.stop_event.is_set():
            try:
                info = self.fetch_room_info()
                if info and info.get('room_code'):
                    code = str(info.get('room_code') or '').strip()
                    status = str(info.get('status') or '').strip().lower()
                    expires_epoch = info.get('expires_at_epoch')

                    now = int(time.time())
                    expired = False
                    try:
                        if expires_epoch is not None:
                            expired = int(expires_epoch) <= now
                    except Exception:
                        expired = False

                    if code and (status == 'closed' or expired):
                        if not self.last_room_code or self.last_room_code != code:
                            self.log(f'room_info_watcher: closed/expired but not in same room (code={code} last={self.last_room_code})')
                        else:
                            # allow new room code to be captured after close
                            self.room_created_block_until = 0.0
                            if time.time() > self.in_room_until:
                                now_ts = time.time()
                                if self.last_closed_not_in_room_code != code or (now_ts - self.last_closed_not_in_room_time) > 30:
                                    self.log(f'room_info_watcher: closed/expired but not in-room screen (code={code})')
                                    self.last_closed_not_in_room_code = code
                                    self.last_closed_not_in_room_time = now_ts
                            else:
                                # activate force exit for 20 seconds (only once per session)
                                if self.force_exit_code != code or self.force_exit_until <= time.time():
                                    self.force_exit_until = time.time() + 20.0
                                    self.force_exit_code = code
                                    self.force_exit_back_sent = False
                                    self.room_created_block_until = 0.0


                        if self.last_closed_sent_code != code:
                            self.log(f'room_info_watcher: detected closed/expired => code={code} status={status} expired={expired}')
                            ok = self.send_room_status(code, 'closed')
                            self.last_closed_sent_code = code
                            self.log(f'room_info_watcher: status closed sent={ok} code={code}')

            except Exception as e:
                self.log(f'room_info_watcher error: {e}')

            time.sleep(5)


# -------------------- DEVICE LOOP --------------------

def list_devices(adb: Adb) -> List[str]:
    result = adb.run(['devices'])
    lines = (result.stdout or b'').decode('utf-8', errors='ignore').splitlines()
    devices: List[str] = []
    for line in lines[1:]:
        if not line.strip():
            continue
        parts = line.split()[:2]
        if len(parts) != 2:
            continue
        serial, status = parts
        if status == 'device':
            devices.append(serial)
    return devices


def main() -> None:
    config_path = os.path.join(os.path.dirname(__file__), 'training.txt')
    global_cfg, app_cfg, states = load_config(config_path)

    adb_path = global_cfg.adb_path
    if not os.path.isabs(adb_path):
        adb_path = os.path.abspath(os.path.join(os.path.dirname(__file__), adb_path))
    adb = Adb(adb_path)

    os.makedirs(global_cfg.log_dir, exist_ok=True)

    if global_cfg.debug_console:
        print('[BOT] adb:', adb_path, flush=True)
        print('[BOT] templates:', global_cfg.templates_dir, flush=True)
        print('[BOT] logs:', global_cfg.log_dir, flush=True)
        missing = []
        for st in states:
            for tmpl in st.templates:
                path = os.path.join(global_cfg.templates_dir, tmpl)
                if not os.path.exists(path):
                    missing.append(tmpl)
        if missing:
            print('[BOT] missing templates:', ', '.join(sorted(set(missing))), flush=True)

    workers: Dict[str, BotWorker] = {}
    last_print = 0.0

    while True:
        devices = list_devices(adb)
        if global_cfg.debug_console and (time.time() - last_print) > 5:
            print('[BOT] devices:', ', '.join(devices) if devices else 'none', flush=True)
            last_print = time.time()

        for device in devices:
            if device not in workers:
                worker = BotWorker(device, adb, global_cfg, app_cfg, states)
                workers[device] = worker
                worker.start()
                if global_cfg.debug_console:
                    print(f'[BOT] worker thread started for {device}', flush=True)

        for device in list(workers.keys()):
            if device not in devices:
                workers[device].stop()
                workers.pop(device, None)

        time.sleep(5)


if __name__ == '__main__':
    main()
