# -*- coding: utf-8 -*-
"""
auto_service8700_manager.py

범용 자동실행/자동재시작 관리자 v250 + 관리 서비스 자동복구·화면내 UI 보정·총괄 종료·Chrome 유지 기준
- 배포 기본값은 auto_service8700_manager.default.json, 사용자 설정은 auto_service8700_manager.local.json에 저장한다.
- 서비스별 ports, bat_text, watch_dirs 를 기준으로 관리한다.
- 시작 전 포트 점유 PID 종료, 실행 후 포트 health 확인, 종료/포트다운 시 재시작을 수행한다.
- WebSocket 포트에는 빈 TCP 접속을 만들지 않고 LISTENING 상태만 확인한다.
- Python 로그는 logs\\auto_run_log_*, logs\\auto_err_log_* 단순 구조를 JSON으로 제어한다.
"""
from __future__ import annotations

import argparse
import base64
import ctypes
import fnmatch
import json
import hashlib
import os
import re
import secrets
import signal
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Dict, Iterable, List, Optional, Sequence, Tuple

try:
    import tkinter as tk
    from tkinter import messagebox
except Exception:
    tk = None
    messagebox = None

FileState = Tuple[int, int]
Snapshot = Dict[str, FileState]

LEGACY_CONFIG_FILE_NAME = "auto_service8700_manager.json"
DEFAULT_CONFIG_FILE_NAME = "auto_service8700_manager.default.json"
LOCAL_CONFIG_FILE_NAME = "auto_service8700_manager.local.json"
CONFIG_FILE_NAME = LOCAL_CONFIG_FILE_NAME
DEFAULT_POLL_INTERVAL = 1.0
DEFAULT_DEBOUNCE_SEC = 1.5
LOCK_FILE_NAME = "auto_service8700_manager.lock.json"
COMMAND_FILE_NAME = "auto_service8700_manager_command.json"
DEFAULT_CDP_USER_DATA_DIR = r"D:\cdp-data-dir"
DEFAULT_CDP_PROFILE_DIRECTORY = "Default"


@dataclass
class ManagedProc:
    name: str
    process: Optional[subprocess.Popen] = None
    last_start_ts: float = 0.0
    last_health_ok_ts: float = 0.0
    last_restart_reason: str = ""
    restarting: bool = False


def script_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent


MONITOR_DEFAULTTONEAREST = 2


class _IGBLUE_RECT(ctypes.Structure):
    _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long)]


class _IGBLUE_MONITORINFO(ctypes.Structure):
    _fields_ = [("cbSize", ctypes.c_ulong), ("rcMonitor", _IGBLUE_RECT), ("rcWork", _IGBLUE_RECT), ("dwFlags", ctypes.c_ulong)]


def _igblue_work_area_for_rect(x: int, y: int, width: int, height: int, root=None) -> dict:
    """Return nearest monitor work-area; fallback to Tk screen. Entire Auto/Auth UI must stay inside it."""
    try:
        u32 = ctypes.windll.user32
        rect = _IGBLUE_RECT(int(x), int(y), int(x + max(1, width)), int(y + max(1, height)))
        monitor = u32.MonitorFromRect(ctypes.byref(rect), MONITOR_DEFAULTTONEAREST)
        if monitor:
            info = _IGBLUE_MONITORINFO()
            info.cbSize = ctypes.sizeof(_IGBLUE_MONITORINFO)
            if u32.GetMonitorInfoW(monitor, ctypes.byref(info)):
                return {"left": int(info.rcWork.left), "top": int(info.rcWork.top), "right": int(info.rcWork.right), "bottom": int(info.rcWork.bottom)}
    except Exception:
        pass
    try:
        sw = int(root.winfo_screenwidth()) if root is not None else 0
        sh = int(root.winfo_screenheight()) if root is not None else 0
        if sw > 0 and sh > 0:
            return {"left": 0, "top": 0, "right": sw, "bottom": sh}
    except Exception:
        pass
    return {"left": 0, "top": 0, "right": max(1, int(width or 1)), "bottom": max(1, int(height or 1))}


def clamp_tk_window_geometry(root, width: int, height: int, x: int, y: int, min_width: int = 1, min_height: int = 1):
    """Clamp Tk window geometry so no part of Auto/Auth UI can leave the nearest monitor work-area."""
    try:
        w = max(int(min_width or 1), int(width or min_width or 1))
        h = max(int(min_height or 1), int(height or min_height or 1))
        px = int(x or 0)
        py = int(y or 0)
        area = _igblue_work_area_for_rect(px, py, w, h, root)
        left, top, right, bottom = int(area["left"]), int(area["top"]), int(area["right"]), int(area["bottom"])
        max_w = max(int(min_width or 1), right - left)
        max_h = max(int(min_height or 1), bottom - top)
        w = min(w, max_w)
        h = min(h, max_h)
        px = max(left, min(px, right - w))
        py = max(top, min(py, bottom - h))
        return int(w), int(h), int(px), int(py)
    except Exception:
        return int(width or min_width or 1), int(height or min_height or 1), int(x or 0), int(y or 0)


SPI_GETWORKAREA = 0x0030


def _igblue_primary_work_area(root=None) -> dict:
    """Return the primary monitor work area. Startup UI is forced here so it is always easy to close."""
    try:
        rect = _IGBLUE_RECT()
        if ctypes.windll.user32.SystemParametersInfoW(SPI_GETWORKAREA, 0, ctypes.byref(rect), 0):
            if int(rect.right) > int(rect.left) and int(rect.bottom) > int(rect.top):
                return {"left": int(rect.left), "top": int(rect.top), "right": int(rect.right), "bottom": int(rect.bottom)}
    except Exception:
        pass
    try:
        sw = int(root.winfo_screenwidth()) if root is not None else 0
        sh = int(root.winfo_screenheight()) if root is not None else 0
        if sw > 0 and sh > 0:
            return {"left": 0, "top": 0, "right": sw, "bottom": sh}
    except Exception:
        pass
    return {"left": 0, "top": 0, "right": 1920, "bottom": 1080}


def clamp_tk_window_geometry_primary(root, width: int, height: int, x: int, y: int, min_width: int = 1, min_height: int = 1, margin: int = 10):
    """Clamp startup geometry to the primary work area, including a small visible margin."""
    w = max(int(min_width or 1), int(width or min_width or 1))
    h = max(int(min_height or 1), int(height or min_height or 1))
    px = int(x or 0)
    py = int(y or 0)
    area = _igblue_primary_work_area(root)
    left, top, right, bottom = (int(area[k]) for k in ("left", "top", "right", "bottom"))
    margin = max(0, int(margin or 0))
    max_w = max(int(min_width or 1), right - left - margin * 2)
    max_h = max(int(min_height or 1), bottom - top - margin * 2)
    w = min(w, max_w)
    h = min(h, max_h)
    min_x, max_x = left + margin, max(left + margin, right - w - margin)
    min_y, max_y = top + margin, max(top + margin, bottom - h - margin)
    px = max(min_x, min(px, max_x))
    py = max(min_y, min(py, max_y))
    return int(w), int(h), int(px), int(py), area


def _igblue_tk_window_snapshot(root) -> dict:
    """Best-effort runtime diagnostics for the actual Tk HWND and screen placement."""
    out = {}
    try:
        root.update_idletasks()
    except Exception:
        pass
    for key, getter in (
        ("geometry", lambda: str(root.geometry())),
        ("state", lambda: str(root.state())),
        ("mapped", lambda: bool(root.winfo_ismapped())),
        ("viewable", lambda: bool(root.winfo_viewable())),
        ("x", lambda: int(root.winfo_x())),
        ("y", lambda: int(root.winfo_y())),
        ("width", lambda: int(root.winfo_width())),
        ("height", lambda: int(root.winfo_height())),
        ("screen_width", lambda: int(root.winfo_screenwidth())),
        ("screen_height", lambda: int(root.winfo_screenheight())),
        ("alpha", lambda: float(root.attributes("-alpha"))),
        ("topmost", lambda: bool(root.attributes("-topmost"))),
        ("hwnd", lambda: int(root.winfo_id())),
    ):
        try:
            out[key] = getter()
        except Exception as exc:
            out[key] = f"error:{exc}"
    try:
        hwnd = int(out.get("hwnd") or 0)
        rect = _IGBLUE_RECT()
        if hwnd and ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)):
            out["win32_rect"] = [int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)]
        if hwnd:
            out["win32_visible"] = bool(ctypes.windll.user32.IsWindowVisible(hwnd))
            out["win32_iconic"] = bool(ctypes.windll.user32.IsIconic(hwnd))
    except Exception as exc:
        out["win32_error"] = str(exc)
    try:
        out["primary_work_area"] = _igblue_primary_work_area(root)
    except Exception:
        pass
    return out



def st_token_file_path() -> Path:
    """RTC/Auto8700 공통 인증 토큰 파일. 배포 ZIP에는 포함하지 않는다."""
    return script_dir().resolve().parent / "_St.token"


def ensure_st_token_file() -> list[dict]:
    """SSH authorized_keys처럼 한 줄에 토큰 1개 + 선택 이름/comment를 허용한다."""
    path = st_token_file_path()
    entries: list[dict] = []
    try:
        if path.exists():
            for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
                raw = line.strip()
                if not raw or raw.startswith("#"):
                    continue
                parts = raw.split(None, 1)
                token_value = (parts[0] if parts else "").strip()
                comment = (parts[1] if len(parts) > 1 else "").strip()
                if len(token_value) >= 16:
                    entries.append({"token": token_value, "comment": comment})
    except Exception as exc:
        print(f"_St.token 읽기 실패: {exc}")

    if not entries:
        try:
            path.parent.mkdir(parents=True, exist_ok=True)
            token_value = secrets.token_urlsafe(32)
            text = "# _St shared tokens: one token per line, optional name/comment after whitespace\n"
            text += f"{token_value} rtc-auto8700-default\n"
            path.write_text(text, encoding="utf-8")
            entries.append({"token": token_value, "comment": "rtc-auto8700-default"})
            print(f"_St.token 자동 생성: {path}")
        except Exception as exc:
            print(f"_St.token 생성 실패: {exc}")
    return entries

def local_config_path() -> Path:
    return script_dir() / LOCAL_CONFIG_FILE_NAME


def default_config_path() -> Path:
    return script_dir() / DEFAULT_CONFIG_FILE_NAME


def legacy_config_path() -> Path:
    return script_dir() / LEGACY_CONFIG_FILE_NAME


def config_path() -> Path:
    """사용자 PC에 저장되는 local 설정 경로. 배포 ZIP에는 포함하지 않는다."""
    return local_config_path()


def expand_path_vars(value: str) -> Path:
    return Path(os.path.expandvars(str(value))).expanduser()


def normalize_path_text(value: str) -> str:
    raw = str(value or "").strip().strip('\"').strip("'")
    if not raw:
        return ""
    expanded = os.path.expandvars(raw)
    try:
        expanded = str(Path(expanded).expanduser())
    except Exception:
        pass
    expanded = expanded.replace("/", "\\") if is_windows() else expanded
    expanded = os.path.normpath(expanded)
    if is_windows():
        expanded = os.path.normcase(expanded)
    return expanded.rstrip("\\/")


def profile_key(user_data_dir: str, profile_directory: str) -> tuple[str, str]:
    return (normalize_path_text(user_data_dir).lower(), str(profile_directory or "Default").strip().lower())


def chrome_profile_number(profile_directory: str) -> Optional[int]:
    """사용자 입력/표시용 Chrome 프로필 번호. Default는 1, Profile N은 N으로 맞춘다."""
    name = str(profile_directory or "Default").strip()
    if name.lower() == "default":
        return 1
    m = re.fullmatch(r"Profile\s+(\d+)", name, re.IGNORECASE)
    if m:
        try:
            return int(m.group(1))
        except Exception:
            return None
    return None


def chrome_profile_sort_key(profile_directory: str) -> tuple[int, int, str]:
    name = str(profile_directory or "Default").strip()
    num = chrome_profile_number(name)
    if num is not None:
        # Default와 Profile 1 충돌이 생기면 Default를 먼저 표시한다.
        default_rank = 0 if name.lower() == "default" else 1
        return (0, int(num) * 10 + default_rank, name.lower())
    return (1, 999999, name.lower())


def profile_choice_id(profile: dict) -> str:
    explicit = profile.get("choice_id")
    if explicit:
        return str(explicit)
    num = chrome_profile_number(str(profile.get("profile_directory") or "Default"))
    if num is not None:
        return str(num)
    return ""


def assign_profile_choice_ids(profiles: List[dict]) -> List[dict]:
    """표시/입력 키를 실제 Chrome 프로필 번호 기준으로 부여한다."""
    used: set[str] = set()
    for idx, item in enumerate(profiles, start=1):
        base = profile_choice_id(item) or str(idx)
        choice = base
        # 일반적으로 Default=1, Profile 2=2 ... 로 충돌이 없다.
        # 만약 Profile 1처럼 충돌이 생기면 화면에서 구분되도록 보조 suffix를 붙인다.
        if choice in used:
            suffix = 2
            while f"{choice}.{suffix}" in used:
                suffix += 1
            choice = f"{choice}.{suffix}"
        item["choice_id"] = choice
        used.add(choice)
    return profiles


def runtime_dir() -> Path:
    temp_dir = Path(tempfile.gettempdir()) / "auto_service8700_manager_runtime"
    temp_dir.mkdir(parents=True, exist_ok=True)
    return temp_dir


def manager_restart_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("manager_restart") or {})
    user = cfg.get("manager_restart") or {}
    if isinstance(user, dict):
        base.update(user)
    return base


def manager_lock_path(cfg: dict) -> Path:
    mcfg = manager_restart_cfg(cfg)
    raw = mcfg.get("lock_file") or str(runtime_dir() / LOCK_FILE_NAME)
    return expand_path_vars(str(raw))


def manager_command_path(cfg: dict) -> Path:
    mcfg = manager_restart_cfg(cfg)
    raw = mcfg.get("command_file") or str(runtime_dir() / COMMAND_FILE_NAME)
    return expand_path_vars(str(raw))


def read_json_file(path: Path) -> Optional[dict]:
    try:
        if not path.exists():
            return None
        data = json.loads(path.read_text(encoding="utf-8-sig"))
        return data if isinstance(data, dict) else None
    except Exception:
        return None


def write_json_file(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(str(tmp), str(path))


def local_config_from_cfg(cfg: dict) -> dict:
    """패치로 덮어쓰지 않을 사용자 설정만 local.json에 저장한다."""
    keep = {
        "schema": "auto_service8700_manager_local_v154",
        "version": str(default_config().get("version") or "2.3.8.154"),
        "build_tag": str(default_config().get("build_tag") or "v154"),
        "description": "Auto8700 local user settings - do not distribute",
    }
    for key in ("ig_chrome", "auto_schedule", "auto8700_ui"):
        if isinstance(cfg.get(key), dict):
            keep[key] = cfg.get(key)
    return keep


def save_config(cfg: dict) -> None:
    """사용자 변경값은 local.json에만 저장한다. default.json은 배포 기준이라 런타임에서 덮어쓰지 않는다."""
    try:
        write_json_file(local_config_path(), local_config_from_cfg(cfg))
    except Exception as exc:
        log(f"[MANAGER] local 설정 저장 실패: {exc}", cfg)


def pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    if pid == os.getpid():
        return True
    if is_windows():
        try:
            proc = subprocess.run(
                ["tasklist", "/FI", f"PID eq {int(pid)}", "/FO", "CSV", "/NH"],
                text=True,
                encoding="utf-8",
                errors="ignore",
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
                timeout=5,
                check=False,
            )
            return str(int(pid)) in proc.stdout
        except Exception:
            return False
    try:
        os.kill(int(pid), 0)
        return True
    except OSError:
        return False


def kill_manager_pid(pid: int, cfg: dict, reason: str) -> None:
    """매니저 자기 교체용 PID 종료. 자식 서비스 보존을 위해 Windows에서 /T를 쓰지 않는다."""
    if pid <= 0 or pid == os.getpid():
        return
    if not pid_alive(pid):
        return
    log(f"[MANAGER] 이전 매니저 PID 종료: {pid} / reason={reason}", cfg)
    if is_windows():
        try:
            subprocess.run(["taskkill", "/PID", str(pid), "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12, check=False)
        except Exception as exc:
            log(f"[MANAGER] 이전 매니저 PID 종료 실패 pid={pid}: {exc}", cfg)
        return
    try:
        os.kill(int(pid), signal.SIGTERM)
    except Exception as exc:
        log(f"[MANAGER] 이전 매니저 PID 종료 실패 pid={pid}: {exc}", cfg)


def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(add_help=True)
    parser.add_argument("--takeover-pid", type=int, default=0, help="새 매니저가 종료할 이전 매니저 PID")
    parser.add_argument("--no-restart-on-start", action="store_true", help="매니저 교체 시 기존 서비스 재기동을 건너뜀")
    parser.add_argument("--request-restart", action="store_true", help="실행 중 매니저에게 재부팅 명령 파일만 생성하고 종료")
    parser.add_argument("--reason", default="manual", help="재부팅 사유")
    return parser.parse_args(argv)


def current_launch_cmd(extra_args: Sequence[str]) -> List[str]:
    if getattr(sys, "frozen", False):
        return [sys.executable, *extra_args]
    return [sys.executable, str(Path(__file__).resolve()), *extra_args]


def launch_reexec_process(cfg: dict, reason: str) -> Optional[subprocess.Popen]:
    args = ["--takeover-pid", str(os.getpid()), "--reason", reason]
    if bool(manager_restart_cfg(cfg).get("keep_children", True)):
        args.append("--no-restart-on-start")
    cmd = current_launch_cmd(args)
    log(f"[MANAGER] 새 매니저 선실행: {' '.join(cmd)}", cfg)
    try:
        if is_windows():
            flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NEW_CONSOLE
            return subprocess.Popen(cmd, cwd=str(script_dir()), creationflags=flags)
        return subprocess.Popen(cmd, cwd=str(script_dir()), start_new_session=True)
    except Exception as exc:
        log(f"[MANAGER] 새 매니저 실행 실패: {exc}", cfg)
        return None


def acquire_manager_lock(cfg: dict, takeover_pid: int = 0) -> None:
    path = manager_lock_path(cfg)
    old = read_json_file(path) or {}
    old_pid = int(old.get("pid") or 0)
    if takeover_pid > 0:
        kill_manager_pid(takeover_pid, cfg, reason="takeover_arg")
    elif bool(manager_restart_cfg(cfg).get("single_instance_takeover", True)) and old_pid and old_pid != os.getpid() and pid_alive(old_pid):
        kill_manager_pid(old_pid, cfg, reason="single_instance_takeover")
    data = {
        "pid": os.getpid(),
        "started_at": datetime.now().isoformat(timespec="seconds"),
        "script": str(Path(__file__).resolve()),
        "version": cfg.get("version"),
        "build_tag": cfg.get("build_tag"),
    }
    write_json_file(path, data)
    log(f"[MANAGER] lock 획득: {path} / pid={os.getpid()}", cfg)


def write_restart_request(cfg: dict, reason: str) -> Path:
    path = manager_command_path(cfg)
    data = {
        "cmd": "restart_manager",
        "reason": reason,
        "requested_at": datetime.now().isoformat(timespec="seconds"),
        "requester_pid": os.getpid(),
    }
    write_json_file(path, data)
    return path


def consume_manager_command(cfg: dict, last_seen: Optional[Tuple[int, int]]) -> Tuple[Optional[Tuple[int, int]], bool]:
    path = manager_command_path(cfg)
    try:
        st = path.stat()
    except FileNotFoundError:
        return None, False
    sig = (st.st_mtime_ns, st.st_size)
    if last_seen == sig:
        return sig, False
    data = read_json_file(path) or {}
    cmd = str(data.get("cmd") or data.get("command") or "").strip().lower()
    if cmd in ("restart_manager", "manager_restart", "self_restart", "reboot_manager"):
        reason = str(data.get("reason") or "command_file")
        log(f"[MANAGER] 재부팅 명령 감지: {path} / reason={reason}", cfg)
        proc = launch_reexec_process(cfg, reason=f"command:{reason}")
        if proc is not None:
            return sig, True
    return sig, False

def now_text() -> str:
    return datetime.now().strftime("%H:%M:%S")


ANSI_RESET = "\033[0m"
ANSI_DIM = "\033[2m"
ANSI_RED = "\033[31m"
ANSI_GREEN = "\033[32m"
ANSI_YELLOW = "\033[33m"
ANSI_BLUE = "\033[34m"
ANSI_MAGENTA = "\033[35m"
ANSI_CYAN = "\033[36m"
ANSI_BOLD = "\033[1m"
ANSI_GRAY = "\033[90m"
_ANSI_SETUP_DONE = False



def set_console_title(title: str) -> None:
    if not title or not is_windows():
        return
    try:
        safe = str(title).strip()
        # cmd title과 같은 기준으로 위험 문자를 제거한다.
        for ch in ['\r', '\n', '&', '|', '<', '>', '^']:
            safe = safe.replace(ch, ' ')
        safe = safe.strip() or 'Auto8700'
        ctypes.windll.kernel32.SetConsoleTitleW(safe)
    except Exception:
        pass

def enable_console_color(cfg: Optional[dict] = None) -> None:
    """Windows 콘솔에서 ANSI 색상을 보이게 한다. 실패해도 출력은 계속한다."""
    global _ANSI_SETUP_DONE
    if _ANSI_SETUP_DONE:
        return
    _ANSI_SETUP_DONE = True
    if cfg is not None and not bool(cfg.get("console_color", True)):
        return
    if os.environ.get("NO_COLOR"):
        return
    if not is_windows():
        return
    try:
        kernel32 = ctypes.windll.kernel32
        handle = kernel32.GetStdHandle(-11)  # STD_OUTPUT_HANDLE
        mode = ctypes.c_uint32()
        if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
            kernel32.SetConsoleMode(handle, mode.value | 0x0004)  # ENABLE_VIRTUAL_TERMINAL_PROCESSING
    except Exception:
        pass


def color_enabled(cfg: Optional[dict] = None) -> bool:
    if cfg is not None and not bool(cfg.get("console_color", True)):
        return False
    if os.environ.get("NO_COLOR"):
        return False
    enable_console_color(cfg)
    # Windows 콘솔은 VT 설정 성공 여부를 정확히 알기 어려우므로 isatty 기준으로만 후퇴한다.
    try:
        return bool(sys.stdout.isatty())
    except Exception:
        return True


def ctext(text: str, color: str, cfg: Optional[dict] = None) -> str:
    if not color_enabled(cfg):
        return str(text)
    return f"{color}{text}{ANSI_RESET}"


def status_color(status: str) -> str:
    status = str(status or "")
    if status == "파일변경":
        return ANSI_YELLOW + ANSI_BOLD
    if status == "최근활동":
        return ANSI_GREEN + ANSI_BOLD
    if status == "존재만":
        return ANSI_GRAY
    return ANSI_CYAN


def colored_status(status: str, cfg: Optional[dict] = None) -> str:
    return ctext(str(status or "존재만"), status_color(str(status or "존재만")), cfg)


def colored_key(key: str, cfg: Optional[dict] = None) -> str:
    k = str(key or "")
    colors = {
        "Y": ANSI_GREEN + ANSI_BOLD,
        "N": ANSI_RED + ANSI_BOLD,
        "Enter": ANSI_BLUE + ANSI_BOLD,
        "숫자": ANSI_MAGENTA + ANSI_BOLD,
    }
    return ctext(k, colors.get(k, ANSI_BOLD), cfg)


def ig_profile_input_prompt(cfg: dict) -> str:
    return (
        "[IG_CHROME] "
        + colored_key("Enter", cfg) + "=다시조회 / "
        + colored_key("숫자", cfg) + " 또는 2,26=실제 프로필 번호 새창 열기 / "
        + colored_key("Y", cfg) + " 프로필번호목록=지정 프로필 적용 / "
        + colored_key("N", cfg) + "=취소: "
    )


def ig_empty_profile_prompt(cfg: dict) -> str:
    return (
        "[IG_CHROME] Chrome User Data에서 프로필 폴더를 찾지 못했습니다. "
        + colored_key("Enter", cfg) + "=다시조회 / "
        + colored_key("N", cfg) + "=취소: "
    )


def _safe_log_filename(name: str) -> str:
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name or "service")).strip("._")
    return safe or "service"


def _service_log_dir(cfg: Optional[dict]) -> Path:
    root = r"log\services"
    try:
        root = str((cfg or {}).get("service_file_log_dir") or root)
    except Exception:
        root = r"log\services"
    path = Path(root)
    if not path.is_absolute():
        path = script_dir() / path
    return path


def _service_names(cfg: Optional[dict]) -> set[str]:
    try:
        return {str(s.get("name") or "") for s in (cfg or {}).get("services", []) if s.get("name")}
    except Exception:
        return set()


def _log_prefix_name(msg: str) -> str:
    m = re.match(r"^\[([^\]]+)\]", str(msg or ""))
    return m.group(1).strip() if m else ""


def _is_hidden_service_log(msg: str, cfg: Optional[dict]) -> bool:
    if not cfg:
        return False
    mode = str(cfg.get("service_console_log_mode") or "auto8700_only").strip().lower()
    if mode in ("all", "verbose", "show_all"):
        return False
    name = _log_prefix_name(msg)
    if not name:
        return False
    if name in ("MANAGER", "DEV8700", "DEV8707", "Auto8700", "AUTO_SERVICE8700_MANAGER"):
        return False
    return name in _service_names(cfg)


def _append_hidden_service_log(name: str, line: str, cfg: Optional[dict]) -> None:
    try:
        if cfg is not None and not bool(cfg.get("service_file_log", True)):
            return
        d = _service_log_dir(cfg)
        d.mkdir(parents=True, exist_ok=True)
        path = d / (_safe_log_filename(name) + ".log")
        with path.open("a", encoding="utf-8") as f:
            f.write(line + "\n")
    except Exception:
        pass


def log(msg: str, cfg: Optional[dict] = None) -> None:
    use_time = True if cfg is None else bool(cfg.get("log_time", True))
    line = f"[{now_text()}] {msg}" if use_time else str(msg)
    if _is_hidden_service_log(str(msg), cfg):
        _append_hidden_service_log(_log_prefix_name(str(msg)), line, cfg)
        return
    print(line, flush=True)


_PY_LOG_LOCK = threading.RLock()
_PY_LOG_FILES: List[object] = []
_ORIGINAL_STDOUT = sys.stdout
_ORIGINAL_STDERR = sys.stderr


class _TeeStream:
    def __init__(self, original, log_files):
        self.original = original
        if isinstance(log_files, (list, tuple)):
            self.log_files = [f for f in log_files if f is not None]
        elif log_files is None:
            self.log_files = []
        else:
            self.log_files = [log_files]
        self.encoding = getattr(original, "encoding", "utf-8")

    def write(self, data):
        try:
            self.original.write(data)
        except Exception:
            pass
        try:
            with _PY_LOG_LOCK:
                for f in list(self.log_files):
                    try:
                        f.write(data)
                        f.flush()
                    except Exception:
                        pass
        except Exception:
            pass

    def flush(self):
        try:
            self.original.flush()
        except Exception:
            pass
        try:
            with _PY_LOG_LOCK:
                for f in list(self.log_files):
                    try:
                        f.flush()
                    except Exception:
                        pass
        except Exception:
            pass

    def isatty(self):
        try:
            return self.original.isatty()
        except Exception:
            return False


_PY_LOG_CONTEXT: dict = {"run_log": "", "err_log": "", "keep_count": 10}


def _python_file_log_cfg(cfg: Optional[dict]) -> dict:
    legacy = {}
    try:
        v = (cfg or {}).get("python_file_logs")
        if isinstance(v, dict):
            legacy.update(v)
    except Exception:
        pass
    pcfg = {
        "enabled": True,
        "run_enabled": True,
        "error_enabled": True,
        "keep_count": 10,
        "log_dir": "logs",
        "run_prefix": "auto_run_log",
        "err_prefix": "auto_err_log",
        "trace": {"ws_request": True, "cdp_devtools": True},
    }
    if legacy:
        if "enabled" in legacy:
            pcfg["enabled"] = bool(legacy.get("enabled"))
        if "keep_files" in legacy:
            pcfg["keep_count"] = legacy.get("keep_files")
        if "root_dir" in legacy:
            pcfg["log_dir"] = legacy.get("root_dir") or "logs"
        if "prefix" in legacy:
            legacy_prefix = _safe_log_filename(str(legacy.get("prefix") or "auto"))
            pcfg["run_prefix"] = f"{legacy_prefix}_run_log"
            pcfg["err_prefix"] = f"{legacy_prefix}_err_log"
    try:
        extra = (cfg or {}).get("python_logs")
        if isinstance(extra, dict):
            pcfg.update(extra)
            tr = dict(pcfg.get("trace") or {})
            tr.update(extra.get("trace") or {})
            pcfg["trace"] = tr
    except Exception:
        pass
    try:
        pcfg["keep_count"] = max(1, min(100, int(pcfg.get("keep_count") or 10)))
    except Exception:
        pcfg["keep_count"] = 10
    pcfg["enabled"] = bool(pcfg.get("enabled", True))
    pcfg["run_enabled"] = bool(pcfg.get("run_enabled", True))
    pcfg["error_enabled"] = bool(pcfg.get("error_enabled", True))
    pcfg["run_prefix"] = _safe_log_filename(str(pcfg.get("run_prefix") or "auto_run_log"))
    pcfg["err_prefix"] = _safe_log_filename(str(pcfg.get("err_prefix") or "auto_err_log"))
    return pcfg


def _prune_python_logs(root: Path, keep: int, run_prefix: str = "auto_run_log", err_prefix: str = "auto_err_log") -> None:
    try:
        for pattern in (f"{run_prefix}_*.log", f"{err_prefix}_*.log"):
            files = []
            for fp in root.glob(pattern):
                try:
                    files.append((fp.stat().st_mtime, fp))
                except Exception:
                    pass
            files.sort(key=lambda item: item[0], reverse=True)
            for _mtime, fp in files[int(keep):]:
                try:
                    fp.unlink()
                except Exception:
                    pass
    except Exception:
        pass


def auto8700_python_log_child_env() -> dict:
    # v166: 통합 로그는 폐기. 자식 py는 자기 logs 폴더에만 기록한다.
    return os.environ.copy()


def _close_python_file_logs() -> None:
    try:
        sys.stdout = _ORIGINAL_STDOUT
        sys.stderr = _ORIGINAL_STDERR
    except Exception:
        pass
    for f in list(_PY_LOG_FILES):
        try:
            f.flush()
            f.close()
        except Exception:
            pass
    _PY_LOG_FILES.clear()


def reset_python_log_files_on_start(cfg: Optional[dict] = None) -> None:
    """v166: logs 폴더에 auto_run_log/auto_err_log를 시작별로 만든다."""
    pcfg = _python_file_log_cfg(cfg)
    if not pcfg.get("enabled", True):
        return
    try:
        root = Path(str(pcfg.get("log_dir") or "logs"))
        if not root.is_absolute():
            root = script_dir() / root
        root.mkdir(parents=True, exist_ok=True)
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        pid = os.getpid()
        run_prefix = str(pcfg.get("run_prefix") or "auto_run_log")
        err_prefix = str(pcfg.get("err_prefix") or "auto_err_log")
        run_path = root / f"{run_prefix}_{stamp}_pid{pid}.log"
        err_path = root / f"{err_prefix}_{stamp}_pid{pid}.log"
        run_f = run_path.open("a", encoding="utf-8", buffering=1) if bool(pcfg.get("run_enabled", True)) else None
        err_f = err_path.open("a", encoding="utf-8", buffering=1) if bool(pcfg.get("error_enabled", True)) else None
        for f in (run_f, err_f):
            if f is not None:
                _PY_LOG_FILES.append(f)
        if run_f is not None:
            sys.stdout = _TeeStream(_ORIGINAL_STDOUT, run_f)
            _PY_LOG_CONTEXT["run_log"] = str(run_path)
        if err_f is not None:
            sys.stderr = _TeeStream(_ORIGINAL_STDERR, err_f)
            _PY_LOG_CONTEXT["err_log"] = str(err_path)
        _PY_LOG_CONTEXT["keep_count"] = int(pcfg.get("keep_count") or 10)
        import atexit as _atexit
        _atexit.register(_close_python_file_logs)
        _prune_python_logs(root, int(pcfg.get("keep_count") or 10), run_prefix, err_prefix)
        banner = f"[{now_text()}] PY_LOG_START app=auto8700 pid={pid} run={_PY_LOG_CONTEXT.get('run_log','')} err={_PY_LOG_CONTEXT.get('err_log','')} keep={pcfg.get('keep_count')} mode=project"
        print(banner, flush=True)
        if err_f is not None:
            try:
                # stderr is already tee'd to err_f; write directly to the file object to avoid duplicate console banner.
                err_f.write(banner + "\n")
                err_f.flush()
            except Exception:
                pass
    except Exception:
        try:
            print("[PY_LOG] start failed", file=sys.stderr, flush=True)
        except Exception:
            pass

def default_config() -> dict:
    return {
        "schema": "auto_service8700_manager_v250",
        "version": "2.3.8.250",
        "build_tag": "v250",
        "description": "Auto8700 v250: recover managed services while alive; master shutdown keeps Chrome and Analyze8780 alive",
        "poll_interval_sec": DEFAULT_POLL_INTERVAL,
        "debounce_sec": DEFAULT_DEBOUNCE_SEC,
        "restart_on_start": True,
        "kill_on_exit": True,
        "start_missing_on_manager_start": False,
        "restart_only_after_seen_alive": True,
        "restart_if_process_exits": True,
        "restart_if_ports_down": True,
        "port_down_grace_sec": 8.0,
        "startup_health_timeout_sec": 30.0,
        "restart_cooldown_sec": 2.0,
        "log_time": True,
        "console_color": True,
        "python_logs": {"enabled": True, "run_enabled": True, "error_enabled": True, "keep_count": 10, "log_dir": "logs", "run_prefix": "auto_run_log", "err_prefix": "auto_err_log", "trace": {"ws_request": True, "cdp_devtools": True}},
        "python_file_logs": {"enabled": True, "keep_files": 10, "root_dir": "logs", "prefix": "auto8700"},
        "manager_restart": {
            "enabled": True,
            "mode": "self_reexec",
            "keep_children": True,
            "command_file": "%TEMP%\\auto_service8700_manager_runtime\\auto_service8700_manager_command.json",
            "lock_file": "%TEMP%\\auto_service8700_manager_runtime\\auto_service8700_manager.lock.json",
            "single_instance_takeover": True
        },
        "ignore_dirs": ["__pycache__", "log", "logs", ".git", ".venv", "venv", "node_modules"],
        "ignore_files": ["auto_service8700_manager.json", "auto_service8700_manager.default.json", "auto_service8700_manager.local.json"],
        "ignore_name_patterns": ["test_*", "*.bak", "*.old", "*.copy", "*~", ".#*"],
        "ignore_extensions": [".json", ".jsonl", ".log", ".tmp", ".lock", ".conf", ".cfg", ".ini", ".toml", ".yaml", ".yml", ".env", ".properties"],
        "ig_chrome": {
            "enabled": True,
            "configured": False,
            "remote_debugging_port": 8700,
            "chrome_exe": "%PROGRAMFILES%\\Google\\Chrome\\Application\\chrome.exe",
            "user_data_dir": "%LOCALAPPDATA%\\Google\\Chrome\\User Data",
            "cdp_user_data_dir": DEFAULT_CDP_USER_DATA_DIR,
            "cdp_profile_directory": DEFAULT_CDP_PROFILE_DIRECTORY,
            "profile_scan_mode": "user_data_activity",
            "active_profile_seconds": 600,
            "activity_probe_paths": [
                "Network\\Reporting and NEL-journal",
                "Network\\Network Persistent State",
                "Preferences",
                "History",
                "Cookies",
                "Sessions",
                "Session Storage",
                "Local Storage"
            ],
            "startup_url": "https://www.instagram.com/explore/",
            "profiles": [],
            "force_user_data_dir": False,
            "preview_launch_in_threads": True,
            "preview_launch_gap_sec": 0.25,
            "launch_on_manager_start": True,
            "relaunch_if_closed": False,
            "debug_profile_policy": "first_only",
            "extension": {
                "enabled": False,
                "path": "D:\\_St\\IG_Ext",
                "reload_mode": "chrome_profile_restart"
            }
        },
        "auto8700_event_server": {
            "enabled": True,
            "host": "127.0.0.1",
            "port": 8707,
            "open_browser_on_start": False
        },
        "devtools_control_marker": {
            "enabled": True,
            "ws_ok_devtools_ok_prefix": "❤️❤️❤️",
            "ws_down_devtools_ok_prefix": "❤️💛❤️",
            "ws_ok_devtools_down_prefix": "❤️❤️💛",
            "ws_down_devtools_down_prefix": "❤️💛💛",
            "manual_prefix": "💛💛💛",
            "prefer_url_contains": "/explore/",
            "status_poll_sec": 5.0
        },
        "devtools_network_watch": {
            "enabled": False,
            "console": True,
            "file_log": False,
            "log_dir": "log\\devtools_network",
            "verbose": False,
            "capture_body": False,
            "max_body_chars": 2000,
            "print_media": True,
            "print_json": True,
            "print_graphql": True
        },
        "auto_schedule": {
            "enabled": False,
            "time": "00:00",
            "schedule1_enabled": False,
            "schedule1_time": "00:00",
            "schedule2_enabled": False,
            "schedule2_time": "00:00",
            "last_run_date": "",
            "last_run_date_1": "",
            "last_run_date_2": "",
            "last_delegated_date": "",
            "last_delegated_date_1": "",
            "last_delegated_date_2": "",
            "last_request_id": "",
            "open_side": True,
            "cdp_storage_key": "IGBLUE_AUTO8700_SCHEDULE_V1",
            "ig_side_heartbeat_ts": 0,
            "ig_side_heartbeat_iso": "",
            "revision": 0,
            "updated_at": "",
            "updated_source": ""
        },
        "cdp_mode_policy": {
            "precheck_before_services": True,
            "block_default_browser_before_cdp": True,
            "disable_rtc_viewer_on_manager_start": True,
            "auto_download_allowed_without_cdp": False,
            "schedule_requires_cdp_mode": True
        },
        "auto8700_ui": {
            "enabled": True,
            "alpha": 0.82,
            "x": 90,
            "y": 90,
            "stage": 0,
            "expanded_width": 1120,
            "compact_width": 520,
            "bar_height": 42
        },
        "services": [
            {
                "name": "WS8771",
                "enabled": True,
                "restart_strategy": "kill_then_start",
                "ports": [8771],
                "health_ports": [8771],
                "health_mode": "listen_only",
                "kill_ports_on_start": True,
                "kill_ports_on_restart": True,
                "watch_dirs": [r"D:\_St\WS8771"],
                "watch_files": ["ws8771_main.py", "ws8771_ig_sort.py", "ws8771_folder_reconcile.py", "ws8771_html_report.py", "run_WS8771.bat"],
                "patterns": ["*.py", "*.bat"],
                "recursive": True,
                "work_dir": r"D:\_St\WS8771",
                "target": "_blank",
                "show_console": True,
                "bat_text": "@echo on\r\ncd /d \"D:\\_St\\WS8771\"\r\ncall py312 ws8771_main.py -v\r\npause\r\n",
            },
            {
                "name": "Analyze8780",
                "enabled": True,
                "start_on_manager_start": True,
                "restart_strategy": "kill_then_start",
                "ports": [8780],
                "health_ports": [8780],
                "health_mode": "listen_only",
                "health_path": "/api/health",
                "startup_api_health_once": True,
                "runtime_health_check": False,
                "health_expect_key": "ok",
                "health_expect_value": True,
                "kill_ports_on_start": True,
                "kill_ports_on_restart": True,
                "watch_dirs": [r"D:\_St\Analyze8780"],
                "watch_files": ["analyze8780_main.py", "analyze8780_repository.py", "run_Analyze8780.bat", "web/index.html"],
                "patterns": ["*.py", "*.html", "*.bat"],
                "recursive": True,
                "work_dir": r"D:\_St\Analyze8780",
                "target": "_blank",
                "show_console": True,
                "bat_text": "@echo on\r\ncd /d \"D:\\_St\\Analyze8780\"\r\ncall py312 analyze8780_main.py --host 127.0.0.1 --port 8780\r\npause\r\n",
            },
            {
                "name": "RTC8790_8791",
                "enabled": True,
                "restart_strategy": "kill_then_start",
                "ports": [8790, 8791],
                "health_ports": [8790, 8791],
                "health_mode": "listen_only",
                "kill_ports_on_start": True,
                "kill_ports_on_restart": True,
                "watch_dirs": [r"D:\_St\RTC8790_8791"],
                "watch_files": ["rtc8790_8791_main.py", "yjm_ai_snapshot_ring.py", "yjm_capture_sources.py", "yjm_clipboard_sync.py", "yjm_file_transfer.py", "yjm_monitor_probe.py", "yjm_net_upnp.py", "yjm_remote_control.py", "yjm_remote_status.py", "yjm_settings.py", "yjm_win2rtc_capture.py", "host/__init__.py", "host/clipboard.py", "host/protocol.py", "web/viewer.html", "requirements.txt", "run_RTC8790_8791.bat"],
                "patterns": ["*.py", "*.html", "*.js", "*.bat"],
                "recursive": True,
                "work_dir": r"D:\_St\RTC8790_8791",
                "target": "_blank",
                "show_console": True,
                "bat_text": "@echo on\r\ncd /d \"D:\\_St\\RTC8790_8791\"\r\ncall py312inst -r requirements.txt\r\ncall py312 rtc8790_8791_main.py -v --host 0.0.0.0 --http-port 8790 --ws-port 8791 --fps 12 --quality 70 --max-width 1280 --auto-start --viewer no --control on --source desktop\r\npause\r\n",
            },
            {
                "name": "AUTO_SERVICE8700_MANAGER",
                "enabled": True,
                "restart_strategy": "start_then_self_takeover",
                "ports": [],
                "health_ports": [],
                "health_mode": "none",
                "kill_ports_on_start": False,
                "kill_ports_on_restart": False,
                "watch_dirs": [r"D:\_St\Auto8700"],
                "watch_files": ["auto_service8700_manager.py", "run_auto_service8700_manager.bat"],
                "patterns": ["*.py", "*.bat"],
                "recursive": True,
                "start_on_manager_start": False,
                "ignore_dirs": ["__pycache__", "log", "logs"],
                "ignore_files": ["auto_service8700_manager.default.json", "auto_service8700_manager.local.json", "auto_service8700_manager.json"],
                "ignore_extensions": [".json", ".jsonl", ".log", ".tmp", ".lock"],
                "work_dir": r"D:\_St\Auto8700",
                "target": "_blank",
                "restart_cooldown_sec": 3.0,
                "bat_text": "@echo on\r\ncd /d \"D:\\_St\\Auto8700\"\r\ncall py312 auto_service8700_manager.py\r\n",
            },
        ],
        "shared_watch": {
            "name": "Shared",
            "enabled": True,
            "watch_dirs": [r"D:\_St\Shared"],
            "patterns": ["*.py", "*.bat"],
            "recursive": True,
            "restart_services": ["WS8771", "Analyze8780", "RTC8790_8791"],
        },
        "bat_catalog": {
            "AUTO_SERVICE8700_MANAGER": "@echo on\r\ncd /d \"D:\\_St\\Auto8700\"\r\ncall py312 auto_service8700_manager.py\r\n",
            "WS8771": "@echo on\r\ncd /d \"D:\\_St\\WS8771\"\r\ncall py312 ws8771_main.py -v\r\npause\r\n",
            "Analyze8780": "@echo on\r\ncd /d \"D:\\_St\\Analyze8780\"\r\ncall py312 analyze8780_main.py --host 127.0.0.1 --port 8780\r\npause\r\n",
            "RTC8790_8791": "@echo on\r\ncd /d \"D:\\_St\\RTC8790_8791\"\r\ncall py312inst -r requirements.txt\r\ncall py312 rtc8790_8791_main.py -v --host 0.0.0.0 --http-port 8790 --ws-port 8791 --fps 12 --quality 70 --max-width 1280 --auto-start --viewer no --control on --source desktop\r\npause\r\n",
        },
    }


def deep_merge_dict(base: dict, override: dict) -> dict:
    """dict는 재귀 병합하고 list/스칼라는 override를 우선한다."""
    result = dict(base or {})
    if not isinstance(override, dict):
        return result
    for key, value in override.items():
        if isinstance(value, dict) and isinstance(result.get(key), dict):
            result[key] = deep_merge_dict(result.get(key) or {}, value)
        else:
            result[key] = value
    return result


def load_default_file_config() -> dict:
    """배포 기준 default.json을 읽고 없으면 코드 기본값으로 생성한다."""
    path = default_config_path()
    data = read_json_file(path)
    if isinstance(data, dict):
        return data
    cfg = default_config()
    try:
        write_json_file(path, cfg)
        print(f"{DEFAULT_CONFIG_FILE_NAME} 이 없어서 기본 설정을 생성했습니다: {path}")
    except Exception as exc:
        print(f"{DEFAULT_CONFIG_FILE_NAME} 생성 실패: {path}\n{exc}")
    return cfg


def normalize_loaded_config(cfg: dict, user_cfg: dict) -> dict:
    """기존 load_config 후처리 기준을 default/local 병합 구조에도 동일 적용한다."""
    dcfg = default_config()
    if "services" not in user_cfg:
        cfg["services"] = dcfg["services"]
    if "shared_watch" not in user_cfg:
        cfg["shared_watch"] = dcfg["shared_watch"]
    if "bat_catalog" not in user_cfg:
        cfg["bat_catalog"] = dcfg["bat_catalog"]
    if "ig_chrome" not in user_cfg:
        cfg["ig_chrome"] = dcfg["ig_chrome"]
    if "auto_schedule" not in user_cfg:
        cfg["auto_schedule"] = dcfg["auto_schedule"]
    else:
        base_schedule = dict(dcfg["auto_schedule"])
        if isinstance(cfg.get("auto_schedule"), dict):
            base_schedule.update(cfg.get("auto_schedule") or {})
        cfg["auto_schedule"] = base_schedule
    if "cdp_mode_policy" not in user_cfg:
        cfg["cdp_mode_policy"] = dcfg.get("cdp_mode_policy", {})
    else:
        base_policy = dict(dcfg.get("cdp_mode_policy") or {})
        if isinstance(cfg.get("cdp_mode_policy"), dict):
            base_policy.update(cfg.get("cdp_mode_policy") or {})
        cfg["cdp_mode_policy"] = base_policy
    if "auto8700_ui" not in user_cfg:
        cfg["auto8700_ui"] = dcfg["auto8700_ui"]
    else:
        base_ui = dict(dcfg["auto8700_ui"])
        if isinstance(cfg.get("auto8700_ui"), dict):
            base_ui.update(cfg.get("auto8700_ui") or {})
        cfg["auto8700_ui"] = base_ui
    cfg["schema"] = dcfg.get("schema")
    cfg["version"] = dcfg.get("version")
    cfg["build_tag"] = dcfg.get("build_tag")
    cfg["description"] = dcfg.get("description")
    # v163 안전 기준: 오래된 runtime/local json이 start_missing/restart_only 값을 덮어써도
    # manager_start 무한 실행과 포트 미확인 재실행을 막는다.
    cfg["start_missing_on_manager_start"] = False
    cfg["restart_only_after_seen_alive"] = True
    cfg["ig_chrome"] = normalize_ig_chrome_cfg(cfg.get("ig_chrome") or {})
    cfg["services"] = [normalize_watch_entry(s) for s in cfg.get("services", [])]
    if cfg.get("shared_watch"):
        cfg["shared_watch"] = normalize_watch_entry(cfg["shared_watch"])
    enforce_cdp_mode_runtime_policy(cfg)
    return cfg


def load_config() -> dict:
    default_file_cfg = load_default_file_config()
    cfg = deep_merge_dict(default_config(), default_file_cfg)
    merged_user: dict = {}

    local = read_json_file(local_config_path())
    # v163 기준: 실제 운영 파일은 auto_service8700_manager.json도 읽는다.
    # 배포 default는 기본틀, runtime json은 현장 기준, local은 프로필/예약/UI 같은 사용자 보존값이다.
    # 단, normalize_watch_entry/merged_watch_ignore에서 위험한 구버전 감시 옵션은 안전 기준으로 강제 보정한다.
    legacy = read_json_file(legacy_config_path())

    if isinstance(legacy, dict):
        merged_user = deep_merge_dict(merged_user, legacy)
    if isinstance(local, dict):
        merged_user = deep_merge_dict(merged_user, local)

    cfg = deep_merge_dict(cfg, merged_user)
    cfg = normalize_loaded_config(cfg, merged_user)

    # local.json이 없는 기존 설치본은 프로필/예약/UI만 local.json으로 1회 이관한다. 이후 패치 ZIP은 local.json을 배포하지 않는다.
    if isinstance(legacy, dict) and not isinstance(local, dict):
        try:
            write_json_file(local_config_path(), local_config_from_cfg(cfg))
            print(f"기존 {LEGACY_CONFIG_FILE_NAME} 설정을 {LOCAL_CONFIG_FILE_NAME}로 이관했습니다: {local_config_path()}")
        except Exception as exc:
            print(f"local 설정 이관 실패: {exc}")
    return cfg


def normalize_watch_entry(entry: dict) -> dict:
    e = dict(entry)
    if "watch_dir" in e and "watch_dirs" not in e:
        e["watch_dirs"] = [e.pop("watch_dir")]
    e.setdefault("watch_dirs", [])
    e.setdefault("watch_files", [])
    e.setdefault("patterns", ["*.py"])
    e.setdefault("recursive", True)
    e.setdefault("enabled", True)
    strategy = str(e.get("restart_strategy") or "kill_then_start").strip().lower()
    strategy_aliases = {
        "prekill": "kill_then_start",
        "kill_first": "kill_then_start",
        "kill_then_run": "kill_then_start",
        "start_then_takeover": "start_then_self_takeover",
        "self_takeover": "start_then_self_takeover",
        "takeover": "start_then_self_takeover",
    }
    strategy = strategy_aliases.get(strategy, strategy)
    if strategy not in ("kill_then_start", "start_then_self_takeover"):
        strategy = "kill_then_start"
    e["restart_strategy"] = strategy
    if "ports" in e:
        e["ports"] = [int(x) for x in e.get("ports") or []]
    if "health_ports" in e:
        e["health_ports"] = [int(x) for x in e.get("health_ports") or []]
    elif "ports" in e:
        e["health_ports"] = [int(x) for x in e.get("ports") or []]
    # 기본값은 listen_only다. WebSocket 포트에 빈 TCP 접속을 만들면 서버가 handshake 실패 traceback을 찍는다.
    e.setdefault("health_mode", "listen_only")
    target = str(e.get("target") or "self").strip().lower()
    if bool(e.get("show_console", False)):
        target = "_blank"
    if target in ("", "current", "same", "_self"):
        target = "self"
    elif target in ("blank", "new", "new_console", "console", "_blank"):
        target = "_blank"
    else:
        target = "self"
    e["target"] = target
    e["show_console"] = target == "_blank"

    name = str(e.get("name") or "").strip().upper()

    # 모든 서비스 감시는 생성물/로그/런타임 파일을 전역 기준으로 무시한다.
    # 서비스별 ignore_*가 빈 배열이어도 전역 ignore와 병합되므로 여기서는 보조 기본값만 넣는다.
    e.setdefault("ignore_dirs", [])
    e.setdefault("ignore_files", [])
    e.setdefault("ignore_name_patterns", [])
    e.setdefault("ignore_extensions", [])
    for d in ["__pycache__", "log", "logs"]:
        if d not in e["ignore_dirs"]:
            e["ignore_dirs"].append(d)
    for ext in [".json", ".jsonl", ".log", ".tmp", ".lock"]:
        if ext not in e["ignore_extensions"]:
            e["ignore_extensions"].append(ext)

    default_watch_files = {
        "WS8771": ["ws8771_main.py", "ws8771_ig_sort.py", "ws8771_folder_reconcile.py", "ws8771_html_report.py", "run_WS8771.bat"],
        "ANALYZE8780": ["analyze8780_main.py", "analyze8780_repository.py", "run_Analyze8780.bat", "web/index.html"],
        "RTC8790_8791": ["rtc8790_8791_main.py", "yjm_ai_snapshot_ring.py", "yjm_capture_sources.py", "yjm_clipboard_sync.py", "yjm_file_transfer.py", "yjm_monitor_probe.py", "yjm_net_upnp.py", "yjm_remote_control.py", "yjm_remote_status.py", "yjm_settings.py", "yjm_win2rtc_capture.py", "host/__init__.py", "host/clipboard.py", "host/protocol.py", "web/viewer.html", "requirements.txt", "run_RTC8790_8791.bat"],
        "AUTO_SERVICE8700_MANAGER": ["auto_service8700_manager.py", "run_auto_service8700_manager.bat"],
    }
    if name in default_watch_files and not list(e.get("watch_files") or []):
        e["watch_files"] = list(default_watch_files[name])

    if name == "WS8771":
        pats = list(e.get("patterns") or [])
        if "*.bat" not in pats:
            pats.append("*.bat")
        e["patterns"] = pats
    elif name == "ANALYZE8780":
        pats = list(e.get("patterns") or [])
        for pat in ["*.py", "*.html", "*.bat"]:
            if pat not in pats:
                pats.append(pat)
        e["patterns"] = pats
        e.setdefault("start_on_manager_start", True)
        e["health_mode"] = "listen_only"
        e.setdefault("health_path", "/api/health")
        e.setdefault("startup_api_health_once", True)
        e.setdefault("runtime_health_check", False)
        e.setdefault("health_expect_key", "ok")
        e.setdefault("health_expect_value", True)
    elif name == "RTC8790_8791":
        pats = list(e.get("patterns") or [])
        for pat in ["*.py", "*.html", "*.js", "*.bat"]:
            if pat not in pats:
                pats.append(pat)
        e["patterns"] = pats

    if name == "AUTO_SERVICE8700_MANAGER":
        # 시작 시 자기 자신을 한 번 더 실행하면 manager_start 루프가 생긴다.
        # 단, 명시된 manager 소스/실행 BAT 변경으로 인한 자기 재실행은 유지한다.
        e["start_on_manager_start"] = False
        e["restart_strategy"] = "start_then_self_takeover"
        e["watch_files"] = list(default_watch_files["AUTO_SERVICE8700_MANAGER"])
        e["patterns"] = ["*.py", "*.bat"]
        e["recursive"] = False
        e.setdefault("ignore_dirs", [])
        e.setdefault("ignore_files", [])
        e.setdefault("ignore_extensions", [])
        for d in ["__pycache__", "log", "logs"]:
            if d not in e["ignore_dirs"]:
                e["ignore_dirs"].append(d)
        for f in ["auto_service8700_manager.default.json", "auto_service8700_manager.local.json", "auto_service8700_manager.json"]:
            if f not in e["ignore_files"]:
                e["ignore_files"].append(f)
        for ext in [".json", ".jsonl", ".log", ".tmp", ".lock"]:
            if ext not in e["ignore_extensions"]:
                e["ignore_extensions"].append(ext)
    return e



def normalize_ig_chrome_cfg(entry: dict) -> dict:
    base = dict(default_config().get("ig_chrome") or {})
    if isinstance(entry, dict):
        base.update(entry)
    try:
        port = int(base.get("remote_debugging_port") or 8700)
    except Exception:
        port = 8700
    base["remote_debugging_port"] = port
    cdp_dir = normalize_path_text(str(base.get("cdp_user_data_dir") or DEFAULT_CDP_USER_DATA_DIR))
    base["cdp_user_data_dir"] = cdp_dir or DEFAULT_CDP_USER_DATA_DIR
    base["cdp_profile_directory"] = str(base.get("cdp_profile_directory") or DEFAULT_CDP_PROFILE_DIRECTORY).strip() or DEFAULT_CDP_PROFILE_DIRECTORY
    profiles = base.get("profiles") or []
    if not isinstance(profiles, list):
        profiles = []
    clean_profiles: List[dict] = []
    seen_profiles: set[tuple[str, str]] = set()
    for item in profiles:
        if isinstance(item, str):
            d = {"profile_directory": item}
        elif isinstance(item, dict):
            d = dict(item)
        else:
            continue
        prof = str(d.get("profile_directory") or d.get("profile") or "").strip()
        if not prof:
            continue
        d["profile_directory"] = prof
        if d.get("user_data_dir"):
            d["user_data_dir"] = normalize_path_text(str(d.get("user_data_dir")))
        key = profile_key(str(d.get("user_data_dir") or base.get("user_data_dir") or ""), prof)
        if key in seen_profiles:
            continue
        seen_profiles.add(key)
        clean_profiles.append(d)
    base["profiles"] = clean_profiles
    base["configured"] = bool(base.get("configured")) and bool(clean_profiles)
    return base


def chrome_cfg(cfg: dict) -> dict:
    return normalize_ig_chrome_cfg(cfg.get("ig_chrome") or {})


def chrome_debug_port(cfg: dict) -> int:
    return int(chrome_cfg(cfg).get("remote_debugging_port") or 8700)


def chrome_debug_url(cfg: dict, path: str = "/json/version") -> str:
    port = chrome_debug_port(cfg)
    if not path.startswith("/"):
        path = "/" + path
    return f"http://127.0.0.1:{port}{path}"


def http_json_get(url: str, timeout: float = 1.2) -> Optional[object]:
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "auto_service8700_manager"})
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8", errors="ignore")
        return json.loads(raw)
    except Exception:
        return None


def chrome_debug_alive(cfg: dict) -> bool:
    data = http_json_get(chrome_debug_url(cfg, "/json/version"), timeout=1.0)
    if not isinstance(data, dict):
        return False
    browser = str(data.get("Browser") or data.get("browser") or "")
    return "Chrome" in browser or "Chromium" in browser


def instagram_download_root() -> Path:
    """Return the fixed sibling _StDown/instagram root used by CDP downloads."""
    st_root = script_dir().parent.resolve()
    if st_root.name != "_St":
        raise ValueError(f"Auto8700 must run below the _St directory: {st_root}")
    return st_root.parent / "_StDown" / "instagram"


def chrome_browser_websocket_url(cfg: dict) -> str:
    data = http_json_get(chrome_debug_url(cfg, "/json/version"), timeout=1.2)
    if not isinstance(data, dict):
        return ""
    return str(data.get("webSocketDebuggerUrl") or "").strip()


def chrome_tabs(cfg: dict) -> List[dict]:
    data = http_json_get(chrome_debug_url(cfg, "/json/list"), timeout=1.5)
    if isinstance(data, list):
        return [x for x in data if isinstance(x, dict)]
    return []


def chrome_cdp_mode_status(cfg: dict) -> dict:
    """8700 CDP모드 여부를 먼저 판단한다. 기존 Chrome 종료/재시작은 여기서 절대 하지 않는다."""
    port = chrome_debug_port(cfg)
    version = http_json_get(chrome_debug_url(cfg, "/json/version"), timeout=0.8)
    alive = False
    browser = ""
    if isinstance(version, dict):
        browser = str(version.get("Browser") or version.get("browser") or "")
        alive = "Chrome" in browser or "Chromium" in browser
    processes = query_chrome_processes_windows() if is_windows() else []
    chrome_pids: List[int] = []
    cdp_pids: List[int] = []
    non_cdp_pids: List[int] = []
    command_lines: List[str] = []
    for proc in processes:
        cmd = str(proc.get("CommandLine") or "")
        if not cmd:
            continue
        if re.search(r"--type(?:=|\s+)", cmd, re.IGNORECASE):
            continue
        try:
            pid = int(proc.get("ProcessId") or 0)
        except Exception:
            pid = 0
        if pid > 0:
            chrome_pids.append(pid)
        debug_port = parse_cmd_arg(cmd, "remote-debugging-port")
        if str(debug_port or "").strip() == str(port):
            if pid > 0:
                cdp_pids.append(pid)
        else:
            if pid > 0:
                non_cdp_pids.append(pid)
        if cmd and len(command_lines) < 3:
            command_lines.append(cmd[:360])
    if alive:
        reason = "cdp_mode_ok"
    elif chrome_pids:
        reason = "chrome_running_without_cdp_mode"
    else:
        reason = "cdp_port_closed_no_chrome"
    return {
        "ok": bool(alive),
        "alive": bool(alive),
        "port": port,
        "reason": reason,
        "browser": browser,
        "chrome_running": bool(chrome_pids),
        "chrome_pids": chrome_pids,
        "cdp_pids": cdp_pids,
        "non_cdp_pids": non_cdp_pids,
        "command_lines": command_lines,
    }


def cdp_mode_warning_message(port: Optional[int] = None) -> str:
    port = int(port or 8700)
    return (
        "현재 CDP모드가 아닙니다.\n\n"
        f"{port} CDP 포트가 열려 있지 않습니다.\n"
        "예약 실행은 CDP모드가 필요합니다.\n\n"
        "Auto8700 전용 CDP user-data-dir로 별도 CDP Chrome을 실행합니다.\n"
        f"기본 CDP user-data-dir: {DEFAULT_CDP_USER_DATA_DIR}\n\n"
        "CDP모드 실행 후 처음 한 번은 전용 Chrome에서 Instagram 로그인이 필요할 수 있습니다.\n"
        "CDP:X 상태에서도 자동 다운로드는 가능하고, 예약 실행만 비활성화됩니다."
    )


def disable_auto_schedule_due_to_cdp_mode(cfg: dict, reason: str = "cdp_mode_cancelled") -> None:
    try:
        scfg = auto_schedule_cfg(cfg)
        if scfg.get("enabled"):
            scfg["enabled"] = False
            cfg["auto_schedule"] = scfg
            save_config(cfg)
        AUTO8700_SCHEDULE_STATE.update({
            "remaining_text": "예약 꺼짐",
            "next_at": "",
            "cdp_ready": False,
            "cdp_error": "cdp_mode_required",
            "last_result": "CDP모드 취소: 예약 비활성화",
            "cdp_cancel_reason": reason,
        })
        log(f"[CDP_MODE] CDP모드 취소/불가 → 예약 비활성화 / reason={reason}", cfg)
    except Exception as exc:
        try:
            log(f"[CDP_MODE] 예약 비활성화 실패: {exc}", cfg)
        except Exception:
            pass


def cdp_mode_policy_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("cdp_mode_policy") or {})
    user = cfg.get("cdp_mode_policy") or {}
    if isinstance(user, dict):
        base.update(user)
    return base


def sanitize_no_default_browser_bat_text(bat_text: str) -> str:
    """Auto8700 manager_start 기준에서는 RTC viewer를 기본 브라우저로 자동 오픈하지 않는다."""
    text = str(bat_text or "")
    text = text.replace("--viewer open", "--viewer no")
    text = text.replace("--viewer= open", "--viewer no")
    text = text.replace("--viewer=open", "--viewer no")
    return text


def enforce_cdp_mode_runtime_policy(cfg: dict) -> None:
    """JSON/local에 남은 일반 브라우저 자동오픈 경로를 런타임에서도 차단한다."""
    policy = cdp_mode_policy_cfg(cfg)
    if bool(policy.get("disable_rtc_viewer_on_manager_start", True)):
        for service in cfg.get("services") or []:
            if str(service.get("name") or "").upper() == "RTC8790_8791":
                service["bat_text"] = sanitize_no_default_browser_bat_text(str(service.get("bat_text") or ""))
                service["viewer_auto_open"] = False
        catalog = cfg.get("bat_catalog")
        if isinstance(catalog, dict) and "RTC8790_8791" in catalog:
            catalog["RTC8790_8791"] = sanitize_no_default_browser_bat_text(str(catalog.get("RTC8790_8791") or ""))
    cfg["cdp_mode_policy"] = policy


def cdp_mode_precheck_before_services(cfg: dict) -> None:
    """서비스/RTC viewer보다 먼저 CDP모드를 확인하고, 필요하면 전용 user-data-dir로 CDP Chrome을 띄운다."""
    enforce_cdp_mode_runtime_policy(cfg)
    policy = cdp_mode_policy_cfg(cfg)
    if not bool(policy.get("precheck_before_services", True)):
        log("[CDP_MODE] precheck_before_services=false → 기존 순서 유지", cfg)
        return
    log("[CDP_MODE] startup_order=cdp_precheck_before_services → services/RTC viewer보다 먼저 8700 포트 확인", cfg)
    mode = chrome_cdp_mode_status(cfg)
    if bool(mode.get("alive")):
        AUTO8700_SCHEDULE_STATE.update({"cdp_ready": True, "cdp_error": "", "last_result": "CDP모드 선확인 OK"})
        log(f"[CDP_MODE] 선확인 OK: 127.0.0.1:{chrome_debug_port(cfg)}", cfg)
        download_state = apply_instagram_download_behavior(cfg, reason="startup_cdp_precheck_alive")
        if not download_state.get("ok"):
            AUTO8700_SCHEDULE_STATE["cdp_error"] = str(download_state.get("error") or "download_path_apply_failed")
        ensure_instagram_tab(cfg)
        return
    pids = ",".join(map(str, mode.get("chrome_pids") or [])) or "-"
    log(f"[CDP_MODE] 선확인 XX → 전용 CDP user-data-dir로 실행 시도 / 기존 Chrome pids={pids} / cdp_user_data_dir={cdp_user_data_dir_from_cfg(cfg)}", cfg)
    ok = launch_chrome_cdp_user_data_dir(cfg, reason="startup_cdp_precheck_before_services")
    log(f"[CDP_MODE] CDP모드 전용 user-data-dir 선실행 결과={'OK' if ok else 'XX'}", cfg)
    if ok:
        AUTO8700_SCHEDULE_STATE.update({"cdp_ready": True, "cdp_error": "", "cdp_prompt_required": False, "last_result": "CDP모드 실행 OK"})
        download_state = apply_instagram_download_behavior(cfg, reason="startup_cdp_dedicated_launch")
        if not download_state.get("ok"):
            AUTO8700_SCHEDULE_STATE["cdp_error"] = str(download_state.get("error") or "download_path_apply_failed")
    else:
        AUTO8700_SCHEDULE_STATE.update({
            "cdp_ready": False,
            "cdp_error": "cdp_port_not_open_after_launch",
            "cdp_prompt_required": False,
            "last_result": "CDP모드 실행 실패: 자동 다운로드 가능, 예약 비활성화",
        })
        disable_auto_schedule_due_to_cdp_mode(cfg, reason="startup_cdp_dedicated_launch_failed")


def chrome_process_pid_list(procs: Optional[List[dict]] = None) -> List[int]:
    items = query_chrome_processes_windows() if procs is None else list(procs or [])
    pids: List[int] = []
    for proc in items:
        try:
            pid = int(proc.get("ProcessId") or 0)
        except Exception:
            pid = 0
        if pid > 0 and pid not in pids:
            pids.append(pid)
    return pids


def log_chrome_process_snapshot(cfg: dict, label: str, max_lines: int = 8) -> List[int]:
    procs = query_chrome_processes_windows()
    pids = chrome_process_pid_list(procs)
    log(f"[CDP_MODE] {label}: count={len(pids)} pids={','.join(map(str, pids)) or '-'}", cfg)
    for proc in procs[:max_lines]:
        try:
            pid = int(proc.get("ProcessId") or 0)
        except Exception:
            pid = 0
        cmd = str(proc.get("CommandLine") or "")[:520]
        log(f"[CDP_MODE] {label} cmd pid={pid}: {cmd or '-'}", cfg)
    return pids



def restart_all_chrome_for_cdp_mode(cfg: dict, reason: str = "legacy_redirected") -> bool:
    """v186 호환용: 과거 재시작 호출은 전용 CDP user-data-dir 실행으로 전환한다."""
    log(f"[CDP_MODE] legacy restart request redirected to dedicated CDP user-data-dir / reason={reason}", cfg)
    return launch_chrome_cdp_user_data_dir(cfg, reason=f"{reason}_legacy_redirect")



def activate_chrome_tab(cfg: dict, tab_id: str) -> bool:
    if not tab_id:
        return False
    data = http_json_get(chrome_debug_url(cfg, f"/json/activate/{urllib.parse.quote(str(tab_id), safe='')}"), timeout=1.0)
    return data is not None


def open_chrome_url_via_debug(cfg: dict, url: str) -> bool:
    encoded = urllib.parse.quote(url, safe=":/?#[]@!$&'()*+,;=%")
    data = http_json_get(chrome_debug_url(cfg, f"/json/new?{encoded}"), timeout=2.0)
    return data is not None


def ensure_instagram_tab(cfg: dict) -> None:
    icfg = chrome_cfg(cfg)
    if not bool(icfg.get("enabled", True)):
        return
    if not chrome_debug_alive(cfg):
        log(f"[IG_CHROME] 127.0.0.1:{chrome_debug_port(cfg)} CDP모드 응답 없음", cfg)
        return
    tab = ensure_instagram_control_marker(cfg)
    if tab:
        log(f"[IG_CHROME] Instagram 제어 탭 확인: {tab.get('url')} / tab_activate_deferred_until_mouse_idle", cfg)
        return
    startup_url = str(icfg.get("startup_url") or "https://www.instagram.com/explore/")
    if open_chrome_url_via_debug(cfg, startup_url):
        log(f"[IG_CHROME] Instagram 탭 없음 → 새 탭 열기: {startup_url}", cfg)
        time.sleep(0.8)
        ensure_instagram_control_marker(cfg)
    else:
        log(f"[IG_CHROME] Instagram 탭 열기 실패: {startup_url}", cfg)



# ------------------- DevTools 8700 / Auto8700 8707 -------------------
DEVTOOLS_EVENTS: List[dict] = []
DEVTOOLS_EVENTS_LOCK = threading.Lock()
DEVTOOLS_WATCH_STATE = {"running": False, "thread": None, "stop": False, "target_id": "", "target_url": "", "last_error": ""}
AUTO8700_SERVER_STATE = {"running": False, "server": None, "thread": None, "last_error": ""}
AUTO8700_SHUTDOWN_EVENT = threading.Event()
AUTO8700_DOWNLOAD_BEHAVIOR_LOCK = threading.Lock()
AUTO8700_DOWNLOAD_BEHAVIOR_STATE = {
    "ok": False,
    "method": "Browser.setDownloadBehavior",
    "download_path": "",
    "applied_at": "",
    "error": "not_applied",
}
AUTO8700_STATUS_CACHE_LOCK = threading.Lock()
AUTO8700_STATUS_CACHE = {
    "chrome_ok": False,
    "ws_ok": False,
    "analyze8780_port_ok": False,
    "analyze8780_api_ok": False,
    "analyze8780_version": "",
    "analyze8780_error": "",
    "analyze8780_api_checked_once": False,
    "analyze8780_api_checked_at": 0.0,
    "configured": False,
    "profiles": 0,
    "cdp_mode": {},
    "control_marker": {"ws_ok": False, "devtools_ok": False, "marker": "❤️💛💛"},
    "updated_at": 0.0,
    "updated_iso": "",
    "refresh_ms": 0,
    "last_error": "",
    "running": False,
    "thread": None,
}


def auto8700_cached_status_snapshot() -> dict:
    """UI/HTTP가 느린 netstat·CDP 작업 없이 읽는 상태 snapshot."""
    with AUTO8700_STATUS_CACHE_LOCK:
        out = dict(AUTO8700_STATUS_CACHE)
        out["cdp_mode"] = dict(AUTO8700_STATUS_CACHE.get("cdp_mode") or {})
        out["control_marker"] = dict(AUTO8700_STATUS_CACHE.get("control_marker") or {})
    updated_at = float(out.get("updated_at") or 0.0)
    out["age_sec"] = max(0.0, time.time() - updated_at) if updated_at > 0 else None
    return out


def reset_analyze8780_status_cache() -> None:
    """Analyze8780 새 실행 때만 포트와 API를 다시 한 번 확인하도록 캐시를 초기화한다."""
    with AUTO8700_STATUS_CACHE_LOCK:
        AUTO8700_STATUS_CACHE.update({
            "analyze8780_port_ok": False,
            "analyze8780_api_ok": False,
            "analyze8780_version": "",
            "analyze8780_error": "",
            "analyze8780_api_checked_once": False,
            "analyze8780_api_checked_at": 0.0,
        })


def refresh_auto8700_status_cache(cfg: dict, include_cdp_mode: bool = False) -> dict:
    """느린 상태 확인은 전용 worker에서만 실행한다. 호출자는 UI thread가 아니어야 한다."""
    started = time.monotonic()
    errors: List[str] = []
    try:
        chrome_ok = bool(chrome_debug_alive(cfg))
    except Exception as exc:
        chrome_ok = False
        errors.append(f"chrome:{exc}")
    try:
        ws_ok = bool(ws_service_alive(cfg))
    except Exception as exc:
        ws_ok = False
        errors.append(f"ws:{exc}")
    with AUTO8700_STATUS_CACHE_LOCK:
        analyze_port_ok = bool(AUTO8700_STATUS_CACHE.get("analyze8780_port_ok"))
        analyze_api_ok = bool(AUTO8700_STATUS_CACHE.get("analyze8780_api_ok"))
        analyze_version = str(AUTO8700_STATUS_CACHE.get("analyze8780_version") or "")
        analyze_error = str(AUTO8700_STATUS_CACHE.get("analyze8780_error") or "")
        analyze_api_checked_once = bool(AUTO8700_STATUS_CACHE.get("analyze8780_api_checked_once"))
        analyze_api_checked_at = float(AUTO8700_STATUS_CACHE.get("analyze8780_api_checked_at") or 0.0)
    try:
        analyze_service = next((item for item in (cfg.get("services") or []) if str(item.get("name") or "").upper() == "ANALYZE8780"), None)
        if analyze_service:
            analyze_host = str(analyze_service.get("health_host") or "127.0.0.1")
            analyze_port = int((analyze_service.get("health_ports") or analyze_service.get("ports") or [8780])[0])
            # v230: Analyze8780은 새 실행 직후 포트와 /api/health를 한 번만 확인한다.
            # 확인 완료 뒤에는 1초 상태 루프에서 HTTP·TCP health를 반복하지 않는다.
            if not analyze_api_checked_once:
                analyze_port_ok = bool(tcp_connect_ok(analyze_host, analyze_port, timeout=0.5))
                if analyze_port_ok:
                    analyze_path = str(analyze_service.get("health_path") or "/api/health")
                    if not analyze_path.startswith("/"):
                        analyze_path = "/" + analyze_path
                    analyze_payload = http_json_get(f"http://{analyze_host}:{analyze_port}{analyze_path}", timeout=1.0)
                    analyze_api_ok = bool(isinstance(analyze_payload, dict) and analyze_payload.get("ok") is True)
                    analyze_version = str(analyze_payload.get("version") or "") if isinstance(analyze_payload, dict) else ""
                    analyze_error = "" if analyze_api_ok else "api_health_failed"
                    analyze_api_checked_once = True
                    analyze_api_checked_at = time.time()
                else:
                    analyze_api_ok = False
                    analyze_error = "port_off"
        else:
            analyze_api_ok = False
            analyze_error = "service_not_configured"
    except Exception as exc:
        analyze_api_ok = False
        analyze_error = str(exc)
        errors.append(f"analyze8780:{exc}")
    try:
        configured = bool(ig_chrome_configured(cfg))
        profiles = len(chrome_cfg(cfg).get("profiles") or [])
    except Exception as exc:
        configured = False
        profiles = 0
        errors.append(f"config:{exc}")

    with AUTO8700_STATUS_CACHE_LOCK:
        previous_mode = dict(AUTO8700_STATUS_CACHE.get("cdp_mode") or {})
    cdp_mode = previous_mode
    if include_cdp_mode or not cdp_mode:
        try:
            cdp_mode = dict(chrome_cdp_mode_status(cfg) or {})
        except Exception as exc:
            errors.append(f"cdp_mode:{exc}")
            cdp_mode = previous_mode

    marker = control_marker_for_status(cfg, ws_ok=ws_ok, devtools_ok=chrome_ok)
    now = time.time()
    with AUTO8700_STATUS_CACHE_LOCK:
        AUTO8700_STATUS_CACHE.update({
            "chrome_ok": chrome_ok,
            "ws_ok": ws_ok,
            "analyze8780_port_ok": analyze_port_ok,
            "analyze8780_api_ok": analyze_api_ok,
            "analyze8780_version": analyze_version,
            "analyze8780_error": analyze_error,
            "analyze8780_api_checked_once": analyze_api_checked_once,
            "analyze8780_api_checked_at": analyze_api_checked_at,
            "configured": configured,
            "profiles": profiles,
            "cdp_mode": cdp_mode,
            "control_marker": {"ws_ok": ws_ok, "devtools_ok": chrome_ok, "marker": marker},
            "updated_at": now,
            "updated_iso": datetime.now().isoformat(timespec="seconds"),
            "refresh_ms": int((time.monotonic() - started) * 1000),
            "last_error": " | ".join(errors),
        })
    return auto8700_cached_status_snapshot()


def auto8700_status_watch_loop(cfg: dict) -> None:
    interval = max(0.5, float(cfg.get("status_watch_interval_sec", 2.0) or 2.0))
    cdp_mode_interval = max(interval, float(cfg.get("status_cdp_mode_interval_sec", 10.0) or 10.0))
    last_cdp_mode_at = 0.0
    with AUTO8700_STATUS_CACHE_LOCK:
        AUTO8700_STATUS_CACHE["running"] = True
    try:
        while not AUTO8700_SHUTDOWN_EVENT.is_set():
            now = time.monotonic()
            include_cdp_mode = last_cdp_mode_at <= 0 or (now - last_cdp_mode_at) >= cdp_mode_interval
            refresh_auto8700_status_cache(cfg, include_cdp_mode=include_cdp_mode)
            if include_cdp_mode:
                last_cdp_mode_at = time.monotonic()
            if AUTO8700_SHUTDOWN_EVENT.wait(interval):
                break
    finally:
        with AUTO8700_STATUS_CACHE_LOCK:
            AUTO8700_STATUS_CACHE["running"] = False


def start_auto8700_status_watch(cfg: dict) -> None:
    with AUTO8700_STATUS_CACHE_LOCK:
        thread = AUTO8700_STATUS_CACHE.get("thread")
        if isinstance(thread, threading.Thread) and thread.is_alive():
            return
        thread = threading.Thread(target=auto8700_status_watch_loop, args=(cfg,), name="auto8700-status-watch", daemon=True)
        AUTO8700_STATUS_CACHE["thread"] = thread
    thread.start()


def shutdown_auto8700_event_server(cfg: dict) -> None:
    srv = AUTO8700_SERVER_STATE.get("server")
    if srv is None:
        return
    try:
        log("[DEV8707] Auto8700 이벤트/웹 서버 종료 요청", cfg)
        srv.shutdown()
        srv.server_close()
    except Exception as exc:
        log(f"[DEV8707] 서버 종료 실패: {exc}", cfg)
    finally:
        AUTO8700_SERVER_STATE.update({"running": False, "server": None})


def request_manager_shutdown(cfg: dict, reason: str = "ui_close") -> None:
    """UI × 요청은 종료 event만 세팅하고 즉시 반환한다. 서버/프로세스 정리는 main finally가 담당한다."""
    if AUTO8700_SHUTDOWN_EVENT.is_set():
        return
    log(f"[EXIT] Auto8700 종료 요청: reason={reason}", cfg)
    AUTO8700_SHUTDOWN_EVENT.set()
    DEVTOOLS_WATCH_STATE["stop"] = True



def event_server_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("auto8700_event_server") or {})
    user = cfg.get("auto8700_event_server") or {}
    if isinstance(user, dict):
        base.update(user)
    try:
        base["port"] = int(base.get("port") or 8707)
    except Exception:
        base["port"] = 8707
    base["host"] = str(base.get("host") or "127.0.0.1")
    return base


def devtools_network_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("devtools_network_watch") or {})
    user = cfg.get("devtools_network_watch") or {}
    if isinstance(user, dict):
        base.update(user)
    return base


def devtools_marker_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("devtools_control_marker") or {})
    user = cfg.get("devtools_control_marker") or {}
    if isinstance(user, dict):
        base.update(user)
    return base


def remember_devtools_event(event: dict, cfg: Optional[dict] = None) -> None:
    event = dict(event)
    event.setdefault("ts", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
    with DEVTOOLS_EVENTS_LOCK:
        DEVTOOLS_EVENTS.append(event)
        max_events = 300
        del DEVTOOLS_EVENTS[:-max_events]
    if cfg:
        maybe_write_devtools_file_log(event, cfg)


def recent_devtools_events(limit: int = 80) -> List[dict]:
    with DEVTOOLS_EVENTS_LOCK:
        return list(DEVTOOLS_EVENTS[-int(limit):])


def maybe_write_devtools_file_log(event: dict, cfg: dict) -> None:
    ncfg = devtools_network_cfg(cfg)
    if not bool(ncfg.get("file_log", False)):
        return
    try:
        log_dir = Path(str(ncfg.get("log_dir") or "log\\devtools_network"))
        if not log_dir.is_absolute():
            log_dir = script_dir() / log_dir
        log_dir.mkdir(parents=True, exist_ok=True)
        day = datetime.now().strftime("%Y%m%d")
        path = log_dir / f"ig_net_{day}.jsonl"
        with path.open("a", encoding="utf-8") as f:
            f.write(json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n")
    except Exception as exc:
        # 파일 로그 실패가 manager를 죽이면 안 된다.
        if cfg:
            log(f"[DEV8707] 파일 로그 실패: {exc}", cfg)


def safe_url_summary(url: str, max_len: int = 180) -> str:
    text = str(url or "")
    # URL 토큰은 너무 길고 민감할 수 있으므로 콘솔 기본 표시는 절단한다.
    if len(text) > max_len:
        return text[:max_len] + "…"
    return text


def devtools_event_should_print(event: dict, cfg: dict) -> bool:
    ncfg = devtools_network_cfg(cfg)
    if not bool(ncfg.get("console", True)):
        return False
    if bool(ncfg.get("verbose", False)):
        return True
    kind = str(event.get("kind") or "")
    mime = str(event.get("mime") or "").lower()
    url = str(event.get("url") or "").lower()
    rtype = str(event.get("type") or "").lower()
    if kind in ("watch_start", "watch_stop", "control_tab", "error"):
        return True
    if bool(ncfg.get("print_graphql", True)) and ("graphql" in url or "/api/" in url):
        return True
    if bool(ncfg.get("print_json", True)) and ("json" in mime or rtype in ("xhr", "fetch")):
        return True
    if bool(ncfg.get("print_media", True)) and (rtype in ("image", "media") or mime.startswith("image/") or mime.startswith("video/")):
        return True
    return False


def print_devtools_event(event: dict, cfg: dict) -> None:
    if not devtools_event_should_print(event, cfg):
        return
    kind = str(event.get("kind") or "event")
    if kind == "request":
        log(f"[NET][REQ] {event.get('method','')} {event.get('type','')} {safe_url_summary(str(event.get('url','')))}", cfg)
    elif kind == "response":
        log(f"[NET][RES] {event.get('status','')} {event.get('type','')} {event.get('mime','')} {safe_url_summary(str(event.get('url','')))}", cfg)
    elif kind == "control_tab":
        marker = str(event.get('marker') or '')
        msg = str(event.get('message') or '')
        suffix = f" / marker={marker}" if marker else ""
        if msg:
            suffix += f" / {msg}"
        log(f"[DEV8700] 제어 탭: {safe_url_summary(str(event.get('url','')))} / title={event.get('title','')}{suffix}", cfg)
    elif kind == "error":
        log(f"[DEV8700] 오류: {event.get('message','')}", cfg)
    else:
        log(f"[DEV8700] {kind}: {safe_url_summary(str(event.get('url','')))}", cfg)


def emit_devtools_event(event: dict, cfg: dict) -> None:
    remember_devtools_event(event, cfg)
    print_devtools_event(event, cfg)


class SimpleCDPWebSocket:
    """외부 패키지 없이 Chrome DevTools WebSocket에 붙기 위한 최소 클라이언트."""
    def __init__(self, ws_url: str, timeout: float = 5.0):
        self.ws_url = ws_url
        self.timeout = timeout
        self.sock: Optional[socket.socket] = None
        self._id = 0

    def connect(self) -> None:
        parsed = urllib.parse.urlparse(self.ws_url)
        if parsed.scheme not in ("ws", "wss"):
            raise RuntimeError(f"지원하지 않는 websocket url: {self.ws_url}")
        if parsed.scheme == "wss":
            raise RuntimeError("wss는 v149 기본 클라이언트에서 지원하지 않습니다")
        host = parsed.hostname or "127.0.0.1"
        port = int(parsed.port or 80)
        path = parsed.path or "/"
        if parsed.query:
            path += "?" + parsed.query
        key = base64.b64encode(os.urandom(16)).decode("ascii")
        sock = socket.create_connection((host, port), timeout=self.timeout)
        req = (
            f"GET {path} HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            "Upgrade: websocket\r\n"
            "Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {key}\r\n"
            "Sec-WebSocket-Version: 13\r\n"
            "\r\n"
        ).encode("ascii")
        sock.sendall(req)
        raw = b""
        while b"\r\n\r\n" not in raw:
            chunk = sock.recv(4096)
            if not chunk:
                break
            raw += chunk
            if len(raw) > 65536:
                break
        head = raw.decode("latin1", errors="ignore")
        if " 101 " not in head.split("\r\n", 1)[0]:
            sock.close()
            raise RuntimeError(f"WebSocket handshake 실패: {head.splitlines()[:1]}")
        self.sock = sock
        self.sock.settimeout(1.0)

    def close(self) -> None:
        try:
            if self.sock:
                self.sock.close()
        except Exception:
            pass
        self.sock = None

    def _read_exact(self, n: int) -> bytes:
        if not self.sock:
            raise RuntimeError("websocket not connected")
        buf = b""
        while len(buf) < n:
            chunk = self.sock.recv(n - len(buf))
            if not chunk:
                raise RuntimeError("websocket closed")
            buf += chunk
        return buf

    def send_text(self, text: str) -> None:
        if not self.sock:
            raise RuntimeError("websocket not connected")
        payload = text.encode("utf-8")
        header = bytearray([0x81])
        length = len(payload)
        if length < 126:
            header.append(0x80 | length)
        elif length < 65536:
            header.append(0x80 | 126)
            header.extend(struct.pack("!H", length))
        else:
            header.append(0x80 | 127)
            header.extend(struct.pack("!Q", length))
        mask = os.urandom(4)
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        self.sock.sendall(bytes(header) + mask + masked)

    def recv_text(self, timeout: float = 1.0) -> Optional[str]:
        if not self.sock:
            return None
        self.sock.settimeout(timeout)
        while True:
            try:
                first = self._read_exact(2)
            except socket.timeout:
                return None
            b1, b2 = first[0], first[1]
            opcode = b1 & 0x0F
            masked = bool(b2 & 0x80)
            length = b2 & 0x7F
            if length == 126:
                length = struct.unpack("!H", self._read_exact(2))[0]
            elif length == 127:
                length = struct.unpack("!Q", self._read_exact(8))[0]
            mask = self._read_exact(4) if masked else b""
            payload = self._read_exact(length) if length else b""
            if masked:
                payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
            if opcode == 0x8:
                raise RuntimeError("websocket close frame")
            if opcode == 0x9:
                # ping -> pong
                self._send_control(0xA, payload)
                continue
            if opcode == 0x1:
                return payload.decode("utf-8", errors="ignore")
            # binary/continuation은 현재 사용하지 않는다.
            continue

    def _send_control(self, opcode: int, payload: bytes = b"") -> None:
        if not self.sock:
            return
        header = bytearray([0x80 | (opcode & 0x0F)])
        length = len(payload)
        header.append(0x80 | length)
        mask = os.urandom(4)
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        self.sock.sendall(bytes(header) + mask + masked)

    def send_cmd(self, method: str, params: Optional[dict] = None) -> int:
        self._id += 1
        msg = {"id": self._id, "method": method}
        if params is not None:
            msg["params"] = params
        self.send_text(json.dumps(msg, ensure_ascii=False))
        return self._id


def cdp_eval(ws_url: str, expression: str, timeout: float = 4.0) -> bool:
    cdp = SimpleCDPWebSocket(ws_url, timeout=timeout)
    try:
        cdp.connect()
        cdp.send_cmd("Runtime.evaluate", {"expression": expression, "awaitPromise": False, "returnByValue": True})
        end = time.time() + timeout
        while time.time() < end:
            txt = cdp.recv_text(timeout=0.5)
            if not txt:
                continue
            data = json.loads(txt)
            if data.get("id"):
                return True
        return True
    except Exception:
        return False
    finally:
        cdp.close()

def cdp_call(ws_url: str, method: str, params: Optional[dict] = None, timeout: float = 4.0) -> dict:
    """CDP 단일 명령을 실행하고 원 응답을 반환한다. 위험한 범용 외부 노출은 라우터에서 제한한다."""
    if not ws_url:
        return {"ok": False, "error": "websocket_debugger_url_missing"}
    if not method:
        return {"ok": False, "error": "method_missing"}
    cdp = SimpleCDPWebSocket(ws_url, timeout=timeout)
    try:
        cdp.connect()
        msg_id = cdp.send_cmd(method, params or {})
        end = time.time() + max(0.5, float(timeout))
        last_event: Optional[dict] = None
        while time.time() < end:
            txt = cdp.recv_text(timeout=0.5)
            if not txt:
                continue
            try:
                data = json.loads(txt)
            except Exception:
                continue
            if data.get("id") == msg_id:
                if data.get("error"):
                    return {"ok": False, "id": msg_id, "error": data.get("error"), "response": data}
                return {"ok": True, "id": msg_id, "result": data.get("result"), "response": data}
            if isinstance(data, dict):
                last_event = data
        return {"ok": False, "id": msg_id, "error": "cdp_response_timeout", "last_event": last_event}
    except Exception as exc:
        return {"ok": False, "error": str(exc)}
    finally:
        cdp.close()


def apply_instagram_download_behavior(cfg: dict, reason: str = "runtime") -> dict:
    """Set Chrome's download root to sibling _StDown/instagram through browser-level CDP."""
    with AUTO8700_DOWNLOAD_BEHAVIOR_LOCK:
        try:
            root = instagram_download_root().resolve()
            root.mkdir(parents=True, exist_ok=True)
            ws_url = chrome_browser_websocket_url(cfg)
            if not ws_url:
                raise RuntimeError("browser_websocket_debugger_url_missing")
            result = cdp_call(
                ws_url,
                "Browser.setDownloadBehavior",
                {"behavior": "allow", "downloadPath": str(root), "eventsEnabled": True},
                timeout=4.0,
            )
            if not result.get("ok"):
                raise RuntimeError(str(result.get("error") or "Browser.setDownloadBehavior_failed"))
            state = {
                "ok": True,
                "method": "Browser.setDownloadBehavior",
                "download_path": str(root),
                "applied_at": datetime.now().isoformat(timespec="seconds"),
                "error": "",
                "reason": str(reason or "runtime"),
            }
            AUTO8700_DOWNLOAD_BEHAVIOR_STATE.clear()
            AUTO8700_DOWNLOAD_BEHAVIOR_STATE.update(state)
            log(f"[CDP_DOWNLOAD] OK path={root} method=Browser.setDownloadBehavior reason={reason}", cfg)
            return dict(state)
        except Exception as exc:
            state = {
                "ok": False,
                "method": "Browser.setDownloadBehavior",
                "download_path": str(instagram_download_root()),
                "applied_at": datetime.now().isoformat(timespec="seconds"),
                "error": str(exc),
                "reason": str(reason or "runtime"),
            }
            AUTO8700_DOWNLOAD_BEHAVIOR_STATE.clear()
            AUTO8700_DOWNLOAD_BEHAVIOR_STATE.update(state)
            log(f"[CDP_DOWNLOAD] FAIL path={state['download_path']} error={exc} reason={reason}", cfg)
            return dict(state)


def cdp_download_root_payload(cfg: dict, ensure: bool = False, reason: str = "status") -> dict:
    if ensure or not AUTO8700_DOWNLOAD_BEHAVIOR_STATE.get("ok"):
        return apply_instagram_download_behavior(cfg, reason=reason)
    return dict(AUTO8700_DOWNLOAD_BEHAVIOR_STATE)


def cdp_sanitize_target(tab: dict) -> dict:
    """RTC/웹 UI에 넘길 target 요약. WebSocket URL 자체는 노출하지 않는다."""
    return {
        "id": str(tab.get("id") or ""),
        "type": str(tab.get("type") or ""),
        "title": str(tab.get("title") or ""),
        "url": str(tab.get("url") or ""),
        "attached": bool(tab.get("attached")),
        "can_cdp": bool(tab.get("webSocketDebuggerUrl")),
    }


def cdp_target_matches(tab: dict, target_id: str) -> bool:
    target_id = str(target_id or "").strip()
    if not target_id:
        return False
    return target_id in (str(tab.get("id") or ""), str(tab.get("targetId") or ""))


def resolve_cdp_target(cfg: dict, target_id: str = "", prefer_instagram: bool = True) -> tuple[Optional[dict], str]:
    """CDP 명령 대상 tab을 찾는다. 없으면 새 탭을 만들지 않는다."""
    tabs = [t for t in chrome_tabs(cfg) if t.get("webSocketDebuggerUrl")]
    if target_id:
        for tab in tabs:
            if cdp_target_matches(tab, target_id):
                return tab, ""
        return None, "target_not_found"
    if prefer_instagram:
        for tab in tabs:
            if "instagram.com" in str(tab.get("url") or ""):
                return tab, ""
    for tab in tabs:
        if str(tab.get("type") or "") == "page":
            return tab, ""
    return (tabs[0], "") if tabs else (None, "target_not_found")


def cdp_status_payload(cfg: dict) -> dict:
    mode = chrome_cdp_mode_status(cfg)
    alive = bool(mode.get("alive"))
    tabs = chrome_tabs(cfg) if alive else []
    cdp_count = sum(1 for t in tabs if t.get("webSocketDebuggerUrl"))
    return {
        "ok": True,
        "port": chrome_debug_port(cfg),
        "alive": bool(alive),
        "configured": bool(chrome_cfg(cfg).get("configured")),
        "profile_count": len(chrome_cfg(cfg).get("profiles") or []),
        "target_count": len(tabs),
        "cdp_target_count": cdp_count,
        "cdp_mode": mode,
        "reason": str(mode.get("reason") or ""),
    }


def cdp_targets_payload(cfg: dict) -> dict:
    alive = chrome_debug_alive(cfg)
    tabs = chrome_tabs(cfg) if alive else []
    targets = [cdp_sanitize_target(t) for t in tabs]
    return {
        "ok": True,
        "status": cdp_status_payload(cfg),
        "targets": targets,
    }


def cdp_evaluate_payload(cfg: dict, payload: dict) -> dict:
    expression = str(payload.get("expression") or "").strip()
    if not expression:
        return {"ok": False, "error": "expression_required"}
    target_id = str(payload.get("target_id") or payload.get("id") or "").strip()
    prefer_instagram = bool(payload.get("prefer_instagram", True))
    tab, err = resolve_cdp_target(cfg, target_id=target_id, prefer_instagram=prefer_instagram)
    if not tab:
        return {"ok": False, "error": err or "target_not_found", "status": cdp_status_payload(cfg)}
    timeout = float(payload.get("timeout") or 4.0)
    params = {
        "expression": expression,
        "awaitPromise": bool(payload.get("awaitPromise", payload.get("await_promise", False))),
        "returnByValue": bool(payload.get("returnByValue", payload.get("return_by_value", True))),
    }
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Runtime.evaluate", params, timeout=timeout)
    result["target"] = cdp_sanitize_target(tab)
    return result


def cdp_bring_to_front_payload(cfg: dict, payload: dict) -> dict:
    target_id = str(payload.get("target_id") or payload.get("id") or "").strip()
    prefer_instagram = bool(payload.get("prefer_instagram", True))
    tab, err = resolve_cdp_target(cfg, target_id=target_id, prefer_instagram=prefer_instagram)
    if not tab:
        return {"ok": False, "error": err or "target_not_found", "status": cdp_status_payload(cfg)}
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Page.bringToFront", {}, timeout=float(payload.get("timeout") or 3.0))
    if result.get("ok"):
        try:
            activate_chrome_tab(cfg, str(tab.get("id") or ""))
        except Exception:
            pass
    result["target"] = cdp_sanitize_target(tab)
    return result

def cdp_instagram_target_payload(cfg: dict) -> dict:
    """Instagram target을 찾고 안전한 요약만 반환한다."""
    tab, err = resolve_cdp_target(cfg, target_id="", prefer_instagram=True)
    if not tab:
        return {"ok": False, "error": err or "instagram_target_not_found", "status": cdp_status_payload(cfg)}
    if "instagram.com" not in str(tab.get("url") or ""):
        return {"ok": False, "error": "instagram_target_not_found", "target": cdp_sanitize_target(tab), "status": cdp_status_payload(cfg)}
    return {"ok": True, "target": cdp_sanitize_target(tab), "status": cdp_status_payload(cfg)}


def cdp_ig_schedule_storage_payload(cfg: dict) -> dict:
    """Instagram origin localStorage의 Auto8700 예약 storage를 읽는다. 삭제/쓰기 금지."""
    tab, err = resolve_cdp_target(cfg, target_id="", prefer_instagram=True)
    if not tab or "instagram.com" not in str(tab.get("url") or ""):
        return {"ok": False, "error": err or "instagram_target_not_found", "status": cdp_status_payload(cfg)}
    key = str(auto_schedule_cfg(cfg).get("cdp_storage_key") or AUTO8700_SCHEDULE_STORAGE_KEY)
    expr = """
(() => {
  const key = %s;
  const raw = localStorage.getItem(key);
  let parsed = null;
  let parse_error = "";
  if (raw) {
    try { parsed = JSON.parse(raw); }
    catch (e) { parse_error = String(e && e.message || e); }
  }
  return {href: location.href, title: document.title, readyState: document.readyState, key, raw, parsed, parse_error};
})()
""" % json.dumps(key, ensure_ascii=False)
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Runtime.evaluate", {"expression": expr, "awaitPromise": False, "returnByValue": True}, timeout=4.0)
    result["target"] = cdp_sanitize_target(tab)
    return result


def cdp_ig_status_payload(cfg: dict) -> dict:
    """IG 전용 상태: target + title/url/readyState + 예약 storage 읽기."""
    base = cdp_instagram_target_payload(cfg)
    if not base.get("ok"):
        return base
    storage = cdp_ig_schedule_storage_payload(cfg)
    base["schedule_storage"] = storage
    return base


def cdp_ig_bring_to_front_payload(cfg: dict, payload: dict) -> dict:
    """Instagram tab만 앞으로 가져온다. target_id가 있어도 Instagram URL이 아니면 거부한다."""
    target_id = str(payload.get("target_id") or payload.get("id") or "").strip()
    tab, err = resolve_cdp_target(cfg, target_id=target_id, prefer_instagram=True)
    if not tab or "instagram.com" not in str(tab.get("url") or ""):
        return {"ok": False, "error": err or "instagram_target_not_found", "status": cdp_status_payload(cfg)}
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Page.bringToFront", {}, timeout=float(payload.get("timeout") or 3.0))
    if result.get("ok"):
        try:
            activate_chrome_tab(cfg, str(tab.get("id") or ""))
        except Exception:
            pass
    result["target"] = cdp_sanitize_target(tab)
    return result


def cdp_ig_send_test_event_payload(cfg: dict, payload: dict) -> dict:
    """IG 페이지에 harmless 테스트 이벤트를 보낸다. 자동시작/다운로드는 호출하지 않는다."""
    tab, err = resolve_cdp_target(cfg, target_id=str(payload.get("target_id") or "").strip(), prefer_instagram=True)
    if not tab or "instagram.com" not in str(tab.get("url") or ""):
        return {"ok": False, "error": err or "instagram_target_not_found", "status": cdp_status_payload(cfg)}
    detail = {
        "source": "AUTO8700",
        "type": "IGBLUE_AUTO8700_CDP_TEST_EVENT",
        "reason": str(payload.get("reason") or "rtc_cdp_test"),
        "request_id": "auto8700_cdp_test_" + str(int(time.time() * 1000)),
    }
    expr = """
(() => {
  const detail = %s;
  window.__IGBLUE_AUTO8700_CDP_TEST_LAST = Object.assign({ts: Date.now()}, detail);
  window.dispatchEvent(new CustomEvent('igblue_auto8700_cdp_test', {detail}));
  document.dispatchEvent(new CustomEvent('igblue_auto8700_cdp_test', {detail}));
  return {ok:true, dispatched:'igblue_auto8700_cdp_test', detail, href:location.href, title:document.title};
})()
""" % json.dumps(detail, ensure_ascii=False)
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Runtime.evaluate", {"expression": expr, "awaitPromise": False, "returnByValue": True}, timeout=4.0)
    result["target"] = cdp_sanitize_target(tab)
    return result


def cdp_ig_side_open_payload(cfg: dict, payload: dict) -> dict:
    """IG content/background에 side panel 열기 요청만 보낸다. 자동시작 요청과 분리한다."""
    tab, err = resolve_cdp_target(cfg, target_id=str(payload.get("target_id") or "").strip(), prefer_instagram=True)
    if not tab or "instagram.com" not in str(tab.get("url") or ""):
        return {"ok": False, "error": err or "instagram_target_not_found", "status": cdp_status_payload(cfg)}
    detail = {
        "source": "AUTO8700",
        "type": "IGBLUE_AUTO8700_SIDE_OPEN_REQUEST",
        "reason": str(payload.get("reason") or "rtc_cdp_side_open_test"),
        "request_id": "auto8700_side_open_" + str(int(time.time() * 1000)),
        "open_side": True,
    }
    expr = """
(() => {
  const detail = %s;
  window.__IGBLUE_AUTO8700_SIDE_OPEN_LAST = Object.assign({ts: Date.now()}, detail);
  document.dispatchEvent(new CustomEvent('igblue_auto8700_side_open_request', {detail}));
  window.postMessage(detail, '*');
  return {ok:true, dispatched:'igblue_auto8700_side_open_request', detail, href:location.href, title:document.title};
})()
""" % json.dumps(detail, ensure_ascii=False)
    result = cdp_call(str(tab.get("webSocketDebuggerUrl") or ""), "Runtime.evaluate", {"expression": expr, "awaitPromise": False, "returnByValue": True}, timeout=4.0)
    result["target"] = cdp_sanitize_target(tab)
    return result



def marker_prefixes(cfg: dict) -> dict:
    marker_cfg = devtools_marker_cfg(cfg)
    ws_ok_devtools_ok = str(
        marker_cfg.get("ws_ok_devtools_ok_prefix")
        or marker_cfg.get("active_prefix")
        or marker_cfg.get("title_prefix")
        or "❤️❤️❤️"
    ).strip() or "❤️❤️❤️"
    ws_down_devtools_ok = str(marker_cfg.get("ws_down_devtools_ok_prefix") or "❤️💛❤️").strip() or "❤️💛❤️"
    ws_ok_devtools_down = str(
        marker_cfg.get("ws_ok_devtools_down_prefix")
        or marker_cfg.get("stale_prefix")
        or "❤️❤️💛"
    ).strip() or "❤️❤️💛"
    ws_down_devtools_down = str(marker_cfg.get("ws_down_devtools_down_prefix") or "❤️💛💛").strip() or "❤️💛💛"
    manual = str(marker_cfg.get("manual_prefix") or "💛💛💛").strip() or "💛💛💛"
    auto = [ws_ok_devtools_ok, ws_down_devtools_ok, ws_ok_devtools_down, ws_down_devtools_down]
    # 중복 제거 + 긴/우선 marker부터 제거한다.
    auto_unique: List[str] = []
    for item in auto:
        if item and item not in auto_unique:
            auto_unique.append(item)
    return {
        "ws_ok_devtools_ok": ws_ok_devtools_ok,
        "ws_down_devtools_ok": ws_down_devtools_ok,
        "ws_ok_devtools_down": ws_ok_devtools_down,
        "ws_down_devtools_down": ws_down_devtools_down,
        "active": ws_ok_devtools_ok,
        "stale": ws_ok_devtools_down,
        "manual": manual,
        "auto": auto_unique,
        "all": auto_unique + ([manual] if manual and manual not in auto_unique else []),
    }


def tab_title_has_prefix(tab: dict, prefix: str) -> bool:
    title = str(tab.get("title") or "")
    return bool(prefix) and title.startswith(prefix)


def tab_title_has_any_auto_marker(tab: dict, cfg: dict) -> bool:
    title = str(tab.get("title") or "")
    for prefix in marker_prefixes(cfg).get("auto") or []:
        if prefix and title.startswith(prefix):
            return True
    return False


def ws_service_alive(cfg: dict) -> bool:
    """WS8771 상태는 marker 두 번째 하트 기준이다. 기본은 8771 LISTENING 확인."""
    for service in cfg.get("services") or []:
        if str(service.get("name") or "").upper() == "WS8771":
            try:
                return bool(health_ok(service))
            except Exception:
                break
    try:
        return bool(netstat_port_listening_windows(8771))
    except Exception:
        return False


def control_marker_for_status(cfg: dict, ws_ok: Optional[bool] = None, devtools_ok: Optional[bool] = None) -> str:
    prefixes = marker_prefixes(cfg)
    if ws_ok is None:
        ws_ok = ws_service_alive(cfg)
    if devtools_ok is None:
        devtools_ok = chrome_debug_alive(cfg)
    if bool(ws_ok) and bool(devtools_ok):
        return str(prefixes.get("ws_ok_devtools_ok") or "❤️❤️❤️")
    if not bool(ws_ok) and bool(devtools_ok):
        return str(prefixes.get("ws_down_devtools_ok") or "❤️💛❤️")
    if bool(ws_ok) and not bool(devtools_ok):
        return str(prefixes.get("ws_ok_devtools_down") or "❤️❤️💛")
    return str(prefixes.get("ws_down_devtools_down") or "❤️💛💛")


def control_marker_status_payload(cfg: dict, devtools_ok: Optional[bool] = None) -> dict:
    ws_ok = ws_service_alive(cfg)
    if devtools_ok is None:
        devtools_ok = chrome_debug_alive(cfg)
    marker = control_marker_for_status(cfg, ws_ok=ws_ok, devtools_ok=devtools_ok)
    return {"ws_ok": bool(ws_ok), "devtools_ok": bool(devtools_ok), "marker": marker}


def choose_instagram_control_tab(cfg: dict) -> Optional[dict]:
    tabs = [t for t in chrome_tabs(cfg) if "instagram.com" in str(t.get("url") or "") and t.get("webSocketDebuggerUrl")]
    if not tabs:
        startup_url = str(chrome_cfg(cfg).get("startup_url") or "https://www.instagram.com/explore/")
        open_chrome_url_via_debug(cfg, startup_url)
        time.sleep(0.7)
        tabs = [t for t in chrome_tabs(cfg) if "instagram.com" in str(t.get("url") or "") and t.get("webSocketDebuggerUrl")]
    if not tabs:
        return None

    # 1순위: 이미 Auto8700이 지정했던 빨간/혼합 marker 탭.
    for prefix in marker_prefixes(cfg).get("auto") or []:
        for tab in tabs:
            if tab_title_has_prefix(tab, prefix):
                return tab

    marker_cfg = devtools_marker_cfg(cfg)
    prefer = str(marker_cfg.get("prefer_url_contains") or "/explore/")
    for tab in tabs:
        if prefer and prefer in str(tab.get("url") or ""):
            return tab
    return tabs[0]


def js_string(value: str) -> str:
    return json.dumps(str(value), ensure_ascii=False)


def mark_control_tab_titles(cfg: dict, selected_tab: Optional[dict], devtools_ok: bool = True) -> Optional[str]:
    """Register the control tab without changing the extension-owned title marker."""
    marker_cfg = devtools_marker_cfg(cfg)
    if not bool(marker_cfg.get("enabled", True)):
        return None
    current_marker = control_marker_for_status(cfg, devtools_ok=devtools_ok)
    selected_id = str((selected_tab or {}).get("id") or "")
    for tab in chrome_tabs(cfg):
        url = str(tab.get("url") or "")
        ws_url = str(tab.get("webSocketDebuggerUrl") or "")
        if "instagram.com" not in url or not ws_url:
            continue
        tab_id = str(tab.get("id") or "")
        is_selected = tab_id == selected_id
        expr = f"""
(() => {{
  const SELECTED = {json.dumps(bool(is_selected))};
  const TAB_ID = {json.dumps(tab_id)};
  const MARKER = {js_string(current_marker)};
  try {{
    window.__AUTO8700_CONTROL_TARGET__ = SELECTED;
    window.__AUTO8700_CONTROL_TAB_ID__ = TAB_ID;
    window.__AUTO8700_CONTROL_MARKER__ = MARKER;
    window.__AUTO8700_TITLE_MARKER_OWNER__ = 'IG_EXTENSION_ONLY';
    const detail = {{ok:true, source:'Auto8700/CDP', target_id:TAB_ID, tab_id:TAB_ID, request_tab_id:TAB_ID, cdp_schedule_only:true, marker:MARKER, ts:Date.now()}};
    try {{ window.dispatchEvent(new CustomEvent('igblue_auto8700_cdp_gate', {{detail}})); }} catch(e) {{}}
    try {{ window.postMessage(Object.assign({{source:'AUTO8700', type:'IGBLUE_AUTO8700_CDP_GATE', detail}}, detail), '*'); }} catch(e) {{}}
  }} catch(e) {{}}
  return {{title:document.title, selected:SELECTED, tab_id:TAB_ID, marker:MARKER, cdp_gate:true, no_title_marker_write:true}};
}})();
"""
        cdp_eval(ws_url, expr, timeout=2.0)
    return current_marker

def refresh_auto8700_control_marker(cfg: dict, devtools_ok: Optional[bool] = None) -> Optional[str]:
    """현재 WS/DevTools 상태를 기준으로 제어 대상 탭 marker를 다시 계산한다.

    devtools_ok=False이면 marker의 마지막 상태를 경고 단계로 낮춘다.
    title을 실제로 바꾸려면 /json/list와 CDP eval은 살아 있어야 하므로 chrome_debug_alive가 완전히 false면 변경하지 못한다.
    """
    can_mark = chrome_debug_alive(cfg)
    if devtools_ok is None:
        devtools_ok = can_mark
    if not can_mark:
        return None
    tab = choose_instagram_control_tab(cfg)
    if not tab:
        return None
    return mark_control_tab_titles(cfg, tab, devtools_ok=bool(devtools_ok))


def mark_auto8700_control_tabs_stale(cfg: dict) -> bool:
    """호환용: DevTools 자체는 살아 있지만 Network/WebSocket 통신이 불안정할 때 3번째 하트를 노란색으로 낮춘다."""
    marker = refresh_auto8700_control_marker(cfg, devtools_ok=False)
    return bool(marker)


def ensure_instagram_control_marker(cfg: dict) -> Optional[dict]:
    if not chrome_debug_alive(cfg):
        return None
    tab = choose_instagram_control_tab(cfg)
    if not tab:
        log("[DEV8700] Instagram 제어 탭을 찾지 못했습니다", cfg)
        return None
    # v187: control marker/CDP:O 갱신은 탭을 앞으로 가져오지 않는다.
    # 실제 예약 자동시작 탭 활성화는 IG side가 WS 사용자 마우스 idle(기본 10초) 확인 후 /api/cdp/ig/bring_to_front로 요청한다.
    marker = mark_control_tab_titles(cfg, tab, devtools_ok=True) or control_marker_for_status(cfg, devtools_ok=True)
    st = control_marker_status_payload(cfg, devtools_ok=True)
    event = {"kind": "control_tab", "url": tab.get("url"), "title": tab.get("title"), "tab_id": tab.get("id"), "debug_port": chrome_debug_port(cfg), "marker": marker, "ws_ok": st.get("ws_ok"), "devtools_ok": st.get("devtools_ok")}
    emit_devtools_event(event, cfg)
    return tab


def devtools_network_watch_loop(cfg: dict) -> None:
    DEVTOOLS_WATCH_STATE["running"] = True
    try:
        while True:
            ncfg = devtools_network_cfg(cfg)
            if DEVTOOLS_WATCH_STATE.get("stop") or not bool(ncfg.get("enabled", False)):
                break
            try:
                if not chrome_debug_alive(cfg):
                    time.sleep(2.0)
                    continue
                tab = ensure_instagram_control_marker(cfg)
                if not tab:
                    time.sleep(2.0)
                    continue
                ws_url = str(tab.get("webSocketDebuggerUrl") or "")
                if not ws_url:
                    time.sleep(2.0)
                    continue
                DEVTOOLS_WATCH_STATE["target_id"] = str(tab.get("id") or "")
                DEVTOOLS_WATCH_STATE["target_url"] = str(tab.get("url") or "")
                cdp = SimpleCDPWebSocket(ws_url, timeout=5.0)
                cdp.connect()
                cdp.send_cmd("Network.enable", {"maxTotalBufferSize": 2000000, "maxResourceBufferSize": 1000000})
                cdp.send_cmd("Page.enable")
                emit_devtools_event({"kind": "watch_start", "url": tab.get("url"), "title": tab.get("title")}, cfg)
                while bool(devtools_network_cfg(cfg).get("enabled", False)) and not DEVTOOLS_WATCH_STATE.get("stop"):
                    txt = cdp.recv_text(timeout=1.0)
                    if not txt:
                        continue
                    try:
                        data = json.loads(txt)
                    except Exception:
                        continue
                    method = str(data.get("method") or "")
                    params = data.get("params") or {}
                    if method == "Network.requestWillBeSent":
                        req = params.get("request") or {}
                        ev = {
                            "kind": "request",
                            "requestId": params.get("requestId"),
                            "type": params.get("type"),
                            "method": req.get("method"),
                            "url": req.get("url"),
                            "initiator": (params.get("initiator") or {}).get("type"),
                        }
                        emit_devtools_event(ev, cfg)
                    elif method == "Network.responseReceived":
                        res = params.get("response") or {}
                        ev = {
                            "kind": "response",
                            "requestId": params.get("requestId"),
                            "type": params.get("type"),
                            "status": res.get("status"),
                            "mime": res.get("mimeType"),
                            "url": res.get("url"),
                            "fromDiskCache": res.get("fromDiskCache"),
                            "fromPrefetchCache": res.get("fromPrefetchCache"),
                        }
                        emit_devtools_event(ev, cfg)
                    elif method in ("Network.webSocketFrameSent", "Network.webSocketFrameReceived"):
                        if bool(devtools_network_cfg(cfg).get("verbose", False)):
                            ev = {"kind": "websocket", "direction": "sent" if method.endswith("Sent") else "received", "url": DEVTOOLS_WATCH_STATE.get("target_url", "")}
                            emit_devtools_event(ev, cfg)
                cdp.close()
            except Exception as exc:
                DEVTOOLS_WATCH_STATE["last_error"] = str(exc)
                emit_devtools_event({"kind": "error", "message": str(exc)}, cfg)
                # 8700 DevTools 자체는 살아 있지만 Network WebSocket 통신이 끊긴 경우,
                # 기존 제어 대상 marker를 ❤️❤️💛로 낮춰서 "지정 이력은 있으나 현재 비통신" 상태를 남긴다.
                try:
                    if chrome_debug_alive(cfg):
                        marker = refresh_auto8700_control_marker(cfg, devtools_ok=False)
                        if marker:
                            emit_devtools_event({"kind": "control_tab", "message": "control marker updated: CDP channel not healthy", "marker": marker, "ws_ok": ws_service_alive(cfg), "devtools_ok": False}, cfg)
                except Exception:
                    pass
                time.sleep(2.0)
    finally:
        DEVTOOLS_WATCH_STATE["running"] = False
        emit_devtools_event({"kind": "watch_stop", "message": "CDP Network watch stopped"}, cfg)


def start_devtools_network_watch_if_enabled(cfg: dict) -> None:
    ncfg = devtools_network_cfg(cfg)
    cfg["devtools_network_watch"] = ncfg
    if not bool(ncfg.get("enabled", False)):
        return
    thread = DEVTOOLS_WATCH_STATE.get("thread")
    if thread is not None and getattr(thread, "is_alive", lambda: False)():
        return
    DEVTOOLS_WATCH_STATE["stop"] = False
    t = threading.Thread(target=devtools_network_watch_loop, args=(cfg,), daemon=True)
    DEVTOOLS_WATCH_STATE["thread"] = t
    t.start()
    log("[DEV8700] Network watcher thread 시작", cfg)


def stop_devtools_network_watch() -> None:
    DEVTOOLS_WATCH_STATE["stop"] = True


def parse_storage_changes(payload: dict) -> dict:
    settings: dict = {}
    src = payload.get("settings") if isinstance(payload.get("settings"), dict) else None
    if src:
        settings.update(src)
    changes = payload.get("changes") if isinstance(payload.get("changes"), dict) else None
    if changes:
        for key, val in changes.items():
            if isinstance(val, dict) and "newValue" in val:
                settings[key] = val.get("newValue")
            else:
                settings[key] = val
    return settings


def apply_devtools_settings_event(cfg: dict, payload: dict) -> dict:
    settings = parse_storage_changes(payload)
    ncfg = devtools_network_cfg(cfg)
    key_map = {
        "devtools_network_enabled": "enabled",
        "devtools_enabled": "enabled",
        "devtools_console": "console",
        "devtools_file_log": "file_log",
        "devtools_capture_body": "capture_body",
        "devtools_verbose": "verbose",
        "devtools_print_media": "print_media",
        "devtools_print_json": "print_json",
        "devtools_print_graphql": "print_graphql",
    }
    changed = {}
    for src_key, dst_key in key_map.items():
        if src_key in settings:
            ncfg[dst_key] = bool(settings.get(src_key))
            changed[dst_key] = ncfg[dst_key]
    # 웹 UI가 devtools_network_watch 자체를 보내는 경우도 허용한다.
    if isinstance(settings.get("devtools_network_watch"), dict):
        for k, v in settings["devtools_network_watch"].items():
            if k in ncfg:
                ncfg[k] = v
                changed[k] = v
    cfg["devtools_network_watch"] = ncfg
    if changed:
        log(f"[DEV8707] 설정 변경 수신: {changed}", cfg)
    if bool(ncfg.get("enabled", False)):
        start_devtools_network_watch_if_enabled(cfg)
    else:
        stop_devtools_network_watch()
    return {"ok": True, "changed": changed, "devtools_network_watch": ncfg}



# ------------------- Auto8700 schedule / IG auto-start bridge -------------------
AUTO8700_SCHEDULE_STATE = {
    "remaining_text": "예약 꺼짐", "next_at": "", "last_run_at": "",
    "last_result": "대기", "running": False, "last_request_id": "",
    "cdp_ready": False, "cdp_error": "", "ig_side_active": False
}

AUTO8700_SCHEDULE_STORAGE_KEY = "IGBLUE_AUTO8700_SCHEDULE_V1"
AUTO8700_IG_HEARTBEAT_TTL_SEC = 15.0


def auto_schedule_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("auto_schedule") or {})
    user = cfg.get("auto_schedule") or {}
    user_has_slot_keys = isinstance(user, dict) and any(k in user for k in ("schedule1_enabled", "schedule1_time", "schedule2_enabled", "schedule2_time"))
    if isinstance(user, dict):
        base.update(user)
    # v188: 예약은 2개 슬롯으로 운영한다. legacy enabled/time은 예약1로 호환한다.
    has_slot_keys = user_has_slot_keys
    legacy_enabled = bool(base.get("enabled", False))
    legacy_time = normalize_schedule_time_text(str(base.get("time") or "00:00"))
    base["schedule1_enabled"] = bool(base.get("schedule1_enabled", legacy_enabled if not has_slot_keys else False))
    base["schedule1_time"] = normalize_schedule_time_text(str(base.get("schedule1_time") or legacy_time or "00:00"))
    base["schedule2_enabled"] = bool(base.get("schedule2_enabled", False))
    base["schedule2_time"] = normalize_schedule_time_text(str(base.get("schedule2_time") or "00:00"))
    base["enabled"] = bool(base.get("schedule1_enabled") or base.get("schedule2_enabled"))
    base["time"] = str(base.get("schedule1_time") or legacy_time or "00:00")
    base["open_side"] = bool(base.get("open_side", True))
    base["cdp_storage_key"] = str(base.get("cdp_storage_key") or AUTO8700_SCHEDULE_STORAGE_KEY)
    for k in ("last_run_date", "last_run_date_1", "last_run_date_2", "last_delegated_date", "last_delegated_date_1", "last_delegated_date_2", "last_request_id", "ig_side_heartbeat_iso"):
        base[k] = str(base.get(k) or "")
    try:
        base["ig_side_heartbeat_ts"] = int(float(base.get("ig_side_heartbeat_ts") or 0))
    except Exception:
        base["ig_side_heartbeat_ts"] = 0
    try:
        base["revision"] = max(0, int(base.get("revision") or 0))
    except Exception:
        base["revision"] = 0
    base["updated_at"] = str(base.get("updated_at") or "")
    base["updated_source"] = str(base.get("updated_source") or "")
    cfg["auto_schedule"] = base
    return base


def auto_schedule_slots(scfg: dict, unique: bool = True) -> list:
    scfg = scfg if isinstance(scfg, dict) else {}
    rows = [
        {"id": 1, "enabled": bool(scfg.get("schedule1_enabled")), "time": normalize_schedule_time_text(str(scfg.get("schedule1_time") or scfg.get("time") or "00:00"))},
        {"id": 2, "enabled": bool(scfg.get("schedule2_enabled")), "time": normalize_schedule_time_text(str(scfg.get("schedule2_time") or "00:00"))},
    ]
    out = []
    seen = set()
    for row in rows:
        if not row.get("enabled"):
            continue
        if unique and row.get("time") in seen:
            continue
        seen.add(row.get("time"))
        out.append(row)
    return out


def auto8700_ui_cfg(cfg: dict) -> dict:
    base = dict(default_config().get("auto8700_ui") or {})
    user = cfg.get("auto8700_ui") or {}
    if isinstance(user, dict):
        base.update(user)
    try:
        base["alpha"] = max(0.35, min(1.0, float(base.get("alpha", 0.82))))
    except Exception:
        base["alpha"] = 0.82
    for k, default in (("x", 90), ("y", 90), ("stage", 0), ("expanded_width", 1120), ("compact_width", 520), ("bar_height", 42)):
        try:
            base[k] = int(base.get(k, default))
        except Exception:
            base[k] = default
    # v159 기준: Auto8700 floating UI는 관리 핵심 창이므로 local/legacy enabled=false로 영구 비활성화하지 않는다.
    # 닫기 버튼도 다음 실행까지 숨김 저장을 하지 않는다. 접기/펼치기는 2단계만 유지한다.
    base["stage"] = max(0, min(1, int(base.get("stage", 0) or 0)))
    if base.get("enabled") is False:
        base["enabled"] = True
        try:
            log("[UI] legacy/local auto8700_ui.enabled=false 감지 → v159 기준으로 floating UI 자동 복구", cfg)
        except Exception:
            pass
    base["enabled"] = True
    cfg["auto8700_ui"] = base
    return base

def normalize_schedule_time_text(value: str) -> str:
    raw = str(value or "").strip()
    m = re.fullmatch(r"(\d{1,2})\s*[:시]\s*(\d{1,2})(?:\s*[:분]\s*(\d{1,2}))?\s*(?:초)?", raw)
    if not m:
        m = re.fullmatch(r"(\d{1,2})(\d{2})", raw)
        if m:
            hh, mm, ss = int(m.group(1)), int(m.group(2)), 0
        else:
            return "00:00"
    else:
        hh, mm, ss = int(m.group(1)), int(m.group(2)), int(m.group(3) or 0)
    hh = max(0, min(23, hh)); mm = max(0, min(59, mm)); ss = max(0, min(59, ss))
    return f"{hh:02d}:{mm:02d}" if ss == 0 else f"{hh:02d}:{mm:02d}:{ss:02d}"


def next_schedule_datetime(time_text: str, now: Optional[datetime] = None) -> datetime:
    now = now or datetime.now()
    parts = [int(x) for x in normalize_schedule_time_text(time_text).split(":")]
    while len(parts) < 3:
        parts.append(0)
    target = now.replace(hour=parts[0], minute=parts[1], second=parts[2], microsecond=0)
    if target <= now:
        target += timedelta(days=1)
    return target


def format_remaining_seconds(seconds: float) -> str:
    sec = max(0, int(round(float(seconds))))
    return f"{sec//3600:02d}시간 {(sec%3600)//60:02d}분 {sec%60:02d}초"


def update_schedule_state(cfg: dict) -> dict:
    scfg = auto_schedule_cfg(cfg)
    AUTO8700_SCHEDULE_STATE["ig_side_active"] = bool(ig_side_schedule_active(cfg))
    slots = auto_schedule_slots(scfg, unique=True)
    if not slots:
        AUTO8700_SCHEDULE_STATE.update({"remaining_text": "예약 꺼짐", "next_at": "", "next_slot": ""})
        return AUTO8700_SCHEDULE_STATE
    now = datetime.now()
    next_rows = [(slot, next_schedule_datetime(str(slot.get("time") or "00:00"), now)) for slot in slots]
    slot, nxt = min(next_rows, key=lambda x: x[1])
    AUTO8700_SCHEDULE_STATE.update({"remaining_text": f"예약{slot.get('id')} " + format_remaining_seconds((nxt-now).total_seconds()), "next_at": nxt.strftime("%Y-%m-%d %H:%M:%S"), "next_slot": str(slot.get("id"))})
    return AUTO8700_SCHEDULE_STATE


def save_auto_schedule(cfg: dict, enabled: Optional[bool] = None, time_text: Optional[str] = None) -> dict:
    scfg = auto_schedule_cfg(cfg)
    if enabled is not None:
        scfg["schedule1_enabled"] = bool(enabled)
    if time_text is not None:
        scfg["schedule1_time"] = normalize_schedule_time_text(time_text)
    scfg["enabled"] = bool(scfg.get("schedule1_enabled") or scfg.get("schedule2_enabled"))
    scfg["time"] = str(scfg.get("schedule1_time") or "00:00")
    cfg["auto_schedule"] = scfg
    save_config(cfg)
    update_schedule_state(cfg)
    try:
        sync_auto_schedule_to_ig_storage(cfg, reason="save_auto_schedule")
    except Exception as exc:
        AUTO8700_SCHEDULE_STATE["cdp_error"] = str(exc)
    return scfg


def save_auto_schedule_settings(cfg: dict, settings: dict, source: str = "unknown") -> dict:
    settings = settings if isinstance(settings, dict) else {}
    scfg = auto_schedule_cfg(cfg)
    has_slots = any(k in settings for k in ("schedule1_enabled", "schedule1_time", "schedule2_enabled", "schedule2_time"))
    if has_slots:
        if "schedule1_enabled" in settings:
            scfg["schedule1_enabled"] = bool(settings.get("schedule1_enabled"))
        if "schedule1_time" in settings:
            scfg["schedule1_time"] = normalize_schedule_time_text(str(settings.get("schedule1_time") or "00:00"))
        if "schedule2_enabled" in settings:
            scfg["schedule2_enabled"] = bool(settings.get("schedule2_enabled"))
        if "schedule2_time" in settings:
            scfg["schedule2_time"] = normalize_schedule_time_text(str(settings.get("schedule2_time") or "00:00"))
    else:
        if "enabled" in settings:
            scfg["schedule1_enabled"] = bool(settings.get("enabled"))
        if "time" in settings:
            scfg["schedule1_time"] = normalize_schedule_time_text(str(settings.get("time") or "00:00"))
    scfg["enabled"] = bool(scfg.get("schedule1_enabled") or scfg.get("schedule2_enabled"))
    scfg["time"] = str(scfg.get("schedule1_time") or "00:00")
    scfg["revision"] = max(0, int(scfg.get("revision") or 0)) + 1
    scfg["updated_at"] = datetime.now().isoformat(timespec="seconds")
    scfg["updated_source"] = str(settings.get("save_source") or source or "unknown")
    cfg["auto_schedule"] = scfg
    save_config(cfg)
    update_schedule_state(cfg)
    try:
        sync_auto_schedule_to_ig_storage(cfg, reason="save_auto_schedule_settings")
    except Exception as exc:
        AUTO8700_SCHEDULE_STATE["cdp_error"] = str(exc)
    return scfg


def auto_schedule_payload(cfg: dict, reason: str = "status") -> dict:
    scfg = auto_schedule_cfg(cfg)
    return {
        "schema": AUTO8700_SCHEDULE_STORAGE_KEY,
        "owner": "Auto8700",
        "enabled": bool(scfg.get("enabled")),
        "time": normalize_schedule_time_text(str(scfg.get("time") or "00:00")),
        "schedule1_enabled": bool(scfg.get("schedule1_enabled")),
        "schedule1_time": normalize_schedule_time_text(str(scfg.get("schedule1_time") or "00:00")),
        "schedule2_enabled": bool(scfg.get("schedule2_enabled")),
        "schedule2_time": normalize_schedule_time_text(str(scfg.get("schedule2_time") or "00:00")),
        "revision": max(0, int(scfg.get("revision") or 0)),
        "updated_at": str(scfg.get("updated_at") or ""),
        "updated_source": str(scfg.get("updated_source") or ""),
        "slots": auto_schedule_slots(scfg, unique=True),
        "last_run_date": str(scfg.get("last_run_date") or ""),
        "last_run_date_1": str(scfg.get("last_run_date_1") or ""),
        "last_run_date_2": str(scfg.get("last_run_date_2") or ""),
        "last_delegated_date": str(scfg.get("last_delegated_date") or ""),
        "last_delegated_date_1": str(scfg.get("last_delegated_date_1") or ""),
        "last_delegated_date_2": str(scfg.get("last_delegated_date_2") or ""),
        "cdp_storage_key": str(scfg.get("cdp_storage_key") or AUTO8700_SCHEDULE_STORAGE_KEY),
        "payload_at": datetime.now().isoformat(timespec="seconds"),
        "reason": str(reason or "status"),
    }


def ig_side_schedule_active(cfg: dict) -> bool:
    scfg = auto_schedule_cfg(cfg)
    try:
        ts = int(float(scfg.get("ig_side_heartbeat_ts") or 0))
    except Exception:
        ts = 0
    if ts <= 0:
        return False
    return (time.time() * 1000 - ts) <= (AUTO8700_IG_HEARTBEAT_TTL_SEC * 1000)


def sync_auto_schedule_to_ig_storage(cfg: dict, reason: str = "status") -> bool:
    # Auto8700이 자기 저장값을 Instagram origin localStorage에 읽기 전용 mirror로 배포한다.
    # side는 이 값을 저장 원본으로 사용하지 않고 8707 /status와 /schedule 응답만 사용한다.
    AUTO8700_SCHEDULE_STATE["cdp_ready"] = False
    AUTO8700_SCHEDULE_STATE["cdp_error"] = ""
    if not chrome_debug_alive(cfg):
        mode = chrome_cdp_mode_status(cfg)
        AUTO8700_SCHEDULE_STATE["cdp_error"] = str(mode.get("reason") or "cdp_mode_not_ready")
        return False
    tabs = [t for t in chrome_tabs(cfg) if "instagram.com" in str(t.get("url") or "") and t.get("webSocketDebuggerUrl")]
    tab = None
    if tabs:
        tab = ensure_instagram_control_marker(cfg) or tabs[0]
    if not tab:
        AUTO8700_SCHEDULE_STATE["cdp_error"] = "instagram_tab_not_found"
        return False
    ws_url = str(tab.get("webSocketDebuggerUrl") or "")
    if not ws_url:
        AUTO8700_SCHEDULE_STATE["cdp_error"] = "websocket_debugger_url_missing"
        return False
    payload = auto_schedule_payload(cfg, reason=reason)
    key = str(payload.get("cdp_storage_key") or AUTO8700_SCHEDULE_STORAGE_KEY)
    expr = f"""
(() => {{
  const key = {json.dumps(key, ensure_ascii=False)};
  const payload = {json.dumps(payload, ensure_ascii=False)};
  try {{
    localStorage.setItem(key, JSON.stringify(payload));
    window.__IGBLUE_AUTO8700_SCHEDULE__ = payload;
    window.dispatchEvent(new CustomEvent('igblue_auto8700_schedule_sync', {{ detail: payload }}));
    return {{ok:true, key, payload}};
  }} catch (e) {{
    return {{ok:false, error:String(e && e.message || e)}};
  }}
}})();
"""
    ok = cdp_eval(ws_url, expr, timeout=3.0)
    AUTO8700_SCHEDULE_STATE["cdp_ready"] = bool(ok)
    if not ok:
        AUTO8700_SCHEDULE_STATE["cdp_error"] = "cdp_eval_failed"
    return bool(ok)


def ensure_ig_chrome_for_schedule(cfg: dict) -> bool:
    icfg = chrome_cfg(cfg)
    cfg["ig_chrome"] = icfg
    if chrome_debug_alive(cfg):
        ensure_instagram_tab(cfg)
        return bool(chrome_debug_alive(cfg))
    ok = launch_chrome_cdp_user_data_dir(cfg, reason="auto_schedule")
    if not ok:
        AUTO8700_SCHEDULE_STATE["last_result"] = "CDP모드 실행 실패: 예약 실행 보류"
    return bool(ok)

def dispatch_ig_auto_start_request(cfg: dict, reason: str = "auto_schedule") -> dict:
    if not ensure_ig_chrome_for_schedule(cfg):
        return {"ok": False, "error": "cdp_mode_not_ready"}
    tab = ensure_instagram_control_marker(cfg) or choose_instagram_control_tab(cfg)
    if not tab:
        return {"ok": False, "error": "instagram_tab_not_found"}
    # v187: 예약 시작 주입 전에 CDP로 탭을 강제 활성화하지 않는다.
    # IG side가 WS 사용자 마우스 10초 idle 확인 후 필요할 때만 /api/cdp/ig/bring_to_front를 호출한다.
    ws_url = str(tab.get("webSocketDebuggerUrl") or "")
    if not ws_url:
        return {"ok": False, "error": "websocket_debugger_url_missing"}
    request_id = f"auto8700_{int(time.time())}_{os.getpid()}"
    payload = {"type": "IGBLUE_AUTO8700_START_REQUEST", "source": "Auto8700", "reason": str(reason or "auto_schedule"), "request_id": request_id, "open_side": bool(auto_schedule_cfg(cfg).get("open_side", True)), "cdp_activate_before_start": True, "cdp_tab_activation_policy": "direct_schedule_activation", "ts": int(time.time()*1000)}
    expr = f"""
(() => {{
  const payload = {json.dumps(payload, ensure_ascii=False)};
  try {{ document.dispatchEvent(new CustomEvent('igblue_auto8700_schedule_start', {{ detail: payload }})); }} catch (e) {{}}
  try {{ window.postMessage(Object.assign({{source:'AUTO8700'}}, payload), '*'); }} catch (e) {{}}
  window.__AUTO8700_LAST_AUTO_START_REQUEST__ = payload;
  return payload.request_id;
}})();
"""
    ok = cdp_eval(ws_url, expr, timeout=3.0)
    if ok:
        AUTO8700_SCHEDULE_STATE.update({"last_request_id": request_id, "last_result": "IG 자동시작 요청 전송", "last_run_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")})
        emit_devtools_event({"kind": "schedule_start", "message": "IG auto start request dispatched", "request_id": request_id, "url": tab.get("url"), "tab_id": tab.get("id")}, cfg)
        return {"ok": True, "request_id": request_id, "tab_id": tab.get("id"), "url": tab.get("url")}
    return {"ok": False, "error": "cdp_eval_failed"}


def run_schedule_start_async(cfg: dict, reason: str) -> None:
    def worker():
        AUTO8700_SCHEDULE_STATE["running"] = True
        try:
            res = dispatch_ig_auto_start_request(cfg, reason=reason)
            AUTO8700_SCHEDULE_STATE["last_result"] = "실행 요청 OK" if res.get("ok") else f"실행 요청 실패: {res.get('error')}"
            log(f"[SCHEDULE] 자동예약 실행 결과: {res}", cfg)
        except Exception as exc:
            AUTO8700_SCHEDULE_STATE["last_result"] = f"실행 예외: {exc}"; log(f"[SCHEDULE] 자동예약 실행 예외: {exc}", cfg)
        finally:
            AUTO8700_SCHEDULE_STATE["running"] = False
    threading.Thread(target=worker, name="auto8700-schedule-start", daemon=True).start()


def auto8700_schedule_tick(cfg: dict) -> None:
    scfg = auto_schedule_cfg(cfg); update_schedule_state(cfg)
    slots = auto_schedule_slots(scfg, unique=True)
    if not slots:
        return
    now = datetime.now(); today_key = now.strftime("%Y-%m-%d")
    for slot in slots:
        slot_id = int(slot.get("id") or 1)
        parts = [int(x) for x in normalize_schedule_time_text(str(slot.get("time") or "00:00")).split(":")]
        while len(parts) < 3:
            parts.append(0)
        run_key = f"last_run_date_{slot_id}"
        delegated_key = f"last_delegated_date_{slot_id}"
        if str(scfg.get(run_key) or "") == today_key:
            continue
        target = now.replace(hour=parts[0], minute=parts[1], second=parts[2], microsecond=0)
        delta = (now - target).total_seconds()
        if 0 <= delta < 90 and not AUTO8700_SCHEDULE_STATE.get("running"):
            if ig_side_schedule_active(cfg):
                if str(scfg.get(delegated_key) or "") != today_key:
                    scfg[delegated_key] = today_key
                    scfg["last_delegated_date"] = today_key
                    cfg["auto_schedule"] = scfg
                    save_config(cfg)
                    AUTO8700_SCHEDULE_STATE["last_result"] = f"IG 실행중: 예약{slot_id} 실행을 IG에 위임"
                    log(f"[SCHEDULE] IG side heartbeat 감지 → 예약{slot_id} 실행을 IG에 위임", cfg)
                continue
            scfg[run_key] = today_key
            scfg["last_run_date"] = today_key
            cfg["auto_schedule"] = scfg
            save_config(cfg)
            run_schedule_start_async(cfg, reason=f"daily_schedule_{slot_id}")
            return




def _first_instagram_cdp_target(cfg: dict) -> dict:
    try:
        if not chrome_debug_alive(cfg):
            return {}
        tabs = [t for t in chrome_tabs(cfg) if "instagram.com" in str(t.get("url") or "")]
        return cdp_sanitize_target(tabs[0]) if tabs else {}
    except Exception:
        return {}


def _core_status_from_ig_target(cfg: dict, tab: dict) -> dict:
    """Best-effort CDP target snapshot."""
    out = {
        "tab_id": str(tab.get("id") or ""),
        "target_id": str(tab.get("id") or ""),
        "url": str(tab.get("url") or ""),
        "title": str(tab.get("title") or ""),
    }
    ws_url = str(tab.get("webSocketDebuggerUrl") or "")
    if not ws_url:
        return out
    expr = "(() => ({ok:true, title:document.title, href:location.href}))()"
    try:
        res = cdp_call(ws_url, "Runtime.evaluate", {"expression": expr, "awaitPromise": False, "returnByValue": True}, timeout=1.8)
        val = (((res.get("result") or {}).get("result") or {}).get("value") or {}) if isinstance(res, dict) else {}
        if isinstance(val, dict):
            out["runtime_title"] = str(val.get("title") or "")
            out["runtime_href"] = str(val.get("href") or "")
    except Exception as exc:
        out["runtime_error"] = str(exc)
    return out


def auto8700_core_clipboard_text(cfg: dict, ui_status: str = "", ui_log: str = "") -> str:
    """Auth is the creator/controller: copy the relation map needed to judge which process/window/tab is linked."""
    try:
        chrome_ok = chrome_debug_alive(cfg)
    except Exception:
        chrome_ok = False
    try:
        ws_ok = ws_service_alive(cfg)
    except Exception:
        ws_ok = False
    try:
        ws_pids = netstat_port_pids_windows(8771) if is_windows() else []
        rtc8790 = netstat_port_pids_windows(8790) if is_windows() else []
        rtc8791 = netstat_port_pids_windows(8791) if is_windows() else []
    except Exception:
        ws_pids = rtc8790 = rtc8791 = []
    chrome_profiles = running_chrome_profiles()
    chrome_pids = []
    for item in chrome_profiles:
        for pid in item.get("pids") or []:
            if pid not in chrome_pids:
                chrome_pids.append(pid)
    tab = _first_instagram_cdp_target(cfg)
    core = _core_status_from_ig_target(cfg, tab) if tab else {}
    profile = ",".join(str(p.get("profile_directory") or "") for p in (chrome_cfg(cfg).get("profiles") or [])) or "-"
    port = chrome_debug_port(cfg)
    cdp_txt = "CDP:OK" if chrome_ok else "CDP:XX"
    cdp_mode = chrome_cdp_mode_status(cfg)
    lines = [
        f"Auto8700 Auth/Core v228",
        f"ts={datetime.now().isoformat(timespec='seconds')}",
        "",
        "프로세스:",
        f"Auto8700 pid={os.getpid()}",
        f"WS8771 pid={','.join(map(str, ws_pids)) or '-'}",
        f"RTC8790 pid={','.join(map(str, rtc8790)) or '-'}",
        f"RTC8791 pid={','.join(map(str, rtc8791)) or '-'}",
        f"Chrome pid={','.join(map(str, chrome_pids)) or '-'}",
        f"Chrome profile={profile}",
        f"Chrome port={port}",
        f"Chrome CDP모드={'OK' if chrome_ok else 'XX'}",
        f"CDP mode reason={cdp_mode.get('reason') or '-'}",
        f"CDP mode pids={','.join(map(str, cdp_mode.get('cdp_pids') or [])) or '-'}",
        f"Chrome non-CDP pids={','.join(map(str, cdp_mode.get('non_cdp_pids') or [])) or '-'}",
        f"WS={'OK' if ws_ok else 'XX'}",
        "",
        "제어권:",
        f"target_id={core.get('target_id') or '-'}",
        f"window_id={core.get('window_id') or '-'}",
        f"active_tab_id={core.get('tab_id') or '-'}",
        f"request_tab_id={core.get('request_tab_id') or '-'}",
        f"blocked_reason={core.get('blocked_reason') or '-'}",
        "",
        "연동:",
        cdp_txt,
        f"Chrome title={core.get('runtime_title') or core.get('title') or '-'}",
        f"Chrome url={core.get('url') or '-'}",
    ]
    if ui_status or ui_log:
        lines += ["", "UI:", str(ui_status or "-"), str(ui_log or "-")]
    return "\n".join(lines).rstrip() + "\n\n\n"

class Auto8700FloatingUI:
    def __init__(self, cfg: dict):
        if tk is None:
            raise RuntimeError("tkinter not available")
        self.cfg = cfg; self.drag_start_x = 0; self.drag_start_y = 0; self.stage = int(auto8700_ui_cfg(cfg).get("stage", 0) or 0); self.settings_win = None
        log(f"[UI] TK_CREATE_BEGIN thread={threading.current_thread().name}", cfg)
        self.root = tk.Tk()
        log("[UI] TK_CREATED", cfg)
        self.root.title("Auto8700")
        self.root.overrideredirect(True)
        self.root.attributes("-topmost", True)
        self.root.protocol("WM_DELETE_WINDOW", self.close_ui)
        self.root.report_callback_exception = self._report_callback_exception
        ui = auto8700_ui_cfg(cfg)
        raw_alpha = float(ui.get("alpha", 0.82) or 0.82)
        safe_alpha = max(0.45, min(1.0, raw_alpha))
        self.root.attributes("-alpha", safe_alpha)
        log(f"[UI] CONFIG enabled={bool(ui.get('enabled', True))} stage={int(ui.get('stage', 0) or 0)} requested_xy={int(ui.get('x', 90) or 90)},{int(ui.get('y', 90) or 90)} alpha_raw={raw_alpha} alpha_applied={safe_alpha}", cfg)
        self.root.configure(bg="#16202a")
        self.frame = tk.Frame(self.root, bg="#16202a", bd=1, relief="solid")
        self.frame.pack(fill="both", expand=True)
        for c, w in enumerate([0,2,3,0,0,0,0,1,0,0,0,0,0,0]):
            self.frame.grid_columnconfigure(c, weight=w)
        self.drag_pointer_start_x = 0
        self.drag_pointer_start_y = 0
        self.drag_moved = False
        # v167: 별도 '이동' 버튼은 제거한다. 버튼이 아닌 텍스트/상태 표시 영역만 드래그 핸들로 사용한다.
        self.title_label = tk.Label(self.frame, text="Auto8700", fg="#fef3c7", bg="#334155", font=("맑은 고딕", 10, "bold"), padx=8, cursor="fleur")
        self.status_label = tk.Label(self.frame, text="상태 확인중", fg="#dbeafe", bg="#1e3a5f", font=("맑은 고딕", 9), anchor="w", padx=8, cursor="fleur", justify="left")
        self.log_label = tk.Label(self.frame, text="로그 대기", fg="#f8fafc", bg="#1f2937", font=("맑은 고딕", 9), anchor="w", padx=8, cursor="fleur", justify="left")
        scfg0 = auto_schedule_cfg(cfg)
        self.schedule1_var = tk.BooleanVar(value=bool(scfg0.get("schedule1_enabled")))
        self.schedule2_var = tk.BooleanVar(value=bool(scfg0.get("schedule2_enabled")))
        self.schedule1_check = tk.Checkbutton(self.frame, text="예약1", variable=self.schedule1_var, command=self.save_schedule_from_ui, bg="#16202a", fg="#e5e7eb", selectcolor="#111827", activebackground="#16202a", activeforeground="white")
        self.schedule2_check = tk.Checkbutton(self.frame, text="예약2", variable=self.schedule2_var, command=self.save_schedule_from_ui, bg="#16202a", fg="#e5e7eb", selectcolor="#111827", activebackground="#16202a", activeforeground="white")
        self.time1_var = tk.StringVar(value=str(scfg0.get("schedule1_time") or "00:00"))
        self.time2_var = tk.StringVar(value=str(scfg0.get("schedule2_time") or "00:00"))
        self.time1_entry = tk.Entry(self.frame, textvariable=self.time1_var, width=6, justify="center")
        self.time2_entry = tk.Entry(self.frame, textvariable=self.time2_var, width=6, justify="center")
        self.count_label = tk.Label(self.frame, text="예약 꺼짐", fg="#a7f3d0", bg="#064e3b", font=("맑은 고딕", 9, "bold"), padx=6, cursor="fleur")
        self.run_btn = tk.Button(self.frame, text="자동실행", command=self.run_now, width=7, bd=0, bg="#2563eb", fg="white")
        self.chrome_btn = tk.Button(self.frame, text="CDP모드", command=self.launch_chrome_from_ui, width=8, bd=0, bg="#059669", fg="white")
        self.save_btn = tk.Button(self.frame, text="저장", command=self.save_schedule_from_ui, width=5, bd=0, bg="#4b5563", fg="white")
        self.setting_btn = tk.Button(self.frame, text="설정", command=self.open_settings, width=5, bd=0, bg="#6b7280", fg="white")
        self.fold_btn = tk.Button(self.frame, text="접기", command=self.cycle_stage, width=5, bd=0, bg="#374151", fg="white")
        self.close_btn = tk.Button(self.frame, text="전체종료", command=self.close_ui, width=7, bd=0, bg="#7f1d1d", fg="white")
        self.last_status_text = ""
        self.cdp_mode_prompted = False
        # v171: Python/Tk hover tooltip은 UI 안정성 우선으로 제거한다. 더블클릭 클립보드 복사만 유지한다.
        # v167: 버튼 제외 텍스트/상태 표시 영역만 드래그 가능하다.
        for drag_widget in (self.title_label, self.status_label, self.log_label, self.count_label):
            drag_widget.bind("<ButtonPress-1>", self.start_move)
            drag_widget.bind("<B1-Motion>", self.do_move)
        self.title_label.bind("<ButtonRelease-1>", self.end_move)
        for copy_drag_widget in (self.status_label, self.count_label, self.log_label):
            copy_drag_widget.bind("<ButtonRelease-1>", self.end_move_and_copy_if_click)
            copy_drag_widget.bind("<Double-Button-1>", self.copy_log_to_clipboard, add="+")
        self.time1_entry.bind("<Return>", lambda e: self.save_schedule_from_ui())
        self.time2_entry.bind("<Return>", lambda e: self.save_schedule_from_ui())
        log("[UI] LAYOUT_BEGIN", cfg)
        self.apply_layout(save=False)
        log(f"[UI] LAYOUT_DONE geometry={self.root.geometry()}", cfg)
        self.root.update_idletasks()
        log(f"[UI] IDLETASKS_DONE snapshot={json.dumps(_igblue_tk_window_snapshot(self.root), ensure_ascii=False, sort_keys=True)}", cfg)
        self.root.deiconify()
        log("[UI] DEICONIFY_DONE", cfg)
        self.root.lift()
        log("[UI] LIFT_DONE", cfg)
        self.root.attributes("-topmost", True)
        self.root.update()
        log(f"[UI] WINDOW_VISIBLE snapshot={json.dumps(_igblue_tk_window_snapshot(self.root), ensure_ascii=False, sort_keys=True)}", cfg)
        self.root.after(250, self.verify_window_visible)
        self.root.after(300, self.tick)
        self.root.after(1200, self.check_cdp_mode_on_start)

    def verify_window_visible(self):
        try:
            before = _igblue_tk_window_snapshot(self.root)
            log(f"[UI] VERIFY_BEGIN snapshot={json.dumps(before, ensure_ascii=False, sort_keys=True)}", self.cfg)
            width = max(1, int(self.root.winfo_width() or 1))
            height = max(1, int(self.root.winfo_height() or 1))
            x = int(self.root.winfo_x())
            y = int(self.root.winfo_y())
            w2, h2, x2, y2, area = clamp_tk_window_geometry_primary(self.root, width, height, x, y, width, height)
            mapped = bool(self.root.winfo_ismapped())
            viewable = bool(self.root.winfo_viewable())
            rect = before.get("win32_rect") if isinstance(before, dict) else None
            intersects = bool(rect and len(rect) == 4 and rect[2] > area["left"] and rect[0] < area["right"] and rect[3] > area["top"] and rect[1] < area["bottom"])
            needs_force = (x != x2 or y != y2 or not mapped or not viewable or not intersects)
            if needs_force:
                self.root.geometry(f"{w2}x{h2}+{x2}+{y2}")
                self.root.deiconify()
                self.root.lift()
                self.root.attributes("-topmost", False)
                self.root.attributes("-topmost", True)
                self.root.update_idletasks()
                self.root.update()
                ui = self.ui_cfg()
                ui["x"] = int(x2)
                ui["y"] = int(y2)
                self.cfg["auto8700_ui"] = ui
                save_config(self.cfg)
                log(f"[UI] FORCE_VISIBLE reason=offscreen_or_unmapped from={x},{y} to={x2},{y2} area={area} after={json.dumps(_igblue_tk_window_snapshot(self.root), ensure_ascii=False, sort_keys=True)}", self.cfg)
            else:
                log(f"[UI] VERIFY_OK geometry={self.root.geometry()} area={area}", self.cfg)
        except Exception as exc:
            log(f"[UI] VERIFY_ERROR: {exc}", self.cfg)

    def _report_callback_exception(self, exc_type, exc_value, exc_traceback):
        try:
            log(f"[UI] CALLBACK_ERROR {exc_type.__name__}: {exc_value}", self.cfg)
        except Exception:
            pass

    def ui_cfg(self): return auto8700_ui_cfg(self.cfg)
    def apply_layout(self, save: bool=True):
        ui = self.ui_cfg()
        for w in (self.title_label,self.status_label,self.log_label,self.schedule1_check,self.time1_entry,self.schedule2_check,self.time2_entry,self.count_label,self.run_btn,self.chrome_btn,self.save_btn,self.setting_btn,self.fold_btn,self.close_btn):
            try:
                w.grid_forget()
            except Exception:
                pass
        requested_x = int(ui.get("x", 90))
        requested_y = int(ui.get("y", 90))
        x = requested_x
        y = requested_y
        h = max(38, min(56, int(ui.get("bar_height", 42) or 42)))
        self.stage = max(0, min(1, int(self.stage)))
        if self.stage == 1:
            width = max(500, int(ui.get('compact_width',520) or 520))
            if save:
                width, h, x, y = clamp_tk_window_geometry(self.root, width, h, x, y, 360, h)
                area = _igblue_work_area_for_rect(x, y, width, h, self.root)
            else:
                width, h, x, y, area = clamp_tk_window_geometry_primary(self.root, width, h, x, y, 360, h)
            self.root.geometry(f"{width}x{h}+{x}+{y}")
            log(f"[UI] GEOMETRY_APPLY stage=compact startup={not save} requested={requested_x},{requested_y} applied={x},{y} size={width}x{h} area={area} corrected={requested_x != x or requested_y != y}", self.cfg)
            self.title_label.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
            self.status_label.grid(row=0, column=1, columnspan=2, sticky="nsew", padx=2, pady=4)
            self.count_label.grid(row=0, column=7, sticky="e", padx=2, pady=4)
            self.fold_btn.config(text="펼침", bg="#2563eb")
            self.fold_btn.grid(row=0, column=12, sticky="e", padx=2, pady=5)
            self.close_btn.grid(row=0, column=13, sticky="e", padx=(0,6), pady=5)
        else:
            width = max(1220, int(ui.get('expanded_width',1240) or 1240))
            if save:
                width, h, x, y = clamp_tk_window_geometry(self.root, width, h, x, y, 500, h)
                area = _igblue_work_area_for_rect(x, y, width, h, self.root)
            else:
                width, h, x, y, area = clamp_tk_window_geometry_primary(self.root, width, h, x, y, 500, h)
            self.root.geometry(f"{width}x{h}+{x}+{y}")
            log(f"[UI] GEOMETRY_APPLY stage=expanded startup={not save} requested={requested_x},{requested_y} applied={x},{y} size={width}x{h} area={area} corrected={requested_x != x or requested_y != y}", self.cfg)
            self.title_label.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
            self.status_label.grid(row=0, column=1, sticky="nsew", padx=2, pady=4)
            self.log_label.grid(row=0, column=2, sticky="nsew", padx=2, pady=4)
            self.schedule1_check.grid(row=0, column=3, sticky="e", padx=2, pady=5)
            self.time1_entry.grid(row=0, column=4, sticky="e", padx=2, pady=5)
            self.schedule2_check.grid(row=0, column=5, sticky="e", padx=2, pady=5)
            self.time2_entry.grid(row=0, column=6, sticky="e", padx=2, pady=5)
            self.count_label.grid(row=0, column=7, sticky="e", padx=2, pady=4)
            self.run_btn.grid(row=0, column=8, sticky="e", padx=2, pady=5)
            self.chrome_btn.grid(row=0, column=9, sticky="e", padx=2, pady=5)
            self.save_btn.grid(row=0, column=10, sticky="e", padx=2, pady=5)
            self.setting_btn.grid(row=0, column=11, sticky="e", padx=2, pady=5)
            self.fold_btn.config(text="접기", bg="#374151")
            self.fold_btn.grid(row=0, column=12, sticky="e", padx=2, pady=5)
            self.close_btn.grid(row=0, column=13, sticky="e", padx=(0,6), pady=5)
        if save:
            self.save_ui_geometry()
    def save_ui_geometry(self):
        try:
            ui=self.ui_cfg(); ui["x"]=int(self.root.winfo_x()); ui["y"]=int(self.root.winfo_y()); ui["stage"]=int(self.stage); self.cfg["auto8700_ui"]=ui; save_config(self.cfg)
        except Exception: pass
    def start_move(self,event):
        try:
            self.drag_pointer_start_x=self.root.winfo_pointerx(); self.drag_pointer_start_y=self.root.winfo_pointery()
            self.drag_start_x=self.drag_pointer_start_x-int(self.root.winfo_x())
            self.drag_start_y=self.drag_pointer_start_y-int(self.root.winfo_y())
        except Exception:
            self.drag_start_x=getattr(event,"x",0); self.drag_start_y=getattr(event,"y",0)
            self.drag_pointer_start_x=0; self.drag_pointer_start_y=0
        self.drag_moved=False
    def do_move(self,event):
        try:
            if abs(self.root.winfo_pointerx()-int(getattr(self,"drag_pointer_start_x",0)))>3 or abs(self.root.winfo_pointery()-int(getattr(self,"drag_pointer_start_y",0)))>3:
                self.drag_moved=True
        except Exception:
            self.drag_moved=True
        x = self.root.winfo_pointerx()-self.drag_start_x
        y = self.root.winfo_pointery()-self.drag_start_y
        w, h, x, y = clamp_tk_window_geometry(self.root, self.root.winfo_width(), self.root.winfo_height(), x, y, max(1, self.root.winfo_width()), max(1, self.root.winfo_height()))
        self.root.geometry(f"{w}x{h}+{x}+{y}")
    def end_move(self,event): self.save_ui_geometry()
    def end_move_and_copy_if_click(self,event):
        moved=bool(getattr(self,"drag_moved",False))
        self.end_move(event)
        if not moved:
            self.copy_log_to_clipboard(event)
    def cycle_stage(self):
        self.stage = 1 if int(self.stage) == 0 else 0
        self.apply_layout(save=True)
    def copy_log_to_clipboard(self, event=None):
        try:
            ui_status = str(getattr(self, "last_status_text", "") or self.status_label.cget("text") or "")
            ui_log = str(self.log_label.cget("text") or "")
            payload = auto8700_core_clipboard_text(self.cfg, ui_status=ui_status, ui_log=ui_log)
            self.root.clipboard_clear()
            self.root.clipboard_append(payload)
            self.root.update_idletasks()
            AUTO8700_SCHEDULE_STATE["last_result"] = "핵심 로그 클립보드 복사 완료"
        except Exception as exc:
            try:
                self.status_label.config(text=f"클립보드 복사 실패: {exc}")
            except Exception:
                pass
    def _ask_cdp_mode_restart(self, context: str = "ui") -> bool:
        if chrome_debug_alive(self.cfg):
            return True
        self.cdp_mode_prompted = True
        AUTO8700_SCHEDULE_STATE["last_result"] = "전용 CDP user-data-dir로 CDP모드 실행 중"
        def worker():
            ok = launch_chrome_cdp_user_data_dir(self.cfg, reason=f"{context}_cdp_user_data_dir")
            if ok:
                AUTO8700_SCHEDULE_STATE.update({"cdp_ready": True, "cdp_prompt_required": False, "last_result": "CDP모드 실행 OK"})
            else:
                disable_auto_schedule_due_to_cdp_mode(self.cfg, reason=f"{context}_cdp_user_data_dir_failed")
        threading.Thread(target=worker, name=f"auto8700-cdp-user-data-dir-{context}", daemon=True).start()
        return True

    def check_cdp_mode_on_start(self):
        if self.cdp_mode_prompted or chrome_debug_alive(self.cfg):
            return
        if not bool(AUTO8700_SCHEDULE_STATE.get("startup_services_ready")):
            try:
                self.root.after(1000, self.check_cdp_mode_on_start)
            except Exception:
                pass
            return
        log("[CDP_MODE] UI 표시 완료 후 전용 CDP user-data-dir 실행 확인", self.cfg)
        self._ask_cdp_mode_restart("startup")

    def save_schedule_from_ui(self):
        try:
            settings = {
                "schedule1_enabled": bool(self.schedule1_var.get()),
                "schedule1_time": str(self.time1_var.get() or "00:00"),
                "schedule2_enabled": bool(self.schedule2_var.get()),
                "schedule2_time": str(self.time2_var.get() or "00:00"),
            }
            want_enabled = bool(settings["schedule1_enabled"] or settings["schedule2_enabled"])
            if want_enabled and not chrome_debug_alive(self.cfg):
                AUTO8700_SCHEDULE_STATE["last_result"] = "CDP모드 실행 중: 예약 저장 대기"
                def worker():
                    if launch_chrome_cdp_user_data_dir(self.cfg, reason="schedule_enable_cdp_user_data_dir"):
                        save_auto_schedule_settings(self.cfg, settings)
                        AUTO8700_SCHEDULE_STATE["last_result"] = "CDP모드 실행 후 예약 저장 완료"
                    else:
                        off = dict(settings)
                        off["schedule1_enabled"] = False
                        off["schedule2_enabled"] = False
                        save_auto_schedule_settings(self.cfg, off)
                        disable_auto_schedule_due_to_cdp_mode(self.cfg, reason="schedule_enable_cdp_user_data_dir_failed")
                        try:
                            self.schedule1_var.set(False); self.schedule2_var.set(False)
                        except Exception:
                            pass
                threading.Thread(target=worker, name="auto8700-schedule-cdp-user-data-dir", daemon=True).start()
                return
            scfg = save_auto_schedule_settings(self.cfg, settings)
            self.time1_var.set(str(scfg.get("schedule1_time") or "00:00"))
            self.time2_var.set(str(scfg.get("schedule2_time") or "00:00"))
            AUTO8700_SCHEDULE_STATE["last_result"]="예약 저장 완료"
        except Exception as exc:
            if messagebox: messagebox.showerror("예약 저장 오류", str(exc), parent=self.root)

    def run_now(self): run_schedule_start_async(self.cfg, reason="manual_ui_run_now")
    def launch_chrome_from_ui(self):
        if chrome_debug_alive(self.cfg):
            ensure_instagram_tab(self.cfg); AUTO8700_SCHEDULE_STATE["last_result"]="CDP모드 이미 실행중"; return
        threading.Thread(target=lambda: launch_chrome_cdp_user_data_dir(self.cfg, reason="manual_ui_cdp_user_data_dir"), name="auto8700-ui-cdp-user-data-dir", daemon=True).start()

    def open_settings(self):
        try:
            if self.settings_win is not None and self.settings_win.winfo_exists():
                self.settings_win.deiconify()
                self.settings_win.lift()
                self.settings_win.focus_force()
                return
        except Exception:
            self.settings_win = None
        win = tk.Toplevel(self.root)
        self.settings_win = win
        try:
            win.protocol("WM_DELETE_WINDOW", lambda: (setattr(self, "settings_win", None), win.destroy()))
        except Exception:
            pass
        win.title("Auto8700 설정")
        win.attributes("-topmost", True)
        win.configure(bg="#f3f4f6")
        w, h, x, y = clamp_tk_window_geometry(win, 820, 590, int(self.root.winfo_x()) + 24, int(self.root.winfo_y()) + 24, 560, 380)
        win.geometry(f"{w}x{h}+{x}+{y}")

        tk.Label(win, text="투명도", bg="#f3f4f6").grid(row=0, column=0, padx=10, pady=(10,4), sticky="w")
        alpha_var = tk.DoubleVar(value=float(self.ui_cfg().get("alpha", 0.82)) * 100)
        tk.Scale(win, from_=35, to=100, orient="horizontal", variable=alpha_var, length=260).grid(row=0, column=1, padx=10, pady=(10,4), sticky="w")
        tk.Label(win, text="확장 폭", bg="#f3f4f6").grid(row=1, column=0, padx=10, pady=4, sticky="w")
        width_var = tk.StringVar(value=str(self.ui_cfg().get("expanded_width", 860)))
        tk.Entry(win, textvariable=width_var, width=20).grid(row=1, column=1, padx=10, pady=4, sticky="w")

        pcfg = self.cfg.get("python_logs") if isinstance(self.cfg.get("python_logs"), dict) else {}
        no_run_log_var = tk.BooleanVar(value=not bool(pcfg.get("run_enabled", True)))
        no_err_log_var = tk.BooleanVar(value=not bool(pcfg.get("error_enabled", True)))
        keep_log_var = tk.StringVar(value=str(pcfg.get("keep_count", 10)))
        tk.Checkbutton(win, text="실행 로그 미사용", variable=no_run_log_var, bg="#f3f4f6").grid(row=1, column=1, padx=210, pady=2, sticky="w")
        tk.Checkbutton(win, text="에러 로그 미사용", variable=no_err_log_var, bg="#f3f4f6").grid(row=1, column=1, padx=330, pady=2, sticky="w")
        tk.Label(win, text="로그", bg="#f3f4f6").grid(row=0, column=2, padx=4, pady=(10,4), sticky="e")
        tk.Entry(win, textvariable=keep_log_var, width=5).grid(row=1, column=2, padx=4, pady=4, sticky="w")

        prof_box = tk.LabelFrame(win, text="Chrome 프로필 설정", bg="#f3f4f6", padx=8, pady=8)
        prof_box.grid(row=2, column=0, columnspan=2, padx=10, pady=8, sticky="nsew")
        win.grid_rowconfigure(2, weight=1)
        win.grid_columnconfigure(1, weight=1)
        prof_box.grid_columnconfigure(0, weight=1)
        status_var = tk.StringVar(value="프로필 목록 준비중")
        cdp_dir_var = tk.StringVar(value=cdp_user_data_dir_from_cfg(self.cfg))

        cdp_line = tk.Frame(prof_box, bg="#f3f4f6")
        cdp_line.grid(row=0, column=0, columnspan=2, sticky="we", pady=(0,6))
        cdp_line.grid_columnconfigure(1, weight=1)
        tk.Label(cdp_line, text="CDP user-data-dir", bg="#f3f4f6").grid(row=0, column=0, sticky="w", padx=(0,6))
        cdp_entry = tk.Entry(cdp_line, textvariable=cdp_dir_var, width=48)
        cdp_entry.grid(row=0, column=1, sticky="we", padx=(0,6))

        def save_cdp_dir_from_entry() -> str:
            path = set_cdp_user_data_dir(self.cfg, cdp_dir_var.get())
            cdp_dir_var.set(path)
            save_config(self.cfg)
            return path

        def open_cdp_dir():
            path = save_cdp_dir_from_entry()
            if open_folder_path(path):
                status_var.set(f"CDP 폴더 열기: {path}")
            else:
                status_var.set(f"CDP 폴더 열기 실패: {path}")

        def launch_cdp_dir_from_line():
            path = save_cdp_dir_from_entry()
            status_var.set(f"CDP모드 실행 요청: {path}")
            threading.Thread(target=lambda: launch_chrome_cdp_user_data_dir(self.cfg, reason="settings_cdp_user_data_dir_line"), name="auto8700-settings-cdp-user-data-dir-line", daemon=True).start()

        tk.Button(cdp_line, text="열기", command=open_cdp_dir, bg="#4b5563", fg="white", bd=0, padx=10, pady=5).grid(row=0, column=2, sticky="e", padx=3)
        tk.Button(cdp_line, text="CDP모드 실행", command=launch_cdp_dir_from_line, bg="#059669", fg="white", bd=0, padx=10, pady=5).grid(row=0, column=3, sticky="e", padx=3)

        tk.Label(prof_box, textvariable=status_var, bg="#f3f4f6", anchor="w").grid(row=1, column=0, sticky="we", pady=(0,4))
        listbox = tk.Listbox(prof_box, height=12, width=92, exportselection=False)
        listbox.grid(row=2, column=0, sticky="nsew")
        prof_box.grid_rowconfigure(2, weight=1)
        scroll = tk.Scrollbar(prof_box, orient="vertical", command=listbox.yview)
        scroll.grid(row=2, column=1, sticky="ns")
        listbox.configure(yscrollcommand=scroll.set)
        profiles_holder = {"items": []}

        def current_saved_keys() -> set[tuple[str, str]]:
            icfg = chrome_cfg(self.cfg)
            return {profile_key(str(p.get("user_data_dir") or icfg.get("user_data_dir") or ""), str(p.get("profile_directory") or "Default")) for p in (icfg.get("profiles") or [])}

        def refresh_profiles():
            try:
                profiles = scan_chrome_user_data_profiles(self.cfg)
                profiles_holder["items"] = profiles
                listbox.delete(0, "end")
                saved = current_saved_keys()
                for item in profiles:
                    choice = str(item.get("choice_id") or profile_choice_id(item) or "?")
                    profile_dir = str(item.get("profile_directory") or "Default")
                    user_data = normalize_path_text(str(item.get("user_data_dir") or chrome_cfg(self.cfg).get("user_data_dir") or ""))
                    key = profile_key(user_data, profile_dir)
                    mark = "✓" if key in saved else " "
                    label = str(item.get("label") or profile_dir)
                    age = str(item.get("age_text") or "")
                    src = str(item.get("source") or "")
                    listbox.insert("end", f"{mark} [{choice}] {profile_dir} / {label} / 최근={age} / source={src}")
                icfg = chrome_cfg(self.cfg)
                if not profiles:
                    status_var.set("프로필 없음: Chrome User Data 경로를 확인하세요. 저장 전 Chrome 자동 실행은 금지됩니다.")
                elif bool(icfg.get("configured")):
                    status_var.set(f"설정됨: {len(icfg.get('profiles') or [])}개 저장 / CDP모드 {'OK' if chrome_debug_alive(self.cfg) else 'OFF'}")
                else:
                    status_var.set("프로필 미설정: 아래 목록에서 선택 → 테스트 실행 → 이 프로필 저장")
            except Exception as exc:
                status_var.set(f"프로필 스캔 오류: {exc}")

        def selected_profile() -> Optional[dict]:
            try:
                sel = listbox.curselection()
                if not sel:
                    if messagebox: messagebox.showwarning("선택 필요", "프로필을 먼저 선택하세요.", parent=win)
                    return None
                return profiles_holder["items"][int(sel[0])]
            except Exception:
                return None

        def test_selected():
            item = selected_profile()
            if not item:
                return
            launch_chrome_profile_preview(self.cfg, item, reason="settings_profile_preview")
            status_var.set("선택 프로필 테스트 실행 요청 완료. Chrome 계정이 맞으면 저장하세요.")

        def save_selected():
            item = selected_profile()
            if not item:
                return
            save_ig_chrome_profiles(self.cfg, [item])
            status_var.set("선택 프로필 저장 완료. 이제 CDP모드 실행/예약 실행이 허용됩니다.")
            refresh_profiles()

        def reset_profiles():
            ok = True
            if messagebox:
                ok = messagebox.askyesno("프로필 초기화", "저장된 Chrome 프로필 설정을 초기화할까요?\n초기화 후 Chrome 자동 실행/예약 실행은 금지됩니다.", parent=win)
            if not ok:
                return
            reset_ig_chrome_profiles(self.cfg)
            status_var.set("프로필 설정 초기화 완료")
            refresh_profiles()

        def launch_saved():
            path = save_cdp_dir_from_entry()
            if chrome_debug_alive(self.cfg):
                status_var.set(f"CDP모드 이미 OK / {path}")
                return
            threading.Thread(target=lambda: launch_chrome_cdp_user_data_dir(self.cfg, reason="settings_chrome_launch"), name="auto8700-settings-cdp-user-data-dir", daemon=True).start()
            status_var.set(f"CDP모드 실행 요청 완료 / {path}")

        btns = tk.Frame(prof_box, bg="#f3f4f6")
        btns.grid(row=3, column=0, columnspan=2, sticky="we", pady=(8,0))
        tk.Button(btns, text="프로필 새로고침", command=refresh_profiles, bg="#4b5563", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=4)
        tk.Button(btns, text="선택 프로필 테스트 실행", command=test_selected, bg="#0f766e", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=4)
        tk.Button(btns, text="이 프로필 저장", command=save_selected, bg="#2563eb", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=4)
        tk.Button(btns, text="프로필 설정 초기화", command=reset_profiles, bg="#991b1b", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=4)

        def apply():
            try:
                ui = self.ui_cfg()
                alpha = max(0.35, min(1.0, float(alpha_var.get()) / 100.0))
                ui["alpha"] = alpha
                ui["expanded_width"] = max(480, min(1400, int(str(width_var.get()).strip() or 860)))
                self.cfg["auto8700_ui"] = ui
                set_cdp_user_data_dir(self.cfg, cdp_dir_var.get())
                log_cfg = self.cfg.get("python_logs") if isinstance(self.cfg.get("python_logs"), dict) else {}
                log_cfg["enabled"] = True
                log_cfg["run_enabled"] = not bool(no_run_log_var.get())
                log_cfg["error_enabled"] = not bool(no_err_log_var.get())
                try:
                    log_cfg["keep_count"] = max(1, min(100, int(str(keep_log_var.get()).strip() or 10)))
                except Exception:
                    log_cfg["keep_count"] = 10
                log_cfg.setdefault("log_dir", "logs")
                log_cfg.setdefault("run_prefix", "auto_run_log")
                log_cfg.setdefault("err_prefix", "auto_err_log")
                log_cfg.pop("manager_unified_enabled", None)
                self.cfg["python_logs"] = log_cfg
                self.cfg["python_file_logs"] = {"enabled": True, "keep_files": log_cfg["keep_count"], "root_dir": "logs", "prefix": "auto8700"}
                self.root.attributes("-alpha", alpha)
                save_config(self.cfg)
                self.apply_layout(save=False)
                self.settings_win = None
                win.destroy()
            except Exception as exc:
                if messagebox: messagebox.showerror("설정 오류", str(exc), parent=win)

        bottom = tk.Frame(win, bg="#f3f4f6")
        bottom.grid(row=3, column=0, columnspan=2, sticky="e", padx=10, pady=10)
        tk.Button(bottom, text="저장", command=apply, bg="#2563eb", fg="white", bd=0, padx=14, pady=7).pack(side="left", padx=4)
        tk.Button(bottom, text="닫기", command=lambda: (setattr(self, "settings_win", None), win.destroy()), bg="#6b7280", fg="white", bd=0, padx=14, pady=7).pack(side="left", padx=4)
        refresh_profiles()
    def close_ui(self):
        # v163: X 버튼은 창 숨김이 아니라 Auto8700 관리자 전체 종료이다.
        try:
            ui = self.ui_cfg()
            ui["x"] = int(self.root.winfo_x())
            ui["y"] = int(self.root.winfo_y())
            ui["stage"] = int(self.stage)
            ui["enabled"] = True
            self.cfg["auto8700_ui"] = ui
            save_config(self.cfg)
        except Exception:
            pass
        request_manager_shutdown(self.cfg, reason="ui_x_close")
        try:
            self.root.destroy()
        except Exception:
            pass
    def tick(self):
        if AUTO8700_SHUTDOWN_EVENT.is_set():
            try:
                self.root.destroy()
            except Exception:
                pass
            return
        try:
            st = update_schedule_state(self.cfg)
            scfg = auto_schedule_cfg(self.cfg)
            cached = auto8700_cached_status_snapshot()
            self.schedule1_var.set(bool(scfg.get("schedule1_enabled")))
            self.schedule2_var.set(bool(scfg.get("schedule2_enabled")))
            if self.root.focus_get() not in (self.time1_entry, self.time2_entry):
                self.time1_var.set(str(scfg.get("schedule1_time") or "00:00"))
                self.time2_var.set(str(scfg.get("schedule2_time") or "00:00"))
            chrome_ok = bool(cached.get("chrome_ok"))
            ws_ok = bool(cached.get("ws_ok"))
            analyze_port_ok = bool(cached.get("analyze8780_port_ok"))
            analyze_api_ok = bool(cached.get("analyze8780_api_ok"))
            configured = bool(cached.get("configured"))
            age_sec = cached.get("age_sec")
            checking = age_sec is None
            stale = isinstance(age_sec, (int, float)) and age_sec > 12.0
            run_text = "실행중" if st.get("running") else str(st.get("last_result") or "대기")
            if checking:
                status_text = "CDP:확인중 WS:확인중 8780:확인중 " + ("프로필OK" if configured else "프로필필요")
            else:
                suffix = " 상태지연" if stale else ""
                analyze_text = "OK" if analyze_api_ok else ("PORT" if analyze_port_ok else "OFF")
                status_text = f"CDP:{'OK' if chrome_ok else 'OFF'} WS:{'OK' if ws_ok else 'OFF'} 8780:{analyze_text} {'프로필OK' if configured else '프로필필요'}{suffix}"
            status_text = status_text[:140]
            log_text = f"{run_text} | 남은시간 {st.get('remaining_text') or '예약 꺼짐'}"[:220]
            self.last_status_text = status_text + "\n" + log_text
            self.status_label.config(text=status_text)
            self.log_label.config(text=log_text)
            self.count_label.config(text=str(st.get("remaining_text") or "예약 꺼짐"))
            try:
                if chrome_ok:
                    self.chrome_btn.config(text="CDP OK", state="disabled", bg="#4b5563")
                elif configured:
                    self.chrome_btn.config(text="CDP모드", state="normal", bg="#059669")
                else:
                    self.chrome_btn.config(text="프로필필요", state="disabled", bg="#7f1d1d")
            except Exception:
                pass
        except Exception as exc:
            try:
                self.status_label.config(text=f"UI 오류: {exc}")
            except Exception:
                pass
        if not AUTO8700_SHUTDOWN_EVENT.is_set():
            self.root.after(1000, self.tick)
    def run(self):
        log("[UI] MAINLOOP_START", self.cfg)
        try:
            self.root.mainloop()
        finally:
            log("[UI] MAINLOOP_EXIT", self.cfg)


def run_auto8700_floating_ui_main_thread(cfg: dict) -> None:
    """Tk는 반드시 main thread에서 생성·실행한다. 실패 시 숨은 manager만 남기지 않는다."""
    if tk is None:
        raise RuntimeError("tkinter not available")
    if threading.current_thread() is not threading.main_thread():
        raise RuntimeError("Auto8700 UI must run on the main thread")
    if not bool(auto8700_ui_cfg(cfg).get("enabled", True)):
        raise RuntimeError("auto8700_ui.enabled=false")
    Auto8700FloatingUI(cfg).run()


def start_auto8700_floating_ui(cfg: dict) -> None:
    """호환 이름. v235에서도 daemon UI thread를 만들지 않고 main-thread 실행만 허용한다."""
    run_auto8700_floating_ui_main_thread(cfg)


class Auto8700EventHandler(BaseHTTPRequestHandler):
    server_version = "Auto8700Event/0.1"

    def _send(self, status: int, body: bytes, content_type: str = "application/json; charset=utf-8") -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError, OSError) as exc:
            try:
                log(f"[DEV8707] client_abort path={getattr(self, 'path', '')} error={exc}", getattr(self.server, 'auto8700_cfg', {}))
            except Exception:
                pass

    def do_OPTIONS(self):
        self._send(204, b"")

    def do_GET(self):
        cfg = getattr(self.server, "auto8700_cfg", {})
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/status":
            body = json.dumps(auto8700_status_payload(cfg), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/events":
            body = json.dumps({"events": recent_devtools_events(200)}, ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/api/cdp/status":
            body = json.dumps(cdp_status_payload(cfg), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/api/cdp/targets":
            body = json.dumps(cdp_targets_payload(cfg), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/api/cdp/ig/status":
            body = json.dumps(cdp_ig_status_payload(cfg), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/api/cdp/download-root":
            body = json.dumps(cdp_download_root_payload(cfg, ensure=False, reason="http_status"), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        if parsed.path == "/api/cdp/ig/schedule_storage":
            body = json.dumps(cdp_ig_schedule_storage_payload(cfg), ensure_ascii=False, indent=2).encode("utf-8")
            self._send(200, body)
            return
        body = auto8700_web_page().encode("utf-8")
        self._send(200, body, "text/html; charset=utf-8")

    def do_POST(self):
        cfg = getattr(self.server, "auto8700_cfg", {})
        parsed = urllib.parse.urlparse(self.path)
        try:
            length = int(self.headers.get("Content-Length") or 0)
            raw = self.rfile.read(length) if length else b"{}"
            payload = json.loads(raw.decode("utf-8", errors="ignore") or "{}")
            if not isinstance(payload, dict):
                payload = {}
            if parsed.path == "/api/cdp/evaluate":
                result = cdp_evaluate_payload(cfg, payload)
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path == "/api/cdp/bring_to_front":
                result = cdp_bring_to_front_payload(cfg, payload)
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path == "/api/cdp/ig/bring_to_front":
                result = cdp_ig_bring_to_front_payload(cfg, payload)
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path == "/api/cdp/ig/send_event":
                result = cdp_ig_send_test_event_payload(cfg, payload)
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path == "/api/cdp/ig/side_open":
                result = cdp_ig_side_open_payload(cfg, payload)
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path == "/api/cdp/download-root/ensure":
                result = cdp_download_root_payload(cfg, ensure=True, reason=str(payload.get("reason") or "ig_download"))
                self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
                return
            if parsed.path not in ("/event", "/settings", "/schedule", "/start"):
                self._send(404, b"{\"ok\":false,\"error\":\"not found\"}")
                return
            act = str(payload.get("act") or "")
            if parsed.path == "/start" or act in ("auto8700_schedule_start", "auto8700_run_now"):
                result = dispatch_ig_auto_start_request(cfg, reason=act or "http_start")
            elif parsed.path == "/schedule" or act in ("auto8700_schedule_save", "auto8700_schedule_heartbeat", "auto8700_schedule_ig_run_mark"):
                settings = payload.get("settings") if isinstance(payload.get("settings"), dict) else payload
                if act == "auto8700_schedule_save":
                    scfg = save_auto_schedule_settings(cfg, settings, source=str(settings.get("save_source") or "ig_side"))
                else:
                    # heartbeat/run-mark는 Auto8700의 예약1/2 값을 절대 덮어쓰지 않는다.
                    scfg = auto_schedule_cfg(cfg)
                    if settings.get("ig_side_heartbeat") or act == "auto8700_schedule_heartbeat":
                        scfg["ig_side_heartbeat_ts"] = int(time.time() * 1000)
                        scfg["ig_side_heartbeat_iso"] = datetime.now().isoformat(timespec="seconds")
                    if settings.get("mark_run_today") or act == "auto8700_schedule_ig_run_mark":
                        _today = datetime.now().strftime("%Y-%m-%d")
                        scfg["last_run_date"] = _today
                        try:
                            _slot = int(settings.get("mark_run_slot_id") or 0)
                        except Exception:
                            _slot = 0
                        if _slot in (1, 2):
                            scfg[f"last_run_date_{_slot}"] = _today
                        else:
                            _mark_time = normalize_schedule_time_text(str(settings.get("mark_run_time") or ""))
                            for _row in auto_schedule_slots(scfg, unique=False):
                                if _row.get("time") == _mark_time:
                                    scfg[f"last_run_date_{int(_row.get('id') or 1)}"] = _today
                                    break
                    cfg["auto_schedule"] = scfg
                    save_config(cfg)
                    update_schedule_state(cfg)
                result = {"ok": True, "auto_schedule": auto_schedule_cfg(cfg), "schedule_state": update_schedule_state(cfg), "cdp_ready": bool(AUTO8700_SCHEDULE_STATE.get("cdp_ready")), "cdp_error": str(AUTO8700_SCHEDULE_STATE.get("cdp_error") or "")}
            else:
                result = apply_devtools_settings_event(cfg, payload)
            self._send(200, json.dumps(result, ensure_ascii=False, indent=2).encode("utf-8"))
        except Exception as exc:
            self._send(500, json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False).encode("utf-8"))

    def log_message(self, fmt: str, *args) -> None:
        # HTTP access log는 manager 콘솔을 어지럽히지 않도록 숨긴다.
        return


def auto8700_status_payload(cfg: dict) -> dict:
    """HTTP status는 상태 worker의 snapshot만 읽는다. netstat/CDP/WMI를 요청 thread에서 실행하지 않는다."""
    ecfg = event_server_cfg(cfg)
    ncfg = devtools_network_cfg(cfg)
    cached = auto8700_cached_status_snapshot()
    return {
        "ok": True,
        "event_server": {"host": ecfg.get("host"), "port": ecfg.get("port"), "running": AUTO8700_SERVER_STATE.get("running")},
        "chrome_debug": {"port": chrome_debug_port(cfg), "alive": bool(cached.get("chrome_ok")), "configured": bool(cached.get("configured")), "profiles": int(cached.get("profiles") or 0), "cdp_mode": dict(cached.get("cdp_mode") or {})},
        "ws": {"port": 8771, "alive": bool(cached.get("ws_ok"))},
        "control_marker": dict(cached.get("control_marker") or {}),
        "status_cache": {"updated_at": cached.get("updated_iso") or "", "age_sec": cached.get("age_sec"), "refresh_ms": int(cached.get("refresh_ms") or 0), "last_error": str(cached.get("last_error") or ""), "running": bool(cached.get("running"))},
        "auto_schedule": auto_schedule_cfg(cfg),
        "schedule_state": update_schedule_state(cfg),
        "schedule_storage": {"key": str(auto_schedule_cfg(cfg).get("cdp_storage_key") or AUTO8700_SCHEDULE_STORAGE_KEY), "cdp_ready": bool(AUTO8700_SCHEDULE_STATE.get("cdp_ready")), "cdp_error": str(AUTO8700_SCHEDULE_STATE.get("cdp_error") or ""), "ig_side_active": bool(ig_side_schedule_active(cfg))},
        "network_watch": {**ncfg, "running": DEVTOOLS_WATCH_STATE.get("running"), "target_url": DEVTOOLS_WATCH_STATE.get("target_url"), "last_error": DEVTOOLS_WATCH_STATE.get("last_error")},
        "recent_events": recent_devtools_events(50),
    }


def auto8700_web_page() -> str:
    return """<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><title>Auto8700 CDP모드</title>
<style>body{font-family:Consolas,Malgun Gothic,monospace;background:#111;color:#eee;margin:16px}button{margin:3px;padding:7px 10px}pre{background:#1b1b1b;padding:10px;white-space:pre-wrap}.on{color:#80ff80}.off{color:#ff8080}</style></head>
<body><h2>Auto8700 8707 CDP모드 Monitor v228</h2>
<div>
<button onclick="setv(true,false)">Network 콘솔 ON</button>
<button onclick="setv(false,false)">Network OFF</button>
<button onclick="setv(true,true)">Network+파일로그 ON</button>
<button onclick="setVerbose()">Verbose 토글 ON</button>
<button onclick="runNow()">지금 자동시작</button>
<label><input type="checkbox" id="scheduleEnabled"> 자동예약</label>
<input id="scheduleTime" value="00:00" size="6">
<button onclick="saveSchedule()">예약 저장</button>
<span id="remain"></span>
</div>
<p>8700은 Chrome CDP모드 전용, 8707은 Auto8700 설정/상태용입니다.</p>
<pre id="status">loading...</pre>
<script>
async function post(obj){await fetch('/event',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({act:'auto8700_web_setting',settings:obj})}); await load();}
function setv(en,file){post({devtools_network_enabled:en,devtools_console:true,devtools_file_log:file});}
function setVerbose(){post({devtools_network_enabled:true,devtools_console:true,devtools_verbose:true});}
async function runNow(){await fetch('/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({act:'auto8700_run_now'})}); await load();}
async function saveSchedule(){await fetch('/schedule',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({act:'auto8700_schedule_save',settings:{enabled:document.getElementById('scheduleEnabled').checked,time:document.getElementById('scheduleTime').value}})}); await load();}
let loadBusy=false; let loadTimer=null;
function scheduleLoad(ms=1000){if(loadTimer){clearTimeout(loadTimer);} loadTimer=setTimeout(load,ms);}
async function load(){
  if(loadBusy){return;}
  loadBusy=true;
  if(loadTimer){clearTimeout(loadTimer);loadTimer=null;}
  try{
    let r=await fetch('/status',{cache:'no-store'});
    let j=await r.json();
    document.getElementById('status').textContent=JSON.stringify(j,null,2);
    try{document.getElementById('scheduleEnabled').checked=!!(j.auto_schedule&&j.auto_schedule.enabled);document.getElementById('scheduleTime').value=(j.auto_schedule&&j.auto_schedule.time)||'00:00';document.getElementById('remain').textContent=(j.schedule_state&&j.schedule_state.remaining_text)||'';}catch(e){}
  }catch(e){document.getElementById('status').textContent='status error: '+e;}
  finally{loadBusy=false;scheduleLoad(1000);}
}
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&!loadBusy){scheduleLoad(0);}});
load();
</script></body></html>"""


def start_auto8700_event_server(cfg: dict) -> None:
    ecfg = event_server_cfg(cfg)
    cfg["auto8700_event_server"] = ecfg
    if not bool(ecfg.get("enabled", True)):
        return
    if AUTO8700_SERVER_STATE.get("running"):
        return
    host = str(ecfg.get("host") or "127.0.0.1")
    port = int(ecfg.get("port") or 8707)
    try:
        httpd = ThreadingHTTPServer((host, port), Auto8700EventHandler)
        httpd.auto8700_cfg = cfg
        t = threading.Thread(target=httpd.serve_forever, daemon=True)
        t.start()
        AUTO8700_SERVER_STATE.update({"running": True, "server": httpd, "thread": t, "last_error": ""})
        log(f"[DEV8707] Auto8700 이벤트/웹 서버 시작: http://{host}:{port}/", cfg)
    except Exception as exc:
        AUTO8700_SERVER_STATE["last_error"] = str(exc)
        log(f"[DEV8707] 서버 시작 실패: {host}:{port} / {exc}", cfg)


def parse_cmd_arg(command_line: str, key: str) -> Optional[str]:
    # --key=value / --key "value" / --key=value with quotes 모두 최대한 처리한다.
    pattern = re.compile(rf"--{re.escape(key)}(?:=|\s+)(\"[^\"]+\"|'[^']+'|[^\s]+)", re.IGNORECASE)
    m = pattern.search(command_line or "")
    if not m:
        return None
    return m.group(1).strip().strip('"').strip("'")


def chrome_profile_from_cmd(command_line: str) -> str:
    profile = parse_cmd_arg(command_line, "profile-directory")
    if profile:
        return profile
    return "Default"


def chrome_user_data_from_cmd(command_line: str) -> str:
    user_data = parse_cmd_arg(command_line, "user-data-dir")
    if user_data:
        return normalize_path_text(user_data)
    return normalize_path_text("%LOCALAPPDATA%\\Google\\Chrome\\User Data")


def query_chrome_processes_windows() -> List[dict]:
    if not is_windows():
        return []
    ps = [
        "powershell",
        "-NoProfile",
        "-ExecutionPolicy", "Bypass",
        "-Command",
        "Get-CimInstance Win32_Process -Filter \"name='chrome.exe'\" | "
        "Select-Object ProcessId,CommandLine | ConvertTo-Json -Depth 3",
    ]
    try:
        proc = subprocess.run(ps, text=True, encoding="utf-8", errors="ignore", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False)
        out = proc.stdout.strip()
        if out:
            data = json.loads(out)
            if isinstance(data, dict):
                data = [data]
            if isinstance(data, list):
                return [x for x in data if isinstance(x, dict)]
    except Exception:
        pass
    try:
        proc = subprocess.run(["wmic", "process", "where", "name='chrome.exe'", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], text=True, encoding="utf-8", errors="ignore", stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False)
    except Exception:
        return []
    items: List[dict] = []
    current: dict = {}
    for line in proc.stdout.splitlines():
        line = line.strip()
        if not line:
            if current:
                items.append(current)
                current = {}
            continue
        if line.startswith("CommandLine="):
            current["CommandLine"] = line.split("=", 1)[1]
        elif line.startswith("ProcessId="):
            current["ProcessId"] = line.split("=", 1)[1]
    if current:
        items.append(current)
    return items



def chrome_user_data_dir(icfg: dict) -> Path:
    raw = str(icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data")
    return Path(normalize_path_text(raw))


def chrome_profile_dirs_from_local_state(user_data_dir: str) -> List[str]:
    data = read_json_file(chrome_local_state_path(user_data_dir)) or {}
    cache = (((data.get("profile") or {}).get("info_cache")) or {}) if isinstance(data, dict) else {}
    result: List[str] = []
    if isinstance(cache, dict):
        for key in cache.keys():
            name = str(key or "").strip()
            if name and name not in result:
                result.append(name)
    return result


def chrome_profile_dirs_from_folder(user_data_dir: str) -> List[str]:
    root = Path(normalize_path_text(user_data_dir))
    result: List[str] = []
    if not root.exists():
        return result
    try:
        for child in root.iterdir():
            if not child.is_dir():
                continue
            name = child.name
            low = name.lower()
            if name == "Default" or low.startswith("profile "):
                result.append(name)
    except Exception:
        return result
    return sorted(set(result), key=chrome_profile_sort_key)


def chrome_existing_profile_dirs(user_data_dir: str) -> List[str]:
    names: List[str] = []
    for name in chrome_profile_dirs_from_local_state(user_data_dir) + chrome_profile_dirs_from_folder(user_data_dir):
        if name and name not in names:
            names.append(name)
    return sorted(names, key=chrome_profile_sort_key)


def activity_probe_paths(icfg: dict) -> List[str]:
    raw = icfg.get("activity_probe_paths")
    if not isinstance(raw, list) or not raw:
        raw = [
            "Network\\Reporting and NEL-journal",
            "Network\\Network Persistent State",
            "Preferences",
            "History",
            "Cookies",
            "Sessions",
            "Session Storage",
            "Local Storage",
        ]
    out: List[str] = []
    for item in raw:
        text = str(item or "").strip().strip("/\\")
        if text:
            out.append(text)
    return out


def safe_stat_signature(path: Path) -> Optional[tuple[int, int]]:
    try:
        st = path.stat()
        return (int(st.st_mtime_ns), int(st.st_size))
    except Exception:
        return None


def probe_path_latest(profile_root: Path, rel_path: str) -> tuple[float, str, Optional[tuple[int, int]]]:
    """프로필 내부 감시 후보의 최근 mtime/source/signature를 반환한다. 디렉터리는 과도한 순회를 피한다."""
    parts = [part for part in re.split(r"[\\/]+", rel_path) if part]
    target = profile_root.joinpath(*parts) if parts else profile_root
    best_mtime = 0.0
    best_source = ""
    best_sig: Optional[tuple[int, int]] = None

    def consider(path: Path, source: str) -> None:
        nonlocal best_mtime, best_source, best_sig
        try:
            st = path.stat()
        except Exception:
            return
        mt = float(st.st_mtime)
        if mt >= best_mtime:
            best_mtime = mt
            best_source = source
            best_sig = (int(st.st_mtime_ns), int(getattr(st, "st_size", 0)))

    if target.is_file():
        consider(target, rel_path)
        return best_mtime, best_source, best_sig
    if target.is_dir():
        consider(target, rel_path)
        # 디렉터리 자체 mtime만으로 부족할 때가 있어 바로 아래 항목 일부만 확인한다.
        checked = 0
        stack = [target]
        while stack and checked < 160:
            current = stack.pop()
            try:
                entries = list(current.iterdir())
            except Exception:
                continue
            for child in entries:
                checked += 1
                if checked > 160:
                    break
                try:
                    rel = str(child.relative_to(profile_root))
                except Exception:
                    rel = rel_path
                consider(child, rel)
                if child.is_dir() and checked < 160:
                    stack.append(child)
        return best_mtime, best_source, best_sig
    return 0.0, "", None


def scan_chrome_user_data_profiles(cfg: dict, previous_signatures: Optional[dict[tuple[str, str], dict[str, tuple[int, int]]]] = None) -> List[dict]:
    """Chrome 프로필 선택용 스캔. 프로세스 command line이 아니라 User Data 폴더/파일 활동 기준이다."""
    icfg = chrome_cfg(cfg)
    user_data = normalize_path_text(str(icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data"))
    root = Path(user_data)
    labels = chrome_profile_labels(user_data)
    profiles: List[dict] = []
    now = time.time()
    try:
        active_window = float(icfg.get("active_profile_seconds") or 600)
    except Exception:
        active_window = 600.0
    probes = activity_probe_paths(icfg)
    previous_signatures = previous_signatures or {}

    for profile in chrome_existing_profile_dirs(user_data):
        profile_root = root / profile
        latest_mtime = 0.0
        latest_source = ""
        current_sig_by_source: dict[str, tuple[int, int]] = {}
        changed_sources: List[str] = []
        prev_sig_by_source = previous_signatures.get(profile_key(user_data, profile), {})

        for rel in probes:
            mt, source, sig = probe_path_latest(profile_root, rel)
            if source and sig is not None:
                current_sig_by_source[source] = sig
                if prev_sig_by_source and prev_sig_by_source.get(source) != sig:
                    changed_sources.append(source)
            if mt > latest_mtime:
                latest_mtime = mt
                latest_source = source

        age_sec: Optional[float] = None
        if latest_mtime > 0:
            age_sec = max(0.0, now - latest_mtime)

        recently_active = bool(age_sec is not None and age_sec <= active_window)
        changed = bool(changed_sources)
        if changed:
            status = "파일변경"
        elif recently_active:
            status = "최근활동"
        else:
            status = "존재만"

        profiles.append({
            "profile_directory": profile,
            "user_data_dir": user_data,
            "label": labels.get(profile, profile),
            "latest_mtime": latest_mtime,
            "latest_source": latest_source,
            "age_sec": age_sec,
            "status": status,
            "changed": changed,
            "recently_active": recently_active,
            "activity_signature": current_sig_by_source,
        })

    profiles.sort(key=lambda x: chrome_profile_sort_key(str(x.get("profile_directory") or "Default")))
    return assign_profile_choice_ids(profiles)


def running_chrome_profiles() -> List[dict]:
    """DevTools/실행 보조용 프로세스 조회. 현재 프로필 선택의 기준이 아니다."""
    seen: dict[tuple[str, str], dict] = {}
    for proc in query_chrome_processes_windows():
        cmd = str(proc.get("CommandLine") or "")
        if not cmd:
            continue
        if re.search(r"--type(?:=|\s+)", cmd, re.IGNORECASE):
            continue
        profile = chrome_profile_from_cmd(cmd)
        user_data = chrome_user_data_from_cmd(cmd)
        key = profile_key(user_data, profile)
        try:
            pid = int(proc.get("ProcessId") or 0)
        except Exception:
            pid = 0
        item = seen.setdefault(
            key,
            {
                "profile_directory": profile,
                "user_data_dir": normalize_path_text(user_data),
                "pids": [],
                "debug_port": parse_cmd_arg(cmd, "remote-debugging-port"),
            },
        )
        if pid > 0 and pid not in item["pids"]:
            item["pids"].append(pid)
        if not item.get("debug_port"):
            item["debug_port"] = parse_cmd_arg(cmd, "remote-debugging-port")
    return sorted(seen.values(), key=lambda x: (normalize_path_text(str(x.get("user_data_dir") or "")), str(x.get("profile_directory") or "")))


def chrome_local_state_path(user_data_dir: str) -> Path:
    return Path(normalize_path_text(user_data_dir)) / "Local State"


def chrome_profile_labels(user_data_dir: str) -> Dict[str, str]:
    path = chrome_local_state_path(user_data_dir)
    data = read_json_file(path) or {}
    cache = (((data.get("profile") or {}).get("info_cache")) or {}) if isinstance(data, dict) else {}
    labels: Dict[str, str] = {}
    if isinstance(cache, dict):
        for profile_dir, info in cache.items():
            if isinstance(info, dict):
                labels[str(profile_dir)] = str(info.get("name") or info.get("gaia_name") or profile_dir)
    return labels


def format_age(age_sec: Optional[float]) -> str:
    if age_sec is None:
        return "없음"
    sec = int(age_sec)
    if sec < 60:
        return f"{sec}초 전"
    minute = sec // 60
    if minute < 60:
        return f"{minute}분 전"
    return f"{minute // 60}시간 전"


def format_mtime(ts: float) -> str:
    if not ts:
        return "없음"
    try:
        return datetime.fromtimestamp(ts).strftime("%H:%M:%S")
    except Exception:
        return "없음"


def selectable_activity_profiles(profiles: List[dict]) -> List[dict]:
    selected = [p for p in profiles if bool(p.get("changed")) or bool(p.get("recently_active"))]
    return selected


def print_running_profiles(profiles: List[dict], cfg: dict) -> None:
    icfg = chrome_cfg(cfg)
    user_data = normalize_path_text(str(icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data"))
    log(f"[IG_CHROME] Chrome User Data 스캔: {user_data}", cfg)
    log("[IG_CHROME] 존재/활동 Chrome 프로필", cfg)
    if not profiles:
        log("  - 없음", cfg)
        return
    for item in profiles:
        choice = str(item.get("choice_id") or profile_choice_id(item) or "?")
        profile = str(item.get("profile_directory") or "Default")
        label = str(item.get("label") or profile)
        status = str(item.get("status") or "존재만")
        status_out = colored_status(status, cfg)
        mtime = format_mtime(float(item.get("latest_mtime") or 0.0))
        age = format_age(item.get("age_sec"))
        source = str(item.get("latest_source") or "-")
        active = bool(item.get("changed")) or bool(item.get("recently_active"))
        marker = ctext("*", status_color(status), cfg) if active else " "
        profile_out = ctext(profile, status_color(status), cfg)
        choice_out = ctext(choice, status_color(status), cfg)
        log(f"  [{choice_out}] {marker} {profile_out} / {label} / 상태={status_out} / 최근={mtime}({age}) / source={source}", cfg)
    log(
        "  "
        + colored_key("숫자", cfg)
        + "목록(예: 2,26)=실제 프로필 번호 기준 새창 열기 / "
        + colored_key("Y", cfg)
        + " 프로필번호목록(예: Y 2,26)=지정 적용 / "
        + colored_key("N", cfg)
        + "=취소",
        cfg,
    )


def profile_keys_from_list(profiles: List[dict]) -> set[tuple[str, str]]:
    return {profile_key(str(p.get("user_data_dir") or ""), str(p.get("profile_directory") or "Default")) for p in profiles}


def profile_title(item: dict) -> str:
    user_data = str(item.get("user_data_dir") or "")
    profile = str(item.get("profile_directory") or "Default")
    label = chrome_profile_labels(user_data).get(profile, profile)
    return f"{profile} / {label}"



def profile_signature_map(profiles: List[dict]) -> dict[tuple[str, str], dict[str, tuple[int, int]]]:
    out: dict[tuple[str, str], dict[str, tuple[int, int]]] = {}
    for item in profiles:
        key = profile_key(str(item.get("user_data_dir") or ""), str(item.get("profile_directory") or "Default"))
        sig = item.get("activity_signature") or {}
        if isinstance(sig, dict):
            out[key] = dict(sig)
    return out


def profile_choice_map(profiles: List[dict]) -> Dict[str, dict]:
    return {str(p.get("choice_id") or profile_choice_id(p)): p for p in profiles if str(p.get("choice_id") or profile_choice_id(p))}


def available_profile_choices(profiles: List[dict]) -> str:
    keys = list(profile_choice_map(profiles).keys())
    def _sort_key(k: str) -> tuple[int, float, str]:
        try:
            return (0, float(k), k)
        except Exception:
            return (1, 999999.0, k)
    keys.sort(key=_sort_key)
    return ",".join(keys)


def parse_profile_choice_list(text: str, profiles: List[dict]) -> tuple[List[str], List[str]]:
    """2,26 또는 2 26 형식의 실제 프로필 번호 목록을 파싱한다. 순서는 유지하고 중복은 제거한다."""
    raw = str(text or "").strip()
    if not raw:
        return [], []
    choices = profile_choice_map(profiles)
    tokens = [t for t in re.split(r"[\s,]+", raw) if t]
    selected: List[str] = []
    errors: List[str] = []
    seen: set[str] = set()
    for token in tokens:
        token = token.strip()
        if not token:
            continue
        if token not in choices:
            errors.append(token)
            continue
        if token not in seen:
            seen.add(token)
            selected.append(token)
    return selected, errors


def profiles_by_choices(profiles: List[dict], choices: List[str]) -> List[dict]:
    by_key = profile_choice_map(profiles)
    return [by_key[c] for c in choices if c in by_key]


def launch_chrome_profile_previews(cfg: dict, profiles: List[dict], choices: List[str], reason: str = "profile_number_preview") -> None:
    if not profiles:
        return
    selected = profiles_by_choices(profiles, choices)
    if not selected:
        return
    icfg = chrome_cfg(cfg)
    threaded = bool(icfg.get("preview_launch_in_threads", True))
    gap = float(icfg.get("preview_launch_gap_sec") or 0.0)

    def _launch(item: dict, choice: str) -> None:
        launch_chrome_profile_preview(cfg, item, reason=f"{reason}:{choice}")

    if threaded and len(selected) > 1:
        threads: List[threading.Thread] = []
        for choice, item in zip(choices, selected):
            t = threading.Thread(target=_launch, args=(item, choice), daemon=True)
            t.start()
            threads.append(t)
            if gap > 0:
                time.sleep(gap)
        log(f"[IG_CHROME] 선택 프로필 {len(selected)}개를 각각 새 창 실행 요청했습니다. 계정 확인 후 Y 프로필번호목록으로 적용하세요.", cfg)
        return

    for choice, item in zip(choices, selected):
        _launch(item, choice)
        if gap > 0:
            time.sleep(gap)
    log(f"[IG_CHROME] 선택 프로필 {len(selected)}개를 열었습니다. 계정 확인 후 Y 프로필번호목록으로 적용하세요.", cfg)


def wait_and_select_running_profiles(cfg: dict) -> List[dict]:
    previous_profiles: List[dict] = []
    previous_by_key: dict[tuple[str, str], dict] = {}
    previous_signatures: dict[tuple[str, str], dict[str, tuple[int, int]]] = {}
    first = True
    while True:
        profiles = scan_chrome_user_data_profiles(cfg, previous_signatures=previous_signatures)
        current_by_key = {profile_key(str(p.get("user_data_dir") or ""), str(p.get("profile_directory") or "Default")): p for p in profiles}
        current_keys = set(current_by_key)
        previous_keys = set(previous_by_key)

        if first:
            print_running_profiles(profiles, cfg)
            first = False
        else:
            closed_or_missing = previous_keys - current_keys
            opened = current_keys - previous_keys
            if closed_or_missing:
                for key in sorted(closed_or_missing):
                    old = previous_by_key.get(key) or {}
                    log(f"[IG_CHROME] 폴더 사라짐: {profile_title(old)}", cfg)
            if opened:
                for key in sorted(opened):
                    new = current_by_key.get(key) or {}
                    log(f"[IG_CHROME] 새 프로필 폴더 감지: {profile_title(new)}", cfg)
            became_inactive = []
            for key in sorted(previous_keys & current_keys):
                old = previous_by_key.get(key) or {}
                new = current_by_key.get(key) or {}
                old_active = bool(old.get("changed")) or bool(old.get("recently_active"))
                new_active = bool(new.get("changed")) or bool(new.get("recently_active"))
                if old_active and not new_active:
                    became_inactive.append(new)
            for item in became_inactive:
                log(f"[IG_CHROME] 닫힘 또는 비활성 추정: {profile_title(item)}", cfg)
            log("[IG_CHROME] 다시 조회 결과", cfg)
            print_running_profiles(profiles, cfg)

        previous_profiles = profiles
        previous_by_key = current_by_key
        previous_signatures = profile_signature_map(profiles)

        if profiles:
            answer_raw = input(ig_profile_input_prompt(cfg)).strip()
            answer = answer_raw.lower()
            if not answer_raw:
                continue
            if answer == "n":
                return []
            if answer in ("a", "o"):
                log("[IG_CHROME] A/O 명령은 사용하지 않습니다. 열기=프로필번호목록(예: 2,26), 적용=Y 프로필번호목록(예: Y 2,26) 입니다.", cfg)
                continue
            if answer == "y":
                log("[IG_CHROME] Y에는 적용할 실제 프로필 번호가 필요합니다. 예: Y 2 또는 Y 2,26", cfg)
                continue
            if answer.startswith("y"):
                rest = answer_raw[1:].strip()
                indexes, errors = parse_profile_choice_list(rest, profiles)
                if errors:
                    log(f"[IG_CHROME] 적용 번호 오류: {','.join(errors)} / 사용 가능={available_profile_choices(profiles)}", cfg)
                    continue
                if not indexes:
                    log("[IG_CHROME] 적용할 프로필 번호가 없습니다. 예: Y 2 또는 Y 2,26", cfg)
                    continue
                selected = profiles_by_choices(profiles, indexes)
                log(f"[IG_CHROME] 지정 적용: 프로필번호={','.join(map(str, indexes))} / profiles={len(selected)}", cfg)
                return selected
            indexes, errors = parse_profile_choice_list(answer_raw, profiles)
            if indexes and not errors:
                launch_chrome_profile_previews(cfg, profiles, indexes, reason="select_number")
                continue
            if errors:
                log(f"[IG_CHROME] 번호 오류: {','.join(errors)} / 사용 가능={available_profile_choices(profiles)}", cfg)
            else:
                log(f"[IG_CHROME] 알 수 없는 입력: {answer_raw} / Enter, 프로필번호목록, Y 프로필번호목록, N 중 하나를 입력하세요.", cfg)
            continue

        answer = input(ig_empty_profile_prompt(cfg)).strip().lower()
        if answer == "n":
            return []
        continue

def save_ig_chrome_profiles(cfg: dict, profiles: List[dict]) -> None:
    if not profiles:
        return
    icfg = normalize_ig_chrome_cfg(cfg.get("ig_chrome") or {})
    port = int(icfg.get("remote_debugging_port") or 8700)
    saved: List[dict] = []
    seen_saved: set[tuple[str, str]] = set()
    for item in profiles:
        profile = str(item.get("profile_directory") or "Default")
        user_data = normalize_path_text(str(item.get("user_data_dir") or icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data"))
        key = profile_key(user_data, profile)
        if key in seen_saved:
            continue
        seen_saved.add(key)
        saved.append({"profile_directory": profile, "user_data_dir": user_data, "active": len(saved) == 0})
    icfg["configured"] = True
    icfg["remote_debugging_port"] = port
    icfg["profiles"] = saved
    icfg["user_data_dir"] = str(saved[0].get("user_data_dir") or icfg.get("user_data_dir"))
    cfg["ig_chrome"] = normalize_ig_chrome_cfg(icfg)
    save_config(cfg)
    path = local_config_path()
    if len(saved) > 1:
        log(f"[IG_CHROME] local 설정 저장: {path} / port={port} / profiles={len(saved)} / 8700 CDP모드 대상=첫 번째({saved[0].get('profile_directory')})", cfg)
    else:
        log(f"[IG_CHROME] local 설정 저장: {path} / port={port} / profile={saved[0].get('profile_directory')}", cfg)

def chrome_exe_path(icfg: dict) -> Path:
    raw = str(icfg.get("chrome_exe") or "%PROGRAMFILES%\\Google\\Chrome\\Application\\chrome.exe")
    candidates = [expand_path_vars(raw)]
    candidates.append(expand_path_vars("%PROGRAMFILES%\\Google\\Chrome\\Application\\chrome.exe"))
    candidates.append(expand_path_vars("%PROGRAMFILES(X86)%\\Google\\Chrome\\Application\\chrome.exe"))
    candidates.append(expand_path_vars("%LOCALAPPDATA%\\Google\\Chrome\\Application\\chrome.exe"))
    for c in candidates:
        if c.exists():
            return c
    return candidates[0]


def cdp_user_data_dir_from_cfg(cfg: dict) -> str:
    icfg = chrome_cfg(cfg)
    value = normalize_path_text(str(icfg.get("cdp_user_data_dir") or DEFAULT_CDP_USER_DATA_DIR))
    return value or DEFAULT_CDP_USER_DATA_DIR


def set_cdp_user_data_dir(cfg: dict, value: str) -> str:
    icfg = chrome_cfg(cfg)
    path = normalize_path_text(str(value or DEFAULT_CDP_USER_DATA_DIR)) or DEFAULT_CDP_USER_DATA_DIR
    icfg["cdp_user_data_dir"] = path
    icfg["cdp_profile_directory"] = str(icfg.get("cdp_profile_directory") or DEFAULT_CDP_PROFILE_DIRECTORY).strip() or DEFAULT_CDP_PROFILE_DIRECTORY
    cfg["ig_chrome"] = normalize_ig_chrome_cfg(icfg)
    return path


def open_folder_path(path_text: str) -> bool:
    path = expand_path_vars(path_text or DEFAULT_CDP_USER_DATA_DIR)
    try:
        path.mkdir(parents=True, exist_ok=True)
        if is_windows():
            os.startfile(str(path))  # type: ignore[attr-defined]
        else:
            subprocess.Popen(["xdg-open", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        return True
    except Exception:
        return False


def launch_chrome_profile_preview(cfg: dict, profile: dict, reason: str = "profile_number_preview") -> bool:
    """프로필 선택 화면에서 번호 입력 시 해당 Chrome 프로필을 일반 모드로 열어 계정을 확인한다."""
    icfg = chrome_cfg(cfg)
    exe = chrome_exe_path(icfg)
    if not exe.exists():
        log(f"[IG_CHROME] chrome.exe 없음: {exe}", cfg)
        return False
    startup_url = str(icfg.get("startup_url") or "https://www.instagram.com/explore/")
    profile_dir = str(profile.get("profile_directory") or "Default")
    user_data = normalize_path_text(str(profile.get("user_data_dir") or icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data"))
    label = str(profile.get("label") or chrome_profile_labels(user_data).get(profile_dir, profile_dir))
    cmd = [
        str(exe),
        f"--profile-directory={profile_dir}",
        "--new-window",
        startup_url,
    ]
    # 기본 Chrome User Data 충돌을 피하기 위해 평소에는 user_data_dir을 강제하지 않는다.
    # 별도 User Data를 쓰는 환경이면 JSON에서 force_user_data_dir=true로 켠다.
    if bool(icfg.get("force_user_data_dir", False)):
        cmd.insert(2, f"--user-data-dir={str(expand_path_vars(user_data))}")
    log(f"[IG_CHROME] 선택 프로필 열기: profile={profile_dir} / {label} / url={startup_url} / reason={reason}", cfg)
    try:
        if is_windows():
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0)))
        else:
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        return True
    except Exception as exc:
        log(f"[IG_CHROME] 선택 프로필 실행 실패: {exc}", cfg)
        return False


def launch_chrome_cdp_user_data_dir(cfg: dict, reason: str = "manual_cdp_user_data_dir") -> bool:
    """CDP모드 전용 버튼/예약 실행은 별도 --user-data-dir로 Chrome을 실행한다."""
    icfg = chrome_cfg(cfg)
    cfg["ig_chrome"] = icfg
    exe = chrome_exe_path(icfg)
    if not exe.exists():
        log(f"[IG_CHROME] chrome.exe 없음: {exe}", cfg)
        return False
    port = int(icfg.get("remote_debugging_port") or 8700)
    if chrome_debug_alive(cfg):
        log(f"[IG_CHROME] CDP모드 이미 실행 중: 127.0.0.1:{port}", cfg)
        ensure_instagram_tab(cfg)
        return True
    startup_url = str(icfg.get("startup_url") or "https://www.instagram.com/explore/")
    cdp_user_data = cdp_user_data_dir_from_cfg(cfg)
    cdp_profile = str(icfg.get("cdp_profile_directory") or DEFAULT_CDP_PROFILE_DIRECTORY).strip() or DEFAULT_CDP_PROFILE_DIRECTORY
    try:
        expand_path_vars(cdp_user_data).mkdir(parents=True, exist_ok=True)
    except Exception as exc:
        log(f"[IG_CHROME] CDP user-data-dir 생성 실패: {cdp_user_data} / {exc}", cfg)
        return False
    cmd = [
        str(exe),
        f"--remote-debugging-port={port}",
        f"--user-data-dir={str(expand_path_vars(cdp_user_data))}",
        f"--profile-directory={cdp_profile}",
        "--no-first-run",
        "--new-window",
        startup_url,
    ]
    log(f"[IG_CHROME] CDP모드 Chrome 실행: cdp_user_data_dir={cdp_user_data} profile={cdp_profile} port={port} reason={reason}", cfg)
    log(f"[CDP_MODE] launch command: {' '.join(map(str, cmd))}", cfg)
    try:
        if is_windows():
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0)))
        else:
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
    except Exception as exc:
        log(f"[IG_CHROME] CDP모드 Chrome 실행 실패: {exc}", cfg)
        return False
    for _ in range(40):
        if chrome_debug_alive(cfg):
            ensure_instagram_tab(cfg)
            return True
        time.sleep(0.5)
    AUTO8700_SCHEDULE_STATE["cdp_error"] = "cdp_port_not_open_after_launch"
    log(f"[IG_CHROME] CDP user-data-dir 실행 후에도 127.0.0.1:{port} CDP 포트 확인 실패", cfg)
    return False


def launch_chrome_debug_profile(cfg: dict, profile: dict, reason: str) -> bool:
    icfg = chrome_cfg(cfg)
    exe = chrome_exe_path(icfg)
    if not exe.exists():
        log(f"[IG_CHROME] chrome.exe 없음: {exe}", cfg)
        return False
    port = int(icfg.get("remote_debugging_port") or 8700)
    startup_url = str(icfg.get("startup_url") or "https://www.instagram.com/explore/")
    profile_dir = str(profile.get("profile_directory") or "Default")
    user_data = normalize_path_text(str(profile.get("user_data_dir") or icfg.get("user_data_dir") or "%LOCALAPPDATA%\\Google\\Chrome\\User Data"))
    cmd = [
        str(exe),
        f"--remote-debugging-port={port}",
        f"--profile-directory={profile_dir}",
        "--new-window",
        startup_url,
    ]
    # user_data_dir은 기존 기본 Chrome User Data 충돌 위험 때문에 JSON에서 force_user_data_dir=true일 때만 명령에 넣는다.
    if bool(icfg.get("force_user_data_dir", False)):
        cmd.insert(2, f"--user-data-dir={str(expand_path_vars(user_data))}")
    log(f"[IG_CHROME] CDP모드 Chrome 실행: profile={profile_dir} port={port} reason={reason}", cfg)
    log(f"[CDP_MODE] launch command: {' '.join(map(str, cmd))}", cfg)
    try:
        if is_windows():
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP | getattr(subprocess, "DETACHED_PROCESS", 0)))
        else:
            proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        return True
    except Exception as exc:
        log(f"[IG_CHROME] Chrome 실행 실패: {exc}", cfg)
        return False



def ig_chrome_configured(cfg: dict) -> bool:
    icfg = chrome_cfg(cfg)
    return bool(icfg.get("enabled", True)) and bool(icfg.get("configured")) and bool(icfg.get("profiles"))


def reset_ig_chrome_profiles(cfg: dict) -> None:
    icfg = normalize_ig_chrome_cfg(cfg.get("ig_chrome") or {})
    icfg["configured"] = False
    icfg["profiles"] = []
    cfg["ig_chrome"] = normalize_ig_chrome_cfg(icfg)
    save_config(cfg)
    log("[IG_CHROME] 프로필 설정 초기화: configured=false / Chrome 자동 실행 금지", cfg)


def launch_saved_chrome_debug(cfg: dict, reason: str = "manual") -> bool:
    """기존 함수명은 유지하지만, CDP모드 실행은 전용 cdp_user_data_dir을 사용한다."""
    icfg = chrome_cfg(cfg)
    cfg["ig_chrome"] = icfg
    if not bool(icfg.get("enabled", True)):
        log("[IG_CHROME] disabled → Chrome 실행 안 함", cfg)
        return False
    return launch_chrome_cdp_user_data_dir(cfg, reason=reason)

def ensure_ig_chrome(cfg: dict) -> None:
    icfg = chrome_cfg(cfg)
    cfg["ig_chrome"] = icfg
    if not bool(icfg.get("enabled", True)):
        log("[IG_CHROME] disabled", cfg)
        return
    if not bool(icfg.get("launch_on_manager_start", True)):
        log("[IG_CHROME] launch_on_manager_start=false → manager 시작 시 Chrome 자동 실행 안 함", cfg)
        return
    if not launch_saved_chrome_debug(cfg, reason="ensure_ig_chrome"):
        if not bool(icfg.get("relaunch_if_closed", False)):
            log("[IG_CHROME] relaunch_if_closed=false → CDP모드 실패를 이유로 추가 자동 재실행하지 않습니다.", cfg)

def is_windows() -> bool:
    return os.name == "nt"


def tcp_connect_ok(host: str, port: int, timeout: float = 0.6) -> bool:
    try:
        with socket.create_connection((host, int(port)), timeout=timeout):
            return True
    except OSError:
        return False


def netstat_port_listening_windows(port: int) -> bool:
    """Windows에서 실제 접속을 만들지 않고 포트 LISTENING 여부만 확인한다."""
    if not is_windows():
        return False
    try:
        proc = subprocess.run(
            ["netstat", "-ano"],
            text=True,
            encoding="utf-8",
            errors="ignore",
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            timeout=8,
            check=False,
        )
    except Exception:
        return False
    # TCP    127.0.0.1:8771   0.0.0.0:0   LISTENING   1234
    # TCP    0.0.0.0:8791     0.0.0.0:0   LISTENING   1234
    tcp_pattern = re.compile(rf"^\s*TCP\s+\S+:{int(port)}\s+\S+\s+LISTENING\s+\d+\s*$", re.IGNORECASE)
    # UDP는 LISTENING 상태 문자열이 없으므로 포트를 점유하면 열려 있다고 본다.
    udp_pattern = re.compile(rf"^\s*UDP\s+\S+:{int(port)}\s+\S+\s+\d+\s*$", re.IGNORECASE)
    for line in proc.stdout.splitlines():
        if tcp_pattern.search(line) or udp_pattern.search(line):
            return True
    return False


def _health_mode_for_port(service: dict, port: int) -> str:
    modes = service.get("health_modes") or service.get("health_mode_by_port") or {}
    if isinstance(modes, dict):
        value = modes.get(str(port), modes.get(int(port)))
        if value:
            return str(value).strip().lower()
    return str(service.get("health_mode") or "listen_only").strip().lower()


def health_ok(service: dict) -> bool:
    ports = [int(p) for p in service.get("health_ports") or service.get("ports") or []]
    if not ports:
        return True
    host = str(service.get("health_host") or "127.0.0.1")
    timeout = float(service.get("health_timeout_sec", 0.6))
    for port in ports:
        mode = _health_mode_for_port(service, port)
        if mode in ("listen", "listen_only", "netstat", "port_listen"):
            if is_windows():
                if not netstat_port_listening_windows(port):
                    return False
            else:
                # 비 Windows에서는 netstat 파서 대신 기존 connect 검사로 후퇴한다.
                if not tcp_connect_ok(host, port, timeout=timeout):
                    return False
        elif mode in ("tcp", "tcp_connect", "connect"):
            if not tcp_connect_ok(host, port, timeout=timeout):
                return False
        elif mode in ("http", "http_json", "json"):
            path = str(service.get("health_path") or "/api/health")
            if not path.startswith("/"):
                path = "/" + path
            data = http_json_get(f"http://{host}:{port}{path}", timeout=max(timeout, 1.0))
            if data is None:
                return False
            expect_key = str(service.get("health_expect_key") or "").strip()
            if expect_key:
                if not isinstance(data, dict):
                    return False
                expected = service.get("health_expect_value", True)
                if data.get(expect_key) != expected:
                    return False
        else:
            # 모르는 mode는 안전하게 listen_only로 처리한다.
            if is_windows():
                if not netstat_port_listening_windows(port):
                    return False
            elif not tcp_connect_ok(host, port, timeout=timeout):
                return False
    return True


def netstat_port_pids_windows(port: int) -> List[int]:
    if not is_windows():
        return []
    try:
        proc = subprocess.run(
            ["netstat", "-ano"],
            text=True,
            encoding="utf-8",
            errors="ignore",
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            timeout=8,
            check=False,
        )
    except Exception:
        return []
    pids: set[int] = set()
    # 예: TCP    127.0.0.1:8771   0.0.0.0:0   LISTENING   1234
    # UDP    0.0.0.0:8772   *:*   1234
    pattern = re.compile(rf"(?:TCP|UDP)\s+\S+:{int(port)}\s+.*?\s+(\d+)\s*$", re.IGNORECASE)
    for line in proc.stdout.splitlines():
        m = pattern.search(line.strip())
        if not m:
            continue
        try:
            pid = int(m.group(1))
        except ValueError:
            continue
        if pid > 0:
            pids.add(pid)
    return sorted(pids)


def kill_pid_windows(pid: int, name: str, cfg: dict) -> None:
    try:
        subprocess.run(
            ["taskkill", "/PID", str(pid), "/T", "/F"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=12,
            check=False,
        )
        log(f"[{name}] 포트 점유 PID 종료: {pid}", cfg)
    except Exception as exc:
        log(f"[{name}] PID 종료 실패 pid={pid}: {exc}", cfg)


def kill_ports(service: dict, cfg: dict, reason: str) -> None:
    ports = [int(p) for p in service.get("ports") or []]
    name = str(service.get("name") or "service")
    if not ports:
        return
    if not is_windows():
        log(f"[{name}] 포트 PID 종료는 Windows netstat/taskkill 기준입니다. 현재 OS에서는 건너뜀: {reason}", cfg)
        return
    current_pid = os.getpid()
    seen: set[int] = set()
    for port in ports:
        pids = netstat_port_pids_windows(port)
        if not pids:
            log(f"[{name}] 포트 비어 있음: {port}", cfg)
        for pid in pids:
            if pid == current_pid or pid in seen:
                continue
            seen.add(pid)
            log(f"[{name}] 포트 {port} 점유 PID 발견: {pid} / reason={reason}", cfg)
            kill_pid_windows(pid, name, cfg)
    if seen:
        time.sleep(float(cfg.get("after_kill_wait_sec", 0.8)))


def sanitize_bat_text(bat_text: str) -> str:
    lines: List[str] = []
    for raw in str(bat_text).replace("\r\n", "\n").replace("\r", "\n").split("\n"):
        line = raw.rstrip()
        stripped = line.strip().lower()
        if not stripped:
            continue
        if stripped == "pause" or stripped.startswith("pause "):
            continue
        # auto manager 안에서 중첩된 새 cmd 창은 피한다. 필요한 경우 JSON에서 command_mode를 custom으로 조정한다.
        lines.append(line)
    if not lines:
        raise ValueError("bat_text에 실행할 줄이 없습니다. pause만 있거나 비어 있습니다.")
    return "\r\n".join(lines) + "\r\n"


def runtime_bat_path(service_name: str) -> Path:
    safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", service_name)
    return runtime_dir() / f"run_{safe}.cmd"


def cmd_title_escape(value: str) -> str:
    # cmd title 명령에서 문제가 되는 제어/구분 문자를 제거한다.
    text = str(value or "").strip()
    text = re.sub(r"[\r\n\t]+", " ", text)
    text = text.replace('"', "'").replace("&", "_").replace("|", "_").replace("<", "_").replace(">", "_")
    return text[:80] or "Auto8700"


def service_console_title(service: dict, work_dir: Path) -> str:
    # 간단 기준: 별도 지정이 없으면 실행 폴더명으로 콘솔 제목을 정한다.
    explicit = str(service.get("console_title") or "").strip()
    if explicit:
        return cmd_title_escape(explicit)
    try:
        folder = work_dir.name or str(service.get("name") or "Auto8700")
    except Exception:
        folder = str(service.get("name") or "Auto8700")
    return cmd_title_escape(folder)


def start_service(service: dict, cfg: dict, reason: str) -> subprocess.Popen:
    name = str(service.get("name") or "service")
    if bool(service.get("kill_ports_on_start", True)):
        kill_ports(service, cfg, reason=f"start:{reason}")
    bat_text = service.get("bat_text") or (cfg.get("bat_catalog") or {}).get(name)
    if not bat_text:
        raise RuntimeError(f"[{name}] bat_text가 없습니다. JSON에 bat_text를 넣어야 합니다.")
    work_dir = Path(str(service.get("work_dir") or script_dir()))
    if not work_dir.exists():
        raise FileNotFoundError(f"[{name}] work_dir 없음: {work_dir}")
    clean_bat = sanitize_bat_text(str(bat_text))
    run_path = runtime_bat_path(name)
    target = str(service.get("target") or "self").strip().lower()
    if target not in ("self", "_blank"):
        target = "self"
    title = service_console_title(service, work_dir)
    if is_windows() and target == "_blank":
        # 새 콘솔 창만 폴더명 기준 title을 지정한다. target=self는 manager 콘솔 제목을 바꿀 수 있어 건드리지 않는다.
        clean_bat = f"title {title}\r\n" + clean_bat
    run_path.write_text(clean_bat, encoding="utf-8")
    log(f"[{name}] 실행: {run_path} / target={target} / show_console={target == '_blank'} / title={title if target == '_blank' else 'inherit'} / reason={reason}", cfg)
    log(f"[{name}] 작업폴더: {work_dir}", cfg)
    if is_windows():
        creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
        if target == "_blank":
            creationflags |= subprocess.CREATE_NEW_CONSOLE
        # 기존 구조 유지: py312 명령을 담은 런타임 cmd를 새 프로세스로 실행한다.
        return subprocess.Popen(["cmd.exe", "/c", str(run_path)], cwd=str(work_dir), creationflags=creationflags, env=auto8700_python_log_child_env())
    # 비 Windows 검사용: .cmd를 sh로 실행하지 않는다. 대신 줄을 shell로 넘긴다.
    shell_text = " && ".join(line for line in clean_bat.splitlines() if not line.strip().lower().startswith("@echo"))
    return subprocess.Popen(shell_text, cwd=str(work_dir), shell=True, preexec_fn=os.setsid, env=auto8700_python_log_child_env())


def stop_process(proc: Optional[subprocess.Popen], name: str, cfg: dict) -> None:
    if proc is None or proc.poll() is not None:
        return
    log(f"[{name}] 프로세스 종료: PID={proc.pid}", cfg)
    if is_windows():
        try:
            subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12, check=False)
        except Exception as exc:
            log(f"[{name}] taskkill 실패: {exc}", cfg)
        return
    try:
        os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
        proc.wait(timeout=5)
    except subprocess.TimeoutExpired:
        os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
    except Exception as exc:
        log(f"[{name}] 종료 실패: {exc}", cfg)


def stop_all_managed_services(services_by_name: Dict[str, dict], managed: Dict[str, ManagedProc], cfg: dict) -> None:
    """Auto8700 총괄 종료: WS/RTC는 닫고 Chrome/CDP/Analyze8780은 유지한다."""
    stopped_names: List[str] = []
    for name, service in services_by_name.items():
        upper_name = str(name or "").upper()
        service_ports = {int(x) for x in (service.get("ports") or []) if str(x).isdigit()}
        if upper_name == "ANALYZE8780" or 8780 in service_ports:
            log(f"[EXIT] 종료 제외: {name} / 상시 분석 서비스 유지", cfg)
            continue
        if upper_name == "AUTO_SERVICE8700_MANAGER" or "CHROME" in upper_name or 8700 in service_ports:
            log(f"[EXIT] 종료 제외: {name} / Chrome·CDP 또는 manager", cfg)
            continue
        mp = managed.get(name)
        stop_process(mp.process if mp else None, name, cfg)
        if mp is not None:
            mp.process = None
        # 추적 Popen이 이미 끝났거나 분리된 child가 남은 경우에도 서비스 포트는 확실히 닫는다.
        if service.get("ports") or service.get("health_ports"):
            kill_ports(service, cfg, reason="manager_exit")
        stopped_names.append(str(name))
    log(f"[EXIT] 관리 서비스 전체 종료 완료: {', '.join(stopped_names) or '-'}", cfg)


def restart_service(name: str, services_by_name: dict, managed: Dict[str, ManagedProc], cfg: dict, reason: str) -> None:
    service = services_by_name.get(name)
    if not service:
        log(f"[{name}] 재시작 대상 없음", cfg)
        return
    if str(name or "").upper() == "ANALYZE8780":
        reset_analyze8780_status_cache()
    mp = managed.setdefault(name, ManagedProc(name=name))
    now = time.time()
    cooldown = float(service.get("restart_cooldown_sec", cfg.get("restart_cooldown_sec", 2.0)))
    if mp.restarting or (now - mp.last_start_ts < cooldown and mp.process is not None and mp.process.poll() is None):
        log(f"[{name}] 재시작 쿨다운으로 건너뜀: reason={reason}", cfg)
        return
    mp.restarting = True
    strategy = str(service.get("restart_strategy") or "kill_then_start").strip().lower()
    try:
        if strategy == "start_then_self_takeover":
            # 자기 manager 교체용: 기존 프로세스를 먼저 죽이지 않는다.
            # 새 manager가 먼저 실행된 뒤 lock single_instance_takeover로 이전 manager PID만 종료한다.
            log(f"[{name}] restart_strategy=start_then_self_takeover: 선실행 후 lock 인계", cfg)
            proc = start_service(service, cfg, reason=reason)
        else:
            log(f"[{name}] restart_strategy=kill_then_start: 기존 종료 후 실행", cfg)
            stop_process(mp.process, name, cfg)
            if bool(service.get("kill_ports_on_restart", True)):
                kill_ports(service, cfg, reason=f"restart:{reason}")
            proc = start_service(service, cfg, reason=reason)
        mp.process = proc
        mp.last_start_ts = time.time()
        mp.last_restart_reason = reason
        wait_for_health(service, cfg, name)
        if health_ok(service):
            mp.last_health_ok_ts = time.time()
            log(f"[{name}] health OK", cfg)
            if str(reason).startswith(("process_exit:", "port_health_down")):
                log(f"[자동복구][완료] service={name} pid={int(getattr(proc, 'pid', 0) or 0)} reason={reason}", cfg)
        else:
            log(f"[{name}] health 미확인: 포트가 아직 열리지 않았습니다", cfg)
            if str(reason).startswith(("process_exit:", "port_health_down")):
                log(f"[자동복구][실패] service={name} reason={reason} error=health_not_ready", cfg)
    except Exception as exc:
        log(f"[{name}] 재시작 실패: {exc}", cfg)
        if str(reason).startswith(("process_exit:", "port_health_down")):
            log(f"[자동복구][실패] service={name} reason={reason} error={exc}", cfg)
    finally:
        mp.restarting = False


def wait_for_health(service: dict, cfg: dict, name: str) -> None:
    ports = service.get("health_ports") or service.get("ports") or []
    if not ports:
        return
    timeout = float(service.get("startup_health_timeout_sec", cfg.get("startup_health_timeout_sec", 30.0)))
    end = time.time() + timeout
    while time.time() < end:
        if health_ok(service):
            return
        time.sleep(0.4)


def enabled_services(cfg: dict) -> List[dict]:
    return [s for s in cfg.get("services", []) if s.get("enabled", True)]


def is_ignored(
    path: Path,
    watch_dir: Path,
    ignore_dirs: Iterable[str],
    ignore_files: Iterable[str],
    ignore_extensions: Iterable[str],
    ignore_name_patterns: Iterable[str] = (),
) -> bool:
    ignore_dir_set = set(str(x) for x in ignore_dirs)
    ignore_file_set = set(str(x) for x in ignore_files)
    ignore_ext_set = {str(x).lower() if str(x).startswith(".") else f".{str(x).lower()}" for x in ignore_extensions}
    if path.name in ignore_file_set:
        return True
    if any(fnmatch.fnmatch(path.name, str(pattern)) for pattern in ignore_name_patterns):
        return True
    if path.suffix.lower() in ignore_ext_set:
        return True
    try:
        rel_parts = path.relative_to(watch_dir).parts
    except ValueError:
        rel_parts = path.parts
    return any(part in ignore_dir_set for part in rel_parts)


def matched(path: Path, patterns: Sequence[str]) -> bool:
    return any(fnmatch.fnmatch(path.name, pattern) for pattern in patterns)


def merged_watch_ignore(entry: dict, cfg: dict, key: str) -> List[str]:
    """전역 ignore와 서비스별 ignore를 항상 합친다.

    과거 local.json에 ignore_extensions=[] 같은 빈 배열이 남아 있으면
    cfg 전역 무시 규칙(.json/log/tmp 등)이 무력화되어 설정 저장/로그 생성만으로
    재시작되는 문제가 있었다. 빈 배열도 '추가 없음'으로 보고 전역 기준은 유지한다.
    """
    values: List[str] = []
    for src in (cfg.get(key, []), entry.get(key, [])):
        if isinstance(src, (list, tuple, set)):
            values.extend(str(x) for x in src if str(x).strip())
        elif src:
            values.append(str(src))
    seen: set[str] = set()
    result: List[str] = []
    for value in values:
        norm = value.lower() if key == "ignore_extensions" else value
        if norm in seen:
            continue
        seen.add(norm)
        result.append(value)
    return result


def iter_watch_files(entry: dict, cfg: dict) -> Iterable[Path]:
    """감시 파일을 열거한다.

    watch_files가 하나라도 있으면 허용 목록만 사용한다. patterns/recursive는
    구버전 설정 및 Shared 감시 호환용 fallback이며 서비스 허용 목록보다 우선할 수 없다.
    """
    patterns = list(entry.get("patterns") or ["*.py"])
    recursive = bool(entry.get("recursive", True))
    watch_files = [str(x).strip().replace("\\", "/") for x in (entry.get("watch_files") or []) if str(x).strip()]
    ignore_dirs = merged_watch_ignore(entry, cfg, "ignore_dirs")
    ignore_files = merged_watch_ignore(entry, cfg, "ignore_files")
    ignore_extensions = merged_watch_ignore(entry, cfg, "ignore_extensions")
    ignore_name_patterns = merged_watch_ignore(entry, cfg, "ignore_name_patterns")
    if str(entry.get("name") or "").strip().upper() == "AUTO_SERVICE8700_MANAGER":
        ignore_files = [f for f in ignore_files if str(f).lower() != "auto_service8700_manager.py"]
    yielded: set[str] = set()
    for wd in entry.get("watch_dirs", []):
        watch_dir = Path(str(wd))
        if not watch_dir.exists():
            continue
        if watch_files:
            for rel in watch_files:
                path = watch_dir / Path(rel)
                if not path.is_file():
                    continue
                if is_ignored(path, watch_dir, ignore_dirs, ignore_files, ignore_extensions, ignore_name_patterns):
                    continue
                key = str(path.resolve())
                if key not in yielded:
                    yielded.add(key)
                    yield path
            continue
        iterator = watch_dir.rglob("*") if recursive else watch_dir.glob("*")
        for path in iterator:
            if path.is_file() and not is_ignored(path, watch_dir, ignore_dirs, ignore_files, ignore_extensions, ignore_name_patterns) and matched(path, patterns):
                key = str(path.resolve())
                if key not in yielded:
                    yielded.add(key)
                    yield path


def take_snapshot(entry: dict, cfg: dict) -> Snapshot:
    snap: Snapshot = {}
    for path in iter_watch_files(entry, cfg):
        try:
            st = path.stat()
        except FileNotFoundError:
            continue
        snap[str(path.resolve())] = (st.st_mtime_ns, st.st_size)
    return snap


def diff_snapshot(old: Snapshot, new: Snapshot) -> List[str]:
    changed: List[str] = []
    old_keys = set(old)
    new_keys = set(new)
    for p in sorted(new_keys - old_keys):
        changed.append(f"생성: {p}")
    for p in sorted(old_keys - new_keys):
        changed.append(f"삭제: {p}")
    for p in sorted(old_keys & new_keys):
        if old[p] != new[p]:
            changed.append(f"수정: {p}")
    return changed


def run_auto8700_manager_core(
    cfg: dict,
    services: List[dict],
    services_by_name: Dict[str, dict],
    watch_entries: List[dict],
    shared: Optional[dict],
) -> int:
    """서비스 시작·감시·종료를 UI와 분리해 worker에서 실행한다."""
    start_auto8700_status_watch(cfg)
    start_auto8700_event_server(cfg)
    update_schedule_state(cfg)

    managed: Dict[str, ManagedProc] = {str(s.get("name")): ManagedProc(name=str(s.get("name"))) for s in services}
    # WS/RTC 서비스와 viewer 자동오픈보다 CDP모드 검사를 먼저 한다.
    cdp_mode_precheck_before_services(cfg)

    if bool(cfg.get("restart_on_start", False)):
        for service in services:
            service_name = str(service.get("name") or "")
            if service_name.upper() == "AUTO_SERVICE8700_MANAGER":
                log(f"[{service_name}] manager_start 자기 실행 제외: 코드/배치 변경 시에만 재실행", cfg)
                continue
            if not bool(service.get("start_on_manager_start", True)):
                log(f"[{service.get('name')}] manager_start 자동 실행 제외: start_on_manager_start=false", cfg)
                continue
            restart_service(str(service.get("name")), services_by_name, managed, cfg, reason="manager_start")

    AUTO8700_SCHEDULE_STATE["startup_services_ready"] = True
    log("[CDP_MODE] startup_services_ready=true → 이제 CDP모드 확인창 표시 가능", cfg)

    # v181: IG Chrome은 이미 서비스 시작 전 CDP모드 선확인에서 처리했다.
    # 여기서는 CDP모드가 이미 OK이면 탭만 재확인하고, 일반 Chrome 추가 실행은 하지 않는다.
    if chrome_debug_alive(cfg):
        ensure_instagram_tab(cfg)
    start_devtools_network_watch_if_enabled(cfg)

    snapshots = {str(e.get("name")): take_snapshot(e, cfg) for e in watch_entries}
    for name, snap in snapshots.items():
        log(f"[{name}] 초기 감시 파일 수: {len(snap)}", cfg)

    poll_interval = float(cfg.get("poll_interval_sec", DEFAULT_POLL_INTERVAL))
    debounce_sec = float(cfg.get("debounce_sec", DEFAULT_DEBOUNCE_SEC))
    port_down_since: Dict[str, float] = {}
    health_seen_alive: set[str] = set()
    for service in services:
        name = str(service.get("name"))
        try:
            if health_ok(service):
                health_seen_alive.add(name)
                managed[name].last_health_ok_ts = time.time()
        except Exception:
            pass
    manager_command_sig: Optional[Tuple[int, int]] = None
    last_marker_refresh_ts = 0.0
    last_marker_value = ""
    last_schedule_tick_ts = 0.0
    file_watch_suppress_until: Dict[str, float] = {}
    last_file_change_signature: Dict[str, Tuple[Tuple[str, ...], float]] = {}

    handover_exit = False
    try:
        while not AUTO8700_SHUTDOWN_EVENT.is_set():
            time.sleep(poll_interval)

            if time.time() - last_schedule_tick_ts >= 1.0:
                last_schedule_tick_ts = time.time()
                try:
                    auto8700_schedule_tick(cfg)
                except Exception as exc:
                    AUTO8700_SCHEDULE_STATE["last_result"] = f"예약 tick 오류: {exc}"

            marker_cfg = devtools_marker_cfg(cfg)
            marker_poll_sec = float(marker_cfg.get("status_poll_sec", 5.0) or 5.0)
            if bool(marker_cfg.get("enabled", True)) and marker_poll_sec > 0 and time.time() - last_marker_refresh_ts >= marker_poll_sec:
                last_marker_refresh_ts = time.time()
                try:
                    marker = refresh_auto8700_control_marker(cfg)
                    if marker and marker != last_marker_value:
                        last_marker_value = marker
                        st = control_marker_status_payload(cfg, devtools_ok=True)
                        log(f"[DEV8700] 제어 marker 갱신: {marker} / ws_ok={st.get('ws_ok')} / devtools_ok={st.get('devtools_ok')}", cfg)
                except Exception as exc:
                    # marker 갱신 실패는 서비스 감시 루프를 막지 않는다.
                    DEVTOOLS_WATCH_STATE["last_error"] = str(exc)

            manager_command_sig, should_exit_for_reexec = consume_manager_command(cfg, manager_command_sig)
            if should_exit_for_reexec:
                handover_exit = True
                log("[MANAGER] 새 매니저 인계 시작 → 무한 대기 없이 현재 감시 루프 종료", cfg)
                AUTO8700_SHUTDOWN_EVENT.set()
                break

            # 프로세스 종료 감시
            for name, mp in list(managed.items()):
                proc = mp.process
                if proc is not None and proc.poll() is not None:
                    code = proc.returncode
                    log(f"[{name}] 프로세스 종료 감지: exit_code={code}", cfg)
                    mp.process = None
                    port_down_since.pop(str(name), None)
                    if bool(cfg.get("restart_if_process_exits", True)):
                        restart_service(name, services_by_name, managed, cfg, reason=f"process_exit:{code}")

            # 포트 health 감시
            if bool(cfg.get("restart_if_ports_down", True)):
                grace = float(cfg.get("port_down_grace_sec", 4.0))
                for service in services:
                    name = str(service.get("name"))
                    if not (service.get("health_ports") or service.get("ports")):
                        continue
                    if service.get("runtime_health_check", True) is False:
                        # v230: Analyze8780은 시작 확인 뒤 반복 health를 하지 않고 프로세스 종료만 감시한다.
                        port_down_since.pop(name, None)
                        continue
                    if health_ok(service):
                        health_seen_alive.add(name)
                        port_down_since.pop(name, None)
                        managed[name].last_health_ok_ts = time.time()
                        continue
                    if bool(cfg.get("restart_only_after_seen_alive", True)) and name not in health_seen_alive and managed[name].process is None:
                        port_down_since.pop(name, None)
                        continue
                    first_down = port_down_since.setdefault(name, time.time())
                    if time.time() - first_down >= grace:
                        log(f"[{name}] 포트 health down 지속 {time.time() - first_down:.1f}s → 재시작", cfg)
                        port_down_since.pop(name, None)
                        restart_service(name, services_by_name, managed, cfg, reason="port_health_down")

            # 파일 변경 감시: 허용 목록 + 예외 규칙 + 중복 signature 차단
            for entry in watch_entries:
                name = str(entry.get("name"))
                now = time.time()
                if now < float(file_watch_suppress_until.get(name, 0.0)):
                    # 재실행 과정에서 생성된 부수 파일은 기준 snapshot에 흡수하고 재실행 사유로 쓰지 않는다.
                    snapshots[name] = take_snapshot(entry, cfg)
                    continue
                old_snapshot = snapshots.get(name, {})
                new_snapshot = take_snapshot(entry, cfg)
                changes = diff_snapshot(old_snapshot, new_snapshot)
                if not changes:
                    snapshots[name] = new_snapshot
                    continue
                time.sleep(debounce_sec)
                stable_snapshot = take_snapshot(entry, cfg)
                stable_changes = diff_snapshot(old_snapshot, stable_snapshot)
                snapshots[name] = stable_snapshot
                if not stable_changes:
                    continue
                signature = tuple(stable_changes)
                prev_sig, prev_ts = last_file_change_signature.get(name, (tuple(), 0.0))
                duplicate_window = max(10.0, float(entry.get("restart_cooldown_sec", cfg.get("restart_cooldown_sec", 2.0))) * 3.0)
                if signature == prev_sig and now - prev_ts < duplicate_window:
                    log(f"[{name}] 동일 변경 signature 중복 무시: {len(signature)}개 / {now - prev_ts:.1f}s", cfg)
                    continue
                last_file_change_signature[name] = (signature, now)
                log(f"[{name}] 허용 파일 변경 감지 → 재실행", cfg)
                for item in stable_changes[:10]:
                    log(f"  - 원인파일 {item}", cfg)
                if len(stable_changes) > 10:
                    log(f"  - 외 {len(stable_changes) - 10}개", cfg)
                if name == str((cfg.get("shared_watch") or {}).get("name")):
                    restart_names = list(entry.get("restart_services") or services_by_name.keys())
                else:
                    restart_names = [name]
                for restart_name in restart_names:
                    restart_service(str(restart_name), services_by_name, managed, cfg, reason=f"file_change:{name}:{'|'.join(stable_changes[:3])}")
                suppress_sec = max(debounce_sec + poll_interval, float(entry.get("restart_cooldown_sec", cfg.get("restart_cooldown_sec", 2.0))))
                file_watch_suppress_until[name] = time.time() + suppress_sec
                snapshots[name] = take_snapshot(entry, cfg)
    except KeyboardInterrupt:
        log("사용자 중지 Ctrl+C", cfg)
    finally:
        log("[EXIT] Auto8700 종료 처리 시작", cfg)
        DEVTOOLS_WATCH_STATE["stop"] = True
        shutdown_auto8700_event_server(cfg)
        if handover_exit:
            log("[EXIT] 매니저 인계 종료: 자식 서비스와 Chrome은 새 매니저가 이어받도록 유지", cfg)
        elif bool(cfg.get("kill_on_exit", True)):
            stop_all_managed_services(services_by_name, managed, cfg)
            # v234: Auto8700 X 종료는 관리 서비스 전체를 닫되 Chrome 탭·창·프로세스는 유지한다.
            log("[EXIT] Chrome/CDP 유지: 탭·창·프로세스 종료 안 함", cfg)
        log("[EXIT] Auto8700 종료 처리 완료", cfg)
    return 0


def main(argv: Optional[Sequence[str]] = None) -> int:
    AUTO8700_SHUTDOWN_EVENT.clear()
    args = parse_args(argv)
    cfg = load_config()
    ensure_st_token_file()
    reset_python_log_files_on_start(cfg)
    enable_console_color(cfg)
    set_console_title(str(cfg.get("manager_console_title") or "Auto8700"))
    if args.request_restart:
        path = write_restart_request(cfg, args.reason)
        log(f"[MANAGER] 재부팅 명령 파일 생성: {path}", cfg)
        return 0
    if args.no_restart_on_start:
        cfg["restart_on_start"] = False
    acquire_manager_lock(cfg, takeover_pid=int(args.takeover_pid or 0))
    services = enabled_services(cfg)
    services_by_name = {str(s.get("name")): s for s in services}
    if not services:
        log("enabled service가 없습니다. auto_service8700_manager.default.json services를 확인하세요.", cfg)
        return 1

    watch_entries: List[dict] = list(services)
    shared = cfg.get("shared_watch")
    if shared and shared.get("enabled", True):
        watch_entries.append(shared)

    log(f"자동실행 관리자 시작: {cfg.get('build_tag')} / schema={cfg.get('schema')}", cfg)
    for service in services:
        log(f"  service={service.get('name')} ports={service.get('ports')} watch={service.get('watch_dirs')} files={service.get('watch_files') or 'pattern_fallback'}", cfg)
    if shared and shared.get("enabled", True):
        log(f"  shared={shared.get('name')} watch={shared.get('watch_dirs')} restart={shared.get('restart_services')}", cfg)
    log(f"  manager_command={manager_command_path(cfg)}", cfg)

    manager_result = {"code": 0, "error": ""}
    manager_done = threading.Event()

    def manager_worker() -> None:
        try:
            manager_result["code"] = int(run_auto8700_manager_core(cfg, services, services_by_name, watch_entries, shared) or 0)
        except Exception as exc:
            manager_result["code"] = 1
            manager_result["error"] = str(exc)
            log(f"[MANAGER] worker 오류: {exc}", cfg)
            request_manager_shutdown(cfg, reason="manager_worker_error")
            try:
                shutdown_auto8700_event_server(cfg)
                stop_all_managed_services(services_by_name, {}, cfg)
                log("[EXIT] worker 오류 emergency cleanup 완료", cfg)
            except Exception as cleanup_exc:
                log(f"[EXIT] worker 오류 emergency cleanup 실패: {cleanup_exc}", cfg)
        finally:
            manager_done.set()

    worker = threading.Thread(target=manager_worker, name="auto8700-manager-worker", daemon=False)
    worker.start()

    ui_error = ""
    try:
        log("[UI] MAIN_THREAD_START", cfg)
        run_auto8700_floating_ui_main_thread(cfg)
    except KeyboardInterrupt:
        request_manager_shutdown(cfg, reason="keyboard_interrupt")
    except Exception as exc:
        ui_error = str(exc)
        log(f"[UI] MAIN_THREAD_ERROR: {exc}", cfg)
        request_manager_shutdown(cfg, reason="ui_start_failed")
    finally:
        if not AUTO8700_SHUTDOWN_EVENT.is_set():
            request_manager_shutdown(cfg, reason="ui_mainloop_closed")
        while worker.is_alive():
            worker.join(timeout=0.25)

    if ui_error and not manager_result.get("error"):
        manager_result["error"] = ui_error
        manager_result["code"] = 1
    return int(manager_result.get("code") or 0)


if __name__ == "__main__":
    raise SystemExit(main())
