# -*- coding: utf-8 -*-
"""
yjm_save_dialog_auto_enter.py
Windows 10/11 전용: "다른 이름으로 저장" 창이 뜨면, 사용 중 상태에서만 자동으로 저장 버튼/Enter를 실행합니다.

외부 패키지 없음. Python 표준 라이브러리 + Windows API(ctypes)만 사용합니다.
"""

import ctypes
from ctypes import wintypes
import argparse
import atexit
import json
import os
import sys
import time
import tkinter as tk
import base64
import hashlib
import queue
import re
import socket
import struct
import subprocess
import shutil
import tempfile
import threading
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from collections import deque
from pathlib import Path
from tkinter import messagebox

# v223: Heart8772 and OS mouse/origin bridge retired.
APP_NAME = "WS"
WS_BUILD_VERSION = "2.3.8.240"
WS_BUILD_TAG = "v240"
WS_LABEL = "WS v240"
CONFIG_FILE = "ws8771_config.json"

ST_ROOT = Path(__file__).resolve().parent.parent
if str(ST_ROOT) not in sys.path:
    sys.path.insert(0, str(ST_ROOT))
from Shared.storage import canonical_instagram_post_url, derive_stdown_root, record_downloaded_file

# v223: 런타임 상태는 파일에 누적하지 않고 메모리와 WS 응답으로만 공유한다.
# 설정 저장은 config JSON만 사용하고, 자동화 중 계속 바뀌는 값은 메모리 ring buffer와 WS 응답으로만 공유한다.
IGBLUE_RUNTIME_LOCK = threading.RLock()
IGBLUE_RUNTIME_EVENTS = deque(maxlen=500)
IGBLUE_RUNTIME_STATE = {}


def _igblue_memory_copy(value):
    try:
        return json.loads(json.dumps(value, ensure_ascii=False, default=str))
    except Exception:
        try:
            return dict(value) if isinstance(value, dict) else value
        except Exception:
            return value


def igblue_runtime_set(key, payload):
    try:
        k = str(key or "runtime")
        doc = _igblue_memory_copy(payload if isinstance(payload, dict) else {"value": payload})
        if isinstance(doc, dict):
            doc.setdefault("memory_updated_at", now_text() if 'now_text' in globals() else time.strftime("%Y-%m-%d %H:%M:%S"))
        with IGBLUE_RUNTIME_LOCK:
            IGBLUE_RUNTIME_STATE[k] = doc
        return doc
    except Exception:
        return payload if isinstance(payload, dict) else {}


def igblue_runtime_get(key=None):
    try:
        with IGBLUE_RUNTIME_LOCK:
            if key is None:
                return _igblue_memory_copy(IGBLUE_RUNTIME_STATE)
            return _igblue_memory_copy(IGBLUE_RUNTIME_STATE.get(str(key or ""), {}))
    except Exception:
        return {}


def igblue_runtime_events(limit=80):
    try:
        n = max(1, min(500, int(limit or 80)))
        with IGBLUE_RUNTIME_LOCK:
            return _igblue_memory_copy(list(IGBLUE_RUNTIME_EVENTS)[-n:])
    except Exception:
        return []

DEFAULT_CONFIG = {
    "enabled": True,
    "enter_mode": "auto",
    "auto_enter_window_sec": 25,
    "alpha": 0.78,
    "x": 80,
    "y": 80,
    "poll_ms": 250,
    "cooldown_sec": 2.5,
    "match_contains": True,
    "target_titles": [
        "다른 이름으로 저장",
        "다른 이름으로 저장 확인",
        "Save As",
        "Confirm Save As"
    ],
    "button_keywords": [
        "저장",
        "Save"
    ],
    "try_idok_after_button_fail": True,
    "try_enter_after_idok": True,
    "ui_collapsed": False,
    "ui_collapse_stage": 0,
    "ui_expanded_width": 820,
    "ui_compact_width": 230,
    "ui_collapsed_width": 34,
    "ui_bar_height": 34,
    "control_ws": {
        "enabled": True,
        "host": "127.0.0.1",
        "port": 8771,
        "token": "auto_enter_local_SEC_20260623"
    },
    "thread_queue_mode": {
        "enabled": True,
        "test_mode": True,
        "poll_interval_ms": 250,
        "result_ttl_sec": 180,
        "actions": [
            "ig_media_exists",
            "ig_media_downloaded",
            "domain_folder_json_read",
            "domain_auto_start"
        ]
    },
    "yellow_api": {
        "enabled": True,
        "url": "https://n.yjm.kr/i/yellow.php",
        "token": "ABC1234XYZ",
        "timeout_sec": 10
    },
    "tts_level": 0,
    "tts_voice_gender": "male",
    "copy_hotkey": "ctrl+shift+1",
    "python_logs": {
        "enabled": True,
        "run_enabled": True,
        "error_enabled": True,
        "keep_count": 10,
        "log_dir": "logs",
        "run_prefix": "ws_run_log",
        "err_prefix": "ws_err_log",
        "project_progress_enabled": True,
        "project_progress_prefix": "ws_project_progress_log",
        "failure_prefix": "ws_failure_log",
        "trace": {"ws_request": True}
    },
    "python_file_logs": {
        "enabled": True,
        "keep_files": 10,
        "root_dir": "logs",
        "prefix": "ws8771"
    },
    "last_instagram_url": "",
    "last_download_file_path": "",
    "last_download_json_path": "",
    "last_media_folder": "",
    "last_folder_json": "",
    "last_folder_reconcile": {},
    "last_media_state": {},
    "rtc": {
        "enabled": True,
        "host": "127.0.0.1",
        "http_port": 8790,
        "ws_port": 8791,
        "fps": 12,
        "quality": 70,
        "max_width": 1280,
        "control": "on",
        "viewer": "open",
        "source": "desktop",
        "display_index": 0
    }
}


# -----------------------------
# Runtime identity / verbose logging
# -----------------------------
RUNTIME_VERBOSE = False
RUNTIME_USER = ""


def default_runtime_user():
    """실행 옵션 -u/--user가 없을 때 Windows 컴퓨터 이름을 기본 사용자 ID로 사용합니다."""
    name = os.environ.get("COMPUTERNAME") or socket.gethostname() or "local_pc"
    return str(name).strip() or "local_pc"


def mask_secret(value):
    s = str(value or "")
    if len(s) <= 8:
        return "****" if s else ""
    return s[:4] + "..." + s[-3:]


def short_json_for_log(value, max_len=420):
    try:
        text = json.dumps(value, ensure_ascii=False, sort_keys=True)
    except Exception:
        text = str(value)
    if len(text) > max_len:
        text = text[:max_len] + "..."
    return text




def vlog(message):
    if not RUNTIME_VERBOSE:
        return
    try:
        print(f"[{now_text()}] {message}", flush=True)
    except Exception:
        print(str(message), flush=True)


def parse_cli_args(argv=None):
    parser = argparse.ArgumentParser(
        description="YJM Save Dialog Auto Enter local WS bridge v75 right panel 4-point calibration"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="터미널에 WS 접속/요청/API 호출 로그를 자세히 출력합니다."
    )
    parser.add_argument(
        "-u", "--user",
        dest="collector_id",
        default="",
        help="수집 사용자/작업자 ID입니다. 생략하면 Windows 컴퓨터 이름을 사용합니다."
    )
    parser.add_argument(
        "--no-single-instance",
        action="store_true",
        help="디버그용: 기존 WS8771 프로세스 자동 종료/인계 기능을 끕니다."
    )
    return parser.parse_args(argv)


def ws8771_runtime_dir():
    base = os.environ.get("TEMP") or os.environ.get("TMP") or os.path.dirname(os.path.abspath(__file__))
    path = os.path.join(base, "ws8771_runtime")
    try:
        os.makedirs(path, exist_ok=True)
    except Exception:
        pass
    return path


def ws8771_single_instance_lock_path():
    return os.path.join(ws8771_runtime_dir(), "ws8771_main.lock.json")


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


def ws8771_pid_command_line(pid):
    if os.name != "nt":
        return ""
    # PowerShell 우선, 실패하면 구형 Windows용 wmic fallback.
    try:
        ps = (
            "try { "
            f"$p = Get-CimInstance Win32_Process -Filter \"ProcessId={int(pid)}\"; "
            "if ($p) { [Console]::OutputEncoding=[Text.Encoding]::UTF8; Write-Output $p.CommandLine } "
            "} catch { }"
        )
        proc = subprocess.run(
            ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="ignore",
            timeout=5,
            check=False,
        )
        out = (proc.stdout or "").strip()
        if out:
            return out
    except Exception:
        pass
    try:
        proc = subprocess.run(
            ["wmic", "process", "where", f"ProcessId={int(pid)}", "get", "CommandLine", "/value"],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="ignore",
            timeout=5,
            check=False,
        )
        out = (proc.stdout or "").strip()
        for line in out.splitlines():
            if line.lower().startswith("commandline="):
                return line.split("=", 1)[1].strip()
        return out
    except Exception:
        return ""


def ws8771_is_previous_self(pid, old_doc):
    try:
        pid = int(pid)
    except Exception:
        return False
    if pid <= 0 or pid == os.getpid():
        return False
    cur_script = os.path.normcase(os.path.abspath(__file__))
    old_script = os.path.normcase(os.path.abspath(str((old_doc or {}).get("script") or "")))
    if old_script and old_script != cur_script:
        return False
    cmd = ws8771_pid_command_line(pid)
    if cmd:
        low = cmd.lower().replace("\\", "/")
        cur_low = cur_script.lower().replace("\\", "/")
        # py launcher/python 실행과 frozen exe 모두 허용하되 WS8771 본체임은 확인한다.
        if ("ws8771_main.py" not in low) and (cur_low not in low):
            return False
    return True


def ws8771_kill_previous_self(pid, reason="single_instance_takeover"):
    try:
        pid = int(pid)
    except Exception:
        return False
    if pid <= 0 or pid == os.getpid():
        return False
    print(f"[WS8771] 이전 WS 프로세스를 종료합니다. pid={pid} reason={reason}", flush=True)
    if os.name == "nt":
        try:
            # 자기 자신 프로세스만 종료한다. /T는 쓰지 않는다.
            subprocess.run(["taskkill", "/PID", str(pid), "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12, check=False)
        except Exception as exc:
            print(f"[WS8771] 이전 WS 종료 실패 pid={pid}: {exc}", flush=True)
            return False
    else:
        try:
            os.kill(pid, 15)
        except Exception as exc:
            print(f"[WS8771] 이전 WS 종료 실패 pid={pid}: {exc}", flush=True)
            return False
    deadline = time.time() + 8.0
    while time.time() < deadline:
        if not ws8771_pid_alive(pid):
            return True
        time.sleep(0.15)
    return not ws8771_pid_alive(pid)


def ws8771_listen_pids_for_port(port=8771):
    """lock이 없는 구버전 WS(v162 이하)를 잡기 위한 8771 포트 fallback."""
    if os.name != "nt":
        return []
    try:
        proc = subprocess.run(
            ["netstat", "-ano", "-p", "TCP"],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="ignore",
            timeout=6,
            check=False,
        )
        out = proc.stdout or ""
    except Exception:
        return []
    pids = []
    pat = re.compile(r"^\s*TCP\s+\S+:(%d)\s+\S+\s+LISTENING\s+(\d+)\s*$" % int(port), re.I)
    for line in out.splitlines():
        m = pat.search(line)
        if not m:
            continue
        try:
            pid = int(m.group(2))
        except Exception:
            continue
        if pid > 0 and pid != os.getpid() and pid not in pids:
            pids.append(pid)
    return pids


def ws8771_is_ws_command_pid(pid):
    cmd = ws8771_pid_command_line(pid)
    if not cmd:
        return False
    low = cmd.lower().replace("\\", "/")
    return ("ws8771_main.py" in low) or ("/ws8771/" in low and "python" in low) or ("ws8771" in low and "main" in low)


def ws8771_kill_port_fallback(port=8771):
    killed = []
    for pid in ws8771_listen_pids_for_port(port):
        if ws8771_is_ws_command_pid(pid):
            if ws8771_kill_previous_self(pid, reason=f"port_{port}_takeover"):
                killed.append(pid)
        else:
            print(f"[WS8771] port {port} 점유 PID가 WS8771로 확인되지 않아 종료하지 않습니다. pid={pid}", flush=True)
    return killed


def ws8771_read_lock(path):
    try:
        with open(path, "r", encoding="utf-8-sig") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def ws8771_write_lock(path):
    data = {
        "pid": os.getpid(),
        "script": os.path.abspath(__file__),
        "started_at": datetime.now().isoformat(timespec="seconds"),
        "ws_build_version": WS_BUILD_VERSION,
        "ws_build_tag": WS_BUILD_TAG,
        "label": WS_LABEL,
    }
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        os.replace(tmp, path)
    except Exception as exc:
        print(f"[WS8771] lock 저장 실패: {exc}", flush=True)
    return data


def ws8771_release_lock(path):
    try:
        doc = ws8771_read_lock(path)
        if int(doc.get("pid") or 0) == os.getpid():
            try:
                os.remove(path)
            except FileNotFoundError:
                pass
    except Exception:
        pass


def ws8771_acquire_single_instance():
    path = ws8771_single_instance_lock_path()
    old = ws8771_read_lock(path)
    old_pid = int(old.get("pid") or 0) if isinstance(old, dict) else 0
    if old_pid and old_pid != os.getpid() and ws8771_pid_alive(old_pid):
        if ws8771_is_previous_self(old_pid, old):
            ws8771_kill_previous_self(old_pid, reason="new_ws_start_takeover")
        else:
            print(f"[WS8771] 기존 lock PID가 WS8771 본체로 확인되지 않아 종료하지 않습니다. pid={old_pid}", flush=True)
    # v163: v162 이하에는 lock이 없었으므로 8771 LISTENING PID도 한 번 확인한다.
    ws8771_kill_port_fallback(port=8771)
    ws8771_write_lock(path)
    atexit.register(ws8771_release_lock, path)
    print(f"[WS8771] single-instance lock 획득: {path} pid={os.getpid()} {WS_LABEL}", flush=True)



def download_url_to_file(url, target_path, headers=None, timeout=120.0):
    """Stream one URL to a temporary file and atomically rename it to target_path."""
    target = Path(str(target_path)).expanduser().resolve()
    target.parent.mkdir(parents=True, exist_ok=True)
    part = target.parent / f".{target.name}.{os.getpid()}.{threading.get_ident()}.part"
    request = urllib.request.Request(str(url or ""), headers=dict(headers or {}), method="GET")
    written = 0
    expected = 0
    try:
        with urllib.request.urlopen(request, timeout=float(timeout)) as response:
            status = int(getattr(response, "status", 200) or 200)
            if status >= 400:
                raise RuntimeError(f"http_status_{status}")
            try:
                expected = int(response.headers.get("Content-Length") or 0)
            except Exception:
                expected = 0
            with open(part, "wb") as out:
                while True:
                    chunk = response.read(1024 * 1024)
                    if not chunk:
                        break
                    out.write(chunk)
                    written += len(chunk)
                out.flush()
                os.fsync(out.fileno())
        if written <= 0:
            raise RuntimeError("download_empty")
        if expected > 0 and written != expected:
            raise RuntimeError(f"download_size_mismatch expected={expected} actual={written}")
        os.replace(str(part), str(target))
        return {"ok": True, "file_path": str(target), "bytes_written": written, "expected_bytes": expected}
    except Exception:
        try:
            if part.exists():
                part.unlink()
        except Exception:
            pass
        raise


# -----------------------------
# Windows API
# -----------------------------
if sys.platform != "win32":
    raise RuntimeError("이 프로그램은 Windows 전용입니다.")

user32 = ctypes.WinDLL("user32", use_last_error=True)

# v166a25: Ctrl+Shift+F12 전역 hotkey는 Tk polling이 아니라 Win32 message queue로 받는다.
user32.RegisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int, wintypes.UINT, wintypes.UINT]
user32.RegisterHotKey.restype = wintypes.BOOL
user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int]
user32.UnregisterHotKey.restype = wintypes.BOOL
user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT]
user32.GetMessageW.restype = wintypes.BOOL

EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
EnumChildProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)

user32.EnumWindows.argtypes = [EnumWindowsProc, wintypes.LPARAM]
user32.EnumWindows.restype = wintypes.BOOL

user32.EnumChildWindows.argtypes = [wintypes.HWND, EnumChildProc, wintypes.LPARAM]
user32.EnumChildWindows.restype = wintypes.BOOL

user32.IsWindowVisible.argtypes = [wintypes.HWND]
user32.IsWindowVisible.restype = wintypes.BOOL

user32.IsWindow.argtypes = [wintypes.HWND]
user32.IsWindow.restype = wintypes.BOOL

user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
user32.GetWindowTextLengthW.restype = ctypes.c_int

user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
user32.GetWindowTextW.restype = ctypes.c_int

user32.GetClassNameW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
user32.GetClassNameW.restype = ctypes.c_int

user32.ShowWindow.argtypes = [wintypes.HWND, ctypes.c_int]
user32.ShowWindow.restype = wintypes.BOOL

user32.SetForegroundWindow.argtypes = [wintypes.HWND]
user32.SetForegroundWindow.restype = wintypes.BOOL

user32.BringWindowToTop.argtypes = [wintypes.HWND]
user32.BringWindowToTop.restype = wintypes.BOOL

user32.SetActiveWindow.argtypes = [wintypes.HWND]
user32.SetActiveWindow.restype = wintypes.HWND

user32.SendMessageW.argtypes = [wintypes.HWND, ctypes.c_uint, wintypes.WPARAM, wintypes.LPARAM]
user32.SendMessageW.restype = wintypes.LPARAM

user32.GetDlgCtrlID.argtypes = [wintypes.HWND]
user32.GetDlgCtrlID.restype = ctypes.c_int

user32.keybd_event.argtypes = [ctypes.c_ubyte, ctypes.c_ubyte, ctypes.c_ulong, ctypes.c_ulong]
user32.keybd_event.restype = None
user32.GetWindowRect.argtypes = [wintypes.HWND, ctypes.POINTER(wintypes.RECT)]
user32.GetWindowRect.restype = wintypes.BOOL
user32.MoveWindow.argtypes = [wintypes.HWND, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wintypes.BOOL]
user32.MoveWindow.restype = wintypes.BOOL



SW_MINIMIZE = 6
SW_RESTORE = 9
BM_CLICK = 0x00F5
WM_COMMAND = 0x0111
WM_SETTEXT = 0x000C
IDOK = 1
IDNO = 7
VK_RETURN = 0x0D
VK_ESCAPE = 0x1B
IDCANCEL = 2
WM_GETTEXT = 0x000D
WM_GETTEXTLENGTH = 0x000E
KEYEVENTF_KEYUP = 0x0002
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):
    """Return nearest monitor work-area; fallback to Tk screen. Entire UI must stay inside this rect."""
    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 WS 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)



def get_window_text(hwnd: int) -> str:
    try:
        length = user32.GetWindowTextLengthW(hwnd)
        if length <= 0:
            return ""
        buf = ctypes.create_unicode_buffer(length + 1)
        user32.GetWindowTextW(hwnd, buf, length + 1)
        return buf.value
    except Exception:
        return ""


def get_class_name(hwnd: int) -> str:
    try:
        buf = ctypes.create_unicode_buffer(256)
        user32.GetClassNameW(hwnd, buf, 256)
        return buf.value
    except Exception:
        return ""


def normalize_list(values):
    if isinstance(values, str):
        values = values.replace("\n", ",").split(",")
    return [str(v).strip() for v in values if str(v).strip()]


def window_title_matches(title: str, targets, contains: bool) -> bool:
    title = (title or "").strip()
    if not title:
        return False
    targets = normalize_list(targets)
    if contains:
        return any(t.lower() in title.lower() for t in targets)
    return any(title.lower() == t.lower() for t in targets)


def enum_matching_dialogs(target_titles, match_contains=False, expected=None, armed=False):
    found = []
    expected = expected if isinstance(expected, dict) else {}

    @EnumWindowsProc
    def callback(hwnd, lparam):
        if user32.IsWindowVisible(hwnd):
            title = get_window_text(hwnd)
            matched = window_title_matches(title, target_titles, match_contains)
            if not matched and armed and (get_class_name(hwnd) or "") == "#32770":
                _edit_hwnd, filename = find_save_dialog_filename_edit(hwnd, expected)
                ig_match, _reason = instagram_download_dialog_match(filename, expected)
                title_low = str(title or "").lower()
                matched = bool(ig_match or "저장" in title_low or "save" in title_low)
            if matched:
                found.append(int(hwnd))
        return True

    user32.EnumWindows(callback, 0)
    return found


def _window_rect_dict(hwnd: int):
    try:
        rect = wintypes.RECT()
        if not user32.GetWindowRect(hwnd, ctypes.byref(rect)):
            return {}
        return {
            "left": int(rect.left),
            "top": int(rect.top),
            "right": int(rect.right),
            "bottom": int(rect.bottom),
            "width": int(rect.right - rect.left),
            "height": int(rect.bottom - rect.top),
        }
    except Exception:
        return {}


def _rect_intersects(a, b) -> bool:
    try:
        a = a if isinstance(a, dict) else {}
        b = b if isinstance(b, dict) else {}
        return not (
            int(a.get("right", 0)) <= int(b.get("left", 0)) or
            int(a.get("left", 0)) >= int(b.get("right", 0)) or
            int(a.get("bottom", 0)) <= int(b.get("top", 0)) or
            int(a.get("top", 0)) >= int(b.get("bottom", 0))
        )
    except Exception:
        return False


def _virtual_screen_rect() -> dict:
    try:
        x = int(user32.GetSystemMetrics(76))
        y = int(user32.GetSystemMetrics(77))
        w = int(user32.GetSystemMetrics(78))
        h = int(user32.GetSystemMetrics(79))
        if w <= 0 or h <= 0:
            raise RuntimeError("bad_virtual_screen")
        return {"left": x, "top": y, "right": x + w, "bottom": y + h, "width": w, "height": h}
    except Exception:
        try:
            w = int(user32.GetSystemMetrics(0))
            h = int(user32.GetSystemMetrics(1))
            return {"left": 0, "top": 0, "right": w, "bottom": h, "width": w, "height": h}
        except Exception:
            return {"left": 0, "top": 0, "right": 1920, "bottom": 1080, "width": 1920, "height": 1080}


def _clamp_rect_position(x: int, y: int, w: int, h: int, screen: dict) -> tuple[int, int]:
    try:
        left = int(screen.get("left", 0))
        top = int(screen.get("top", 0))
        right = int(screen.get("right", left + 1920))
        bottom = int(screen.get("bottom", top + 1080))
        x = max(left, min(int(x), right - max(1, int(w))))
        y = max(top, min(int(y), bottom - max(1, int(h))))
        return int(x), int(y)
    except Exception:
        return int(x), int(y)



def _ws8771_process_name_for_pid(pid: int) -> str:
    try:
        pid = int(pid)
        if pid <= 0:
            return ""
        proc = subprocess.run(
            ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            text=True,
            encoding="utf-8",
            errors="ignore",
            timeout=2,
            check=False,
        )
        line = (proc.stdout or "").strip().splitlines()[0] if (proc.stdout or "").strip() else ""
        if not line or "INFO:" in line.upper():
            return ""
        try:
            import csv
            row = next(csv.reader([line]))
            return str(row[0] or "").strip().strip('"')
        except Exception:
            return line.split(",", 1)[0].strip().strip('"')
    except Exception:
        return ""



def find_child_button(parent_hwnd: int, keywords):
    keywords = [k.lower() for k in normalize_list(keywords)]
    if not keywords:
        return None

    result = {"hwnd": None}

    @EnumChildProc
    def callback(hwnd, lparam):
        text = get_window_text(hwnd)
        cls = get_class_name(hwnd)
        if text and cls.lower() == "button":
            low = text.lower()
            if any(k in low for k in keywords):
                result["hwnd"] = int(hwnd)
                return False
        return True

    user32.EnumChildWindows(parent_hwnd, callback, 0)
    return result["hwnd"]


def collect_child_texts(parent_hwnd: int):
    texts = []

    @EnumChildProc
    def callback(hwnd, lparam):
        try:
            text = (read_control_text(hwnd) or get_window_text(hwnd) or "").strip()
            cls = (get_class_name(hwnd) or "").strip()
            if text:
                texts.append({"hwnd": int(hwnd), "class": cls, "text": text})
        except Exception:
            pass
        return True

    try:
        user32.EnumChildWindows(parent_hwnd, callback, 0)
    except Exception:
        pass
    return texts


def save_confirm_duplicate_info(hwnd: int):
    """Return duplicate/overwrite confirmation info for Windows Save As confirm dialogs.

    The normal Save As dialog is allowed to click Save/Enter.  A secondary
    confirmation dialog that says the file already exists must never be
    accepted; use No/Cancel so automation can continue without a modal block.
    """
    try:
        title = get_window_text(hwnd)
        items = collect_child_texts(hwnd)
        all_text = "\n".join([title] + [str(x.get("text") or "") for x in items])
        low = all_text.lower()
        title_low = str(title or "").lower()
        title_hint = any(k in title_low for k in ("저장 확인", "confirm save", "save as"))
        exists_hint = ("이미 있습니다" in all_text or "바꾸시겠습니까" in all_text or "already exists" in low or "replace it" in low or "overwrite" in low)
        file_hint = bool(re.search(r"\(\d+\)\.(?:mp4|jpg|jpeg|png|webp|avif)\b", all_text, re.I)) or bool(re.search(r"\.(?:mp4|jpg|jpeg|png|webp|avif)\b", all_text, re.I))
        if title_hint and exists_hint:
            return {"matched": True, "title": title, "text": all_text[:1000], "file_hint": file_hint}
        return {"matched": False, "title": title, "text": all_text[:1000], "file_hint": file_hint}
    except Exception as e:
        return {"matched": False, "error": str(e)}


def click_save_confirm_no(hwnd: int):
    try:
        activate_window(hwnd)
        time.sleep(0.05)
    except Exception:
        pass
    no_hwnd = find_child_button(hwnd, ["아니요", "아니오", "No", "&No", "N)"])
    if no_hwnd and user32.IsWindow(no_hwnd):
        user32.SendMessageW(no_hwnd, BM_CLICK, 0, 0)
        return True, "duplicate_confirm_no_clicked"
    try:
        user32.SendMessageW(hwnd, WM_COMMAND, IDNO, 0)
        time.sleep(0.05)
        return True, "duplicate_confirm_idno"
    except Exception:
        pass
    try:
        user32.SendMessageW(hwnd, WM_COMMAND, IDCANCEL, 0)
        time.sleep(0.05)
        return True, "duplicate_confirm_cancel"
    except Exception:
        pass
    press_escape_key()
    return True, "duplicate_confirm_escape"


def press_enter_key():
    # foreground 창에 Enter 입력
    user32.keybd_event(VK_RETURN, 0, 0, 0)
    time.sleep(0.03)
    user32.keybd_event(VK_RETURN, 0, KEYEVENTF_KEYUP, 0)


def press_escape_key():
    user32.keybd_event(VK_ESCAPE, 0, 0, 0)
    time.sleep(0.03)
    user32.keybd_event(VK_ESCAPE, 0, KEYEVENTF_KEYUP, 0)


def read_control_text(hwnd: int) -> str:
    try:
        length = int(user32.SendMessageW(hwnd, WM_GETTEXTLENGTH, 0, 0))
        if length <= 0:
            return get_window_text(hwnd)
        buf = ctypes.create_unicode_buffer(length + 1)
        user32.SendMessageW(hwnd, WM_GETTEXT, length + 1, ctypes.addressof(buf))
        return buf.value
    except Exception:
        return get_window_text(hwnd)


def get_control_id(hwnd: int) -> int:
    try:
        return int(user32.GetDlgCtrlID(hwnd))
    except Exception:
        return 0


def write_control_text(hwnd: int, value: str) -> bool:
    try:
        if not hwnd or not user32.IsWindow(hwnd):
            return False
        text = str(value or "")
        user32.SendMessageW(hwnd, WM_SETTEXT, 0, ctypes.cast(ctypes.c_wchar_p(text), ctypes.c_void_p).value or 0)
        time.sleep(0.03)
        return read_control_text(hwnd).strip().strip('"').casefold() == text.strip().strip('"').casefold()
    except Exception:
        return False


def expected_media_filename(expected=None) -> str:
    expected = expected if isinstance(expected, dict) else {}
    raw = os.path.basename(str(expected.get("prefix") or "").strip().strip('"'))
    if not raw:
        return ""
    if re.search(r"\.(?:mp4|jpg|jpeg|png|webp|avif)$", raw, re.I):
        return raw
    kind = str(expected.get("asset_kind") or "").strip().lower()
    ext = "mp4" if any(token in kind for token in ("video", "mp4", "reel")) else "jpg"
    return f"{raw}.{ext}"


def find_save_dialog_filename_edit(parent_hwnd: int, expected=None):
    expected = expected if isinstance(expected, dict) else {}
    candidates = []

    @EnumChildProc
    def callback(hwnd, lparam):
        cls = (get_class_name(hwnd) or "").lower()
        if cls == "edit":
            txt = (read_control_text(hwnd) or "").strip()
            score = 0
            if txt and re.search(r"\.(?:mp4|jpg|jpeg|png|webp|avif)(?:\s*)$", txt, re.I):
                score += 100
                matched, _reason = instagram_download_dialog_match(txt, expected)
                if matched:
                    score += 200
            if get_control_id(hwnd) in (1001, 1152):
                score += 40
            candidates.append({"hwnd": int(hwnd), "text": txt, "score": score})
        return True

    try:
        user32.EnumChildWindows(parent_hwnd, callback, 0)
    except Exception:
        pass
    if candidates:
        candidates.sort(key=lambda item: (int(item.get("score") or 0), bool(item.get("text"))), reverse=True)
        best = candidates[0]
        if int(best.get("score") or 0) > 0 or len(candidates) == 1:
            return int(best.get("hwnd") or 0) or None, str(best.get("text") or "")
    return None, ""


def has_windows_duplicate_suffix(filename: str) -> bool:
    return bool(re.search(r"\s\(\d+\)\.(?:mp4|jpg|jpeg|png|webp|avif)\s*$", str(filename or ""), re.I))


def instagram_download_dialog_match(filename: str, expected=None):
    """Instagram 확장이 만든 저장 파일명인지 판별한다.

    arm 요청의 prefix/shortcode가 있으면 그것을 우선 사용하고, arm이 늦거나 빠진 경우에도
    `_No000_`/`_No없음_` + shortcode + 미디어 확장자 형식만 자동 승인한다.
    """
    expected = expected if isinstance(expected, dict) else {}
    name = os.path.basename(str(filename or "").strip().strip('"'))
    low = name.lower()
    if not re.search(r"\.(?:mp4|jpg|jpeg|png|webp|avif)$", low, re.I):
        return False, "not_media_extension"
    stem = re.sub(r"\.(?:mp4|jpg|jpeg|png|webp|avif)$", "", name, flags=re.I)
    prefix = os.path.basename(str(expected.get("prefix") or "").strip().strip('"'))
    prefix = re.sub(r"\.(?:mp4|jpg|jpeg|png|webp|avif)$", "", prefix, flags=re.I)
    shortcode = str(expected.get("shortcode") or "").strip()
    if prefix and (stem == prefix or stem.startswith(prefix + " ") or stem.startswith(prefix + "(")):
        return True, "expected_prefix"
    if shortcode and shortcode in stem:
        return True, "expected_shortcode"
    if re.search(r"_(?:No\d{3}|No없음)_[A-Za-z0-9_-]{5,}(?:_\d+)?$", stem, re.I):
        return True, "ig_filename_pattern"
    return False, "filename_pattern_mismatch"


def save_dialog_target_path(expected=None, filename: str = "") -> str:
    expected = expected if isinstance(expected, dict) else {}
    base_name = os.path.basename(str(filename or expected_media_filename(expected) or "").strip().strip('"'))
    if not base_name:
        return ""
    folder = expected_download_folder(
        {
            "account": str(expected.get("account") or "instagram"),
            "account_folder_merge": bool(expected.get("account_folder_merge")),
        }
    )
    os.makedirs(folder, exist_ok=True)
    return os.path.join(folder, safe_path_part(base_name, "media.bin"))



def ws_intentional_stop_marker_path():
    return os.path.join(tempfile.gettempdir(), "auto_service8700_manager_runtime", "ws8771_intentional_stop.json")


def write_ws_intentional_stop_marker(reason="ui_x_close"):
    path = ws_intentional_stop_marker_path()
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump({
                "service": "WS8771",
                "pid": os.getpid(),
                "reason": str(reason or "ui_x_close"),
                "requested_at": now_text(),
                "requested_at_epoch": time.time(),
                "ws_build_tag": WS_BUILD_TAG,
            }, f, ensure_ascii=False, indent=2)
        os.replace(tmp, path)
        return path
    except Exception:
        return ""



def igblue_log_dir():
    # v166a10: Python file logs use logs/ws_run_log_* and ws_err_log_*; legacy runtime JSONL stays memory-only.
    return os.path.join(script_dir(), "logs")


_PY_LOG_LOCK = threading.RLock()
_PY_LOG_FILES = []
_ORIGINAL_STDOUT = sys.stdout
_ORIGINAL_STDERR = sys.stderr
_PROJECT_LOG_LOCK = threading.RLock()
_PROJECT_LOG_CONTEXT = {
    "enabled": False,
    "run_id": "",
    "run_log": "",
    "error_log": "",
    "project_progress_log": "",
    "failure_dir": "",
    "failure_prefix": "ws_failure_log",
    "keep_count": 10,
}
_PROJECT_PROGRESS_FILE = None


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


def _python_file_log_cfg(cfg):
    legacy = {}
    try:
        extra = cfg.get("python_file_logs") if isinstance(cfg, dict) else None
        if isinstance(extra, dict):
            legacy.update(extra)
    except Exception:
        pass
    base = {
        "enabled": True,
        "run_enabled": True,
        "error_enabled": True,
        "keep_count": 10,
        "log_dir": "logs",
        "run_prefix": "ws_run_log",
        "err_prefix": "ws_err_log",
        "project_progress_enabled": True,
        "project_progress_prefix": "ws_project_progress_log",
        "failure_prefix": "ws_failure_log",
        "trace": {"ws_request": True},
    }
    if legacy:
        if "enabled" in legacy:
            base["enabled"] = bool(legacy.get("enabled"))
        if "keep_files" in legacy:
            base["keep_count"] = legacy.get("keep_files")
        if "root_dir" in legacy:
            base["log_dir"] = legacy.get("root_dir") or "logs"
        if "prefix" in legacy:
            legacy_prefix = safe_path_part(legacy.get("prefix") or "ws", "ws")
            base["run_prefix"] = f"{legacy_prefix}_run_log"
            base["err_prefix"] = f"{legacy_prefix}_err_log"
    try:
        extra = cfg.get("python_logs") if isinstance(cfg, dict) else None
        if isinstance(extra, dict):
            base.update(extra)
            tr = dict(base.get("trace") or {})
            tr.update(extra.get("trace") or {})
            base["trace"] = tr
    except Exception:
        pass
    try:
        base["keep_count"] = max(1, min(100, int(base.get("keep_count") or 10)))
    except Exception:
        base["keep_count"] = 10
    base["enabled"] = bool(base.get("enabled", True))
    base["run_enabled"] = bool(base.get("run_enabled", True))
    base["error_enabled"] = bool(base.get("error_enabled", True))
    base["run_prefix"] = safe_path_part(base.get("run_prefix") or "ws_run_log", "ws_run_log")
    base["err_prefix"] = safe_path_part(base.get("err_prefix") or "ws_err_log", "ws_err_log")
    base["project_progress_enabled"] = bool(base.get("project_progress_enabled", True))
    base["project_progress_prefix"] = safe_path_part(base.get("project_progress_prefix") or "ws_project_progress_log", "ws_project_progress_log")
    base["failure_prefix"] = safe_path_part(base.get("failure_prefix") or "ws_failure_log", "ws_failure_log")
    base.pop("use_manager_unified", None)
    return base


def _prune_python_logs(root, keep, run_prefix="ws_run_log", err_prefix="ws_err_log", project_progress_prefix="ws_project_progress_log", failure_prefix="ws_failure_log"):
    try:
        for pattern in (
            f"{run_prefix}_*.log",
            f"{err_prefix}_*.log",
            f"{project_progress_prefix}_*.log",
            f"{failure_prefix}_*.log",
        ):
            files = []
            if not os.path.isdir(root):
                continue
            for name in os.listdir(root):
                if not name.lower().endswith(".log"):
                    continue
                if not name.startswith(pattern.replace("*.log", "")):
                    continue
                p = os.path.join(root, name)
                try:
                    files.append((os.path.getmtime(p), p))
                except Exception:
                    pass
            files.sort(reverse=True)
            for _mtime, p in files[int(keep):]:
                try:
                    os.remove(p)
                except Exception:
                    pass
    except Exception:
        pass



def project_log_context_snapshot():
    with _PROJECT_LOG_LOCK:
        return dict(_PROJECT_LOG_CONTEXT)


def _append_project_progress_line(message):
    line = f"[{now_text()}] {str(message or '').strip()}"
    try:
        with _PROJECT_LOG_LOCK:
            if _PROJECT_PROGRESS_FILE is not None:
                _PROJECT_PROGRESS_FILE.write(line + "\n")
                _PROJECT_PROGRESS_FILE.flush()
                return True
    except Exception:
        pass
    return False


def _project_failure_keyword(check_id):
    safe_check = safe_path_part(check_id or "CHECK", "CHECK")
    return f"PP-{safe_check}-{datetime.now().strftime('%Y%m%d_%H%M%S_%f')[:-3]}"


def _write_project_failure_excerpt(check_id, keyword, progress_judgement, detail_payload):
    ctx = project_log_context_snapshot()
    root = str(ctx.get("failure_dir") or "").strip()
    if not root:
        return ""
    try:
        os.makedirs(root, exist_ok=True)
        prefix = safe_path_part(ctx.get("failure_prefix") or "ws_failure_log", "ws_failure_log")
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        safe_check = safe_path_part(check_id or "CHECK", "CHECK")
        safe_keyword = safe_path_part(keyword or "DETAIL", "DETAIL")
        path = os.path.join(root, f"{prefix}_{stamp}_{safe_check}_{safe_keyword}.log")
        doc = detail_payload if isinstance(detail_payload, dict) else {"detail": str(detail_payload or "")}
        with open(path, "a", encoding="utf-8") as f:
            f.write(f"프로젝트 진행 판정: {progress_judgement}\n")
            f.write(f"실행ID: {ctx.get('run_id','')}\n")
            f.write(f"원본 상세로그: {ctx.get('run_log','')}\n")
            f.write(f"검색어: *** 상세로그 *** {keyword}\n")
            f.write(f"발생시간: {now_text()}\n")
            f.write("\n*** 상세로그 시작 ***\n")
            for key, value in doc.items():
                f.write(f"{key}={value}\n")
            f.write("*** 상세로그 종료 ***\n")
        _prune_python_logs(
            root,
            int(ctx.get("keep_count") or 10),
            "__unused_run__",
            "__unused_err__",
            "__unused_progress__",
            prefix,
        )
        return path
    except Exception:
        return ""


def _write_run_detail_marker(keyword, check_id, detail_payload):
    doc = detail_payload if isinstance(detail_payload, dict) else {"detail": str(detail_payload or "")}
    try:
        print(f"[{now_text()}] *** 상세로그 *** {keyword}", flush=True)
        print(f"[{now_text()}] 체크ID={check_id}", flush=True)
        for key, value in doc.items():
            print(f"[{now_text()}] {key}={value}", flush=True)
        print(f"[{now_text()}] *** 상세로그 종료 *** {keyword}", flush=True)
    except Exception:
        pass


def _open_env_log(path):
    try:
        if not path:
            return None
        parent = os.path.dirname(path)
        if parent:
            os.makedirs(parent, exist_ok=True)
        return open(path, "a", encoding="utf-8", buffering=1)
    except Exception:
        return None


def start_python_file_logs(cfg):
    """실행·에러·프로젝트 진행 로그를 같은 실행 ID로 시작한다."""
    global _ORIGINAL_STDOUT, _ORIGINAL_STDERR, _PROJECT_PROGRESS_FILE, _PROJECT_LOG_CONTEXT
    pcfg = _python_file_log_cfg(cfg if isinstance(cfg, dict) else {})
    if not pcfg.get("enabled", True):
        return {"enabled": False, "reason": "python_logs_disabled"}
    try:
        root = str(pcfg.get("log_dir") or "logs")
        if not os.path.isabs(root):
            root = os.path.join(script_dir(), root)
        os.makedirs(root, exist_ok=True)
        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        pid = os.getpid()
        run_prefix = str(pcfg.get("run_prefix") or "ws_run_log")
        err_prefix = str(pcfg.get("err_prefix") or "ws_err_log")
        project_progress_prefix = str(pcfg.get("project_progress_prefix") or "ws_project_progress_log")
        failure_prefix = str(pcfg.get("failure_prefix") or "ws_failure_log")
        run_path = os.path.join(root, f"{run_prefix}_{stamp}_pid{pid}.log")
        err_path = os.path.join(root, f"{err_prefix}_{stamp}_pid{pid}.log")
        project_progress_path = os.path.join(root, f"{project_progress_prefix}_{stamp}_pid{pid}.log")
        run_f = open(run_path, "a", encoding="utf-8", buffering=1) if bool(pcfg.get("run_enabled", True)) else None
        err_f = open(err_path, "a", encoding="utf-8", buffering=1) if bool(pcfg.get("error_enabled", True)) else None
        project_f = open(project_progress_path, "a", encoding="utf-8", buffering=1) if bool(pcfg.get("project_progress_enabled", True)) else None
        _PROJECT_PROGRESS_FILE = project_f
        _PROJECT_LOG_CONTEXT = {
            "enabled": bool(project_f),
            "run_id": f"{stamp}_pid{pid}",
            "run_log": run_path if run_f else "",
            "error_log": err_path if err_f else "",
            "project_progress_log": project_progress_path if project_f else "",
            "failure_dir": root,
            "failure_prefix": failure_prefix,
            "keep_count": int(pcfg.get("keep_count") or 10),
        }
        for f in (run_f, err_f, project_f):
            if f is not None:
                _PY_LOG_FILES.append(f)
        if run_f is not None:
            sys.stdout = _TeeStream(_ORIGINAL_STDOUT, run_f)
        if err_f is not None:
            sys.stderr = _TeeStream(_ORIGINAL_STDERR, err_f)
        atexit.register(_close_python_file_logs)
        _prune_python_logs(
            root,
            int(pcfg.get("keep_count") or 10),
            run_prefix,
            err_prefix,
            project_progress_prefix,
            failure_prefix,
        )
        if project_f is not None:
            _append_project_progress_line(
                f"[프로젝트진행][시작][체크완료] WS8771 프로젝트 진행로그 생성 / 실행ID={stamp}_pid{pid} / 상세로그={run_path if run_f else ''} / 에러로그={err_path if err_f else ''}"
            )
        banner = f"[{now_text()}] PY_LOG_START app=ws8771 pid={pid} run={run_path if run_f else ''} err={err_path if err_f else ''} progress={project_progress_path if project_f else ''} keep={pcfg.get('keep_count')} mode=project"
        print(banner, flush=True)
        if err_f is not None:
            print(banner, file=sys.stderr, flush=True)
        return {
            "enabled": True,
            "run_log": run_path if run_f else "",
            "error_log": err_path if err_f else "",
            "project_progress_log": project_progress_path if project_f else "",
        }
    except Exception:
        try:
            traceback.print_exc()
        except Exception:
            pass
        return {"enabled": False, "reason": "python_logs_start_failed"}


def _close_python_file_logs():
    global _PROJECT_PROGRESS_FILE, _PROJECT_LOG_CONTEXT
    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()
    _PROJECT_PROGRESS_FILE = None
    _PROJECT_LOG_CONTEXT = {
        "enabled": False,
        "run_id": "",
        "run_log": "",
        "error_log": "",
        "project_progress_log": "",
        "failure_dir": "",
        "failure_prefix": "ws_failure_log",
        "keep_count": 10,
    }


def reset_python_log_files_on_start(cfg=None):
    """호환 함수: v166a10부터 logs/ws_run_log 및 ws_err_log 기준으로 시작별 생성한다."""
    return start_python_file_logs(cfg if isinstance(cfg, dict) else {})


def igblue_append_jsonl(filename, payload):
    """v125: legacy append API retained, but it never writes to disk.

    Runtime diagnostics are kept in memory and returned through WS/status copy.
    """
    try:
        name = safe_path_part(filename, "ig_auto_download.jsonl")
        doc = _igblue_memory_copy(payload if isinstance(payload, dict) else {"value": payload})
        if isinstance(doc, dict):
            doc.setdefault("ts", now_text())
            doc.setdefault("runtime_log_name", name)
        with IGBLUE_RUNTIME_LOCK:
            IGBLUE_RUNTIME_EVENTS.append({"ts": now_text(), "name": name, "payload": doc})
            IGBLUE_RUNTIME_STATE["latest_event"] = {"name": name, "payload": doc}
            IGBLUE_RUNTIME_STATE["event:" + name] = doc
        return "memory://" + name
    except Exception:
        return "memory://error"



CALIBRATION_EXACT_MICRO_OFFSETS = (
    (0, -1),
    (0, 1),
    (-1, 0),
    (1, 0),
    (-1, -1),
    (1, -1),
    (-1, 1),
    (1, 1),
)



def _ig_num(value, default=0.0):
    try:
        n = float(value)
        return n if n == n else float(default)
    except Exception:
        return float(default)


def activate_window(hwnd: int):
    try:
        user32.ShowWindow(hwnd, SW_RESTORE)
        user32.BringWindowToTop(hwnd)
        user32.SetActiveWindow(hwnd)
        user32.SetForegroundWindow(hwnd)
    except Exception:
        pass


def auto_accept_save_dialog(hwnd: int, cfg: dict, expected=None):
    """
    Enter/저장 직전 저장창의 파일명 입력칸을 내부 확인한다.
    Windows가 이미 ` (1).mp4` 같은 중복 suffix를 제시했으면 저장하지 않고 취소한다.
    로그/음성 안내에는 파일명을 남기지 않는다.
    """
    if not user32.IsWindow(hwnd):
        return False, "window_gone"

    dup_confirm = save_confirm_duplicate_info(hwnd)
    if dup_confirm.get("matched"):
        _clicked, method = click_save_confirm_no(hwnd)
        return False, method

    activate_window(hwnd)
    time.sleep(0.05)

    expected = expected if isinstance(expected, dict) else {}
    edit_hwnd, filename = find_save_dialog_filename_edit(hwnd, expected)
    if has_windows_duplicate_suffix(filename):
        cancel_hwnd = find_child_button(hwnd, ["취소", "Cancel"])
        if cancel_hwnd and user32.IsWindow(cancel_hwnd):
            user32.SendMessageW(cancel_hwnd, BM_CLICK, 0, 0)
        else:
            try:
                user32.SendMessageW(hwnd, WM_COMMAND, IDCANCEL, 0)
            except Exception:
                press_escape_key()
        return False, "duplicate_suffix_cancelled"

    filename_match, _filename_reason = instagram_download_dialog_match(filename, expected)
    target_path = save_dialog_target_path(expected, filename) if expected else ""
    target_path_written = bool(target_path and edit_hwnd and filename_match and write_control_text(edit_hwnd, target_path))

    # Instagram 저장창은 D:\_StDown\instagram\계정\파일명으로 지정한 뒤 Enter를 보낸다.
    if cfg.get("try_enter_after_idok", True):
        press_enter_key()
        time.sleep(0.12)
        if user32.IsWindow(hwnd) and cfg.get("try_idok_after_button_fail", True):
            user32.SendMessageW(hwnd, WM_COMMAND, IDOK, 0)
        if target_path_written:
            return True, "target_path_enter"
        return True, "enter_relocate_fallback" if target_path else "enter"

    button_hwnd = find_child_button(hwnd, cfg.get("button_keywords", []))
    if button_hwnd and user32.IsWindow(button_hwnd):
        user32.SendMessageW(button_hwnd, BM_CLICK, 0, 0)
        return True, "button_click"

    if cfg.get("try_idok_after_button_fail", True):
        user32.SendMessageW(hwnd, WM_COMMAND, IDOK, 0)
        time.sleep(0.05)
        return True, "idok"

    return False, "no_action"


def load_config():
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILE)
    cfg = dict(DEFAULT_CONFIG)
    if os.path.exists(path):
        try:
            with open(path, "r", encoding="utf-8") as f:
                old = json.load(f)
            if isinstance(old, dict):
                legacy_has_enter_mode = "enter_mode" in old
                cfg.update(old)
                if not legacy_has_enter_mode and "enabled" in old:
                    cfg["enter_mode"] = "on" if bool(old.get("enabled")) else "off"
        except Exception:
            pass
    try:
        titles = normalize_list(cfg.get("target_titles", []))
        for t in ("다른 이름으로 저장 확인", "Confirm Save As"):
            if t not in titles:
                titles.append(t)
        cfg["target_titles"] = titles
        # Chrome/Windows 버전에 따라 제목 뒤에 앱 이름이 붙어도 Instagram 파일명 검증 후 처리한다.
        cfg["match_contains"] = True
    except Exception:
        pass
    return cfg


def save_config(cfg):
    path = os.path.join(os.path.dirname(os.path.abspath(__file__)), CONFIG_FILE)
    try:
        with open(path, "w", encoding="utf-8") as f:
            json.dump(cfg, f, ensure_ascii=False, indent=2)
    except Exception:
        pass




# -----------------------------
# IG media JSON + Yellow PHP API bridge
# -----------------------------
def now_text():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def safe_path_part(value, fallback="unknown"):
    s = str(value or fallback or "unknown")
    for ch in '\\/:*?"<>|\x00':
        s = s.replace(ch, "_")
    s = "_".join(s.split())
    while "__" in s:
        s = s.replace("__", "_")
    s = s.strip("._ ")[:120]
    return s or str(fallback or "unknown")


def safe_file_name(value, fallback="media.bin"):
    raw = str(value or fallback or "media.bin").replace("\\", "/").split("/")[-1]
    for ch in ':*?"<>|\x00':
        raw = raw.replace(ch, "_")
    raw = "_".join(raw.split()).rstrip(". ")
    if not raw:
        raw = str(fallback or "media.bin")
    if len(raw) <= 240:
        return raw
    stem, ext = os.path.splitext(raw)
    keep = max(1, 240 - len(ext))
    return stem[:keep] + ext


def normalize_int(value, default=0):
    try:
        n = int(float(value))
        return n if n > 0 else int(default)
    except Exception:
        return int(default)


def detect_asset_kind(file_path, meta=None):
    meta = meta if isinstance(meta, dict) else {}
    raw = str(meta.get("asset_kind") or meta.get("media_kind") or meta.get("download_kind") or meta.get("file_kind") or "").strip().lower()
    if raw in ("image", "jpg", "jpeg", "png", "webp", "avif"):
        return "image"
    if raw in ("video", "mp4", "mov", "webm", "m4v"):
        return "video"
    ext = os.path.splitext(str(file_path or ""))[1].lower().lstrip(".")
    if ext in ("jpg", "jpeg", "png", "webp", "avif"):
        return "image"
    if ext in ("mp4", "mov", "webm", "m4v"):
        return "video"
    return ""


def meta_asset_kind(meta, fallback_path=""):
    meta = meta if isinstance(meta, dict) else {}
    kind = detect_asset_kind(fallback_path, meta)
    if kind:
        return kind
    raw = " ".join(str(meta.get(k) or "") for k in ("source_url", "media_url", "download_prefix", "file_name"))
    low = raw.lower()
    if ".mp4" in low or "video" in low:
        return "video"
    if any(x in low for x in (".jpg", ".jpeg", ".png", ".webp", ".avif", "image")):
        return "image"
    return ""


def meta_media_index(meta):
    meta = meta if isinstance(meta, dict) else {}
    try:
        return int(float(meta.get("media_index") or 0))
    except Exception:
        return 0


def shortcode_asset_key(shortcode, asset_kind, media_index=0):
    shortcode = str(shortcode or "").strip()
    asset_kind = str(asset_kind or "").strip().lower()
    try:
        media_index = int(media_index or 0)
    except Exception:
        media_index = 0
    return "::".join([shortcode, asset_kind or "asset", str(media_index)]) if shortcode else ""


def normalize_hotkey_text(value):
    text = str(value or "ctrl+shift+1").strip().lower().replace("control", "ctrl")
    text = text.replace(" ", "")
    return text or "ctrl+shift+1"


def hotkey_parts(value):
    return [x for x in normalize_hotkey_text(value).split("+") if x]


def vk_for_hotkey_part(part):
    p = str(part or "").lower().strip()
    if p in ("ctrl", "control"):
        return 0x11
    if p == "shift":
        return 0x10
    if p == "alt":
        return 0x12
    if p in ("win", "meta"):
        return 0x5B
    if len(p) == 1 and p.isdigit():
        return ord(p)
    if len(p) == 1 and "a" <= p <= "z":
        return ord(p.upper())
    if p.startswith("f") and p[1:].isdigit():
        n = int(p[1:])
        if 1 <= n <= 24:
            return 0x70 + n - 1
    return 0


def resolve_item_file_paths(folder, item=None, files=None):
    folder = os.path.abspath(str(folder or ""))
    out = []
    def add(path_or_name):
        if not path_or_name:
            return
        v = str(path_or_name)
        path = v if os.path.isabs(v) else os.path.join(folder, v)
        if path not in out:
            out.append(path)
    if isinstance(item, dict):
        add(item.get("file_path"))
        add(item.get("file_name"))
        files_obj = item.get("files")
        if isinstance(files_obj, dict):
            for v in files_obj.values():
                if isinstance(v, dict):
                    add(v.get("file_path") or v.get("file_name"))
                else:
                    add(v)
    if isinstance(files, list):
        for f in files:
            add(f)
    return out


def asset_kind_from_ext(path_or_name):
    ext = os.path.splitext(str(path_or_name or ""))[1].lower().lstrip(".")
    if ext in ("mp4", "mov", "webm", "m4v"):
        return "video"
    if ext in ("jpg", "jpeg", "png", "webp", "avif"):
        return "image"
    return ""


def asset_path_matches_request(path_or_name, requested_asset_kind):
    requested = str(requested_asset_kind or "").strip().lower()
    if not requested:
        return True
    actual = asset_kind_from_ext(path_or_name)
    return bool(actual and actual == requested)


def enrich_local_duplicate_info(local, folder, requested_asset_kind=""):
    local = dict(local or {})
    item = local.get("item") if isinstance(local.get("item"), dict) else {}
    requested_asset_kind = str(requested_asset_kind or "").strip().lower()
    paths = resolve_item_file_paths(folder, item, local.get("files"))
    if requested_asset_kind:
        paths = [p for p in paths if asset_path_matches_request(p, requested_asset_kind)]
    existing = [p for p in paths if os.path.exists(p)]
    chosen = existing[0] if existing else (paths[0] if paths else "")
    local["file_paths"] = paths
    local["existing_file_paths"] = existing
    local["requested_asset_kind"] = requested_asset_kind
    # v104: local JSON hit alone is not enough to block.  A duplicate is only
    # confirmed when the requested asset kind/ext has an existing physical file.
    local["exists"] = bool(existing)
    if requested_asset_kind and not existing:
        local["asset_kind_mismatch_or_missing_file"] = True
        local["duplicate_confirmed"] = False
    else:
        local["duplicate_confirmed"] = bool(existing)
    if chosen:
        local["file_path"] = chosen
        local["file_name"] = os.path.basename(chosen)
        local["folder_path"] = os.path.dirname(chosen)
    if item:
        for k in ("shortcode", "asset_kind", "media_index", "rank_label", "rank_no", "filename_stem", "md5", "sha256"):
            if k in item and k not in local:
                local[k] = item.get(k)
    if requested_asset_kind:
        local["asset_kind"] = requested_asset_kind
    return local


def meta_rank_no(meta, local=None):
    """Return current list rank number from meta/local, accepting No001 strings."""
    meta = meta if isinstance(meta, dict) else {}
    local = local if isinstance(local, dict) else {}
    n = normalize_int(meta.get("rank_no") or local.get("rank_no"), 0)
    if n:
        return n
    for v in (meta.get("rank_label"), meta.get("rank"), local.get("rank_label"), local.get("rank")):
        m = re.search(r"No\s*0*(\d+)", str(v or ""), re.I)
        if m:
            return normalize_int(m.group(1), 0)
    return 0


def has_rank_no_in_name(path_or_name):
    return bool(re.search(r"(?:^|_)No\d{3}(?:_|\.)", os.path.basename(str(path_or_name or "")), re.I))


def ranked_legacy_name_for_path(path, meta, local=None):
    """Build ranked filename for a legacy local duplicate path that lacks _No000_."""
    meta = meta if isinstance(meta, dict) else {}
    local = local if isinstance(local, dict) else {}
    rank_no = meta_rank_no(meta, local)
    if not rank_no:
        return ""
    base = os.path.basename(str(path or ""))
    if not base or has_rank_no_in_name(base):
        return ""
    root, ext = os.path.splitext(base)
    shortcode = safe_path_part(meta.get("shortcode") or local.get("shortcode") or "", "")
    if not shortcode:
        return ""
    no = f"No{rank_no:03d}"
    # Current legacy shape is usually owner_uploadstamp_shortcode.ext.
    # Insert _No000_ immediately before the stable shortcode when possible.
    if root.endswith("_" + shortcode):
        return root[:-(len(shortcode) + 1)] + f"_{no}_" + shortcode + ext
    if shortcode in root:
        return re.sub(r"_?" + re.escape(shortcode) + r"$", f"_{no}_{shortcode}", root) + ext
    return f"{root}_{no}_{shortcode}{ext}"


def rename_file_if_possible(src, dst):
    try:
        src = os.path.abspath(str(src or ""))
        dst = os.path.abspath(str(dst or ""))
        if not src or not dst or src == dst:
            return src
        if not os.path.exists(src):
            return src
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        if os.path.exists(dst):
            # Do not overwrite a newer/correct file. Keep the ranked path as canonical.
            return dst
        os.replace(src, dst)
        return dst
    except Exception as e:
        vlog(f"legacy No filename rename failed src={src} dst={dst} error={e}")
        return src


def migrate_local_duplicate_rank_filename(data, meta, local):
    """v100: duplicate check may find an older file created before _No000_ was enforced.

    If the current list rank is known and a local duplicate path lacks _No000_, rename the
    physical file and its .md5 sidecar, then rewrite the local folder JSON reference so
    Ctrl+Shift+1 and future duplicate checks show the ranked canonical filename.
    """
    meta = meta if isinstance(meta, dict) else {}
    local = dict(local or {})
    if not meta_rank_no(meta, local):
        return local
    paths = local.get("existing_file_paths") if isinstance(local.get("existing_file_paths"), list) else []
    if not paths:
        paths = local.get("file_paths") if isinstance(local.get("file_paths"), list) else []
    changed = False
    renamed = []
    path_map = {}
    for src in list(paths):
        if not src or has_rank_no_in_name(src):
            renamed.append(src)
            continue
        new_name = ranked_legacy_name_for_path(src, meta, local)
        if not new_name:
            renamed.append(src)
            continue
        dst = os.path.join(os.path.dirname(os.path.abspath(src)), new_name)
        new_path = rename_file_if_possible(src, dst)
        if os.path.abspath(new_path) != os.path.abspath(src):
            changed = True
            path_map[os.path.abspath(src)] = os.path.abspath(new_path)
            old_md5 = sidecar_md5_path(src)
            new_md5 = sidecar_md5_path(new_path)
            if os.path.exists(old_md5) and os.path.abspath(old_md5) != os.path.abspath(new_md5):
                rename_file_if_possible(old_md5, new_md5)
        renamed.append(new_path)
    if renamed:
        local["file_paths"] = renamed
        existing = [p for p in renamed if os.path.exists(p)]
        local["existing_file_paths"] = existing
        chosen = existing[0] if existing else renamed[0]
        local["file_path"] = chosen
        local["file_name"] = os.path.basename(chosen)
        local["folder_path"] = os.path.dirname(chosen)
    if changed:
        try:
            json_path = str(local.get("json_path") or "")
            doc = read_json_file(json_path, {}) if json_path else {}
            items = doc.get("items") if isinstance(doc, dict) else None
            key = local.get("key") or local.get("shortcode_asset_key")
            item = local.get("item") if isinstance(local.get("item"), dict) else {}
            targets = []
            if isinstance(items, dict):
                if key and key in items:
                    targets.append(items[key])
                sc = str(meta.get("shortcode") or local.get("shortcode") or "").strip()
                kind = str(meta_asset_kind(meta, str(data.get("url") or data.get("prefix") or "")) or local.get("asset_kind") or "").lower()
                idx = meta_media_index(meta)
                for it in items.values():
                    if isinstance(it, dict) and it not in targets:
                        if sc and str(it.get("shortcode") or "").strip() == sc and str(it.get("asset_kind") or "").lower() == kind and meta_media_index(it) == idx:
                            targets.append(it)
            elif item:
                targets.append(item)
            for it in targets:
                old_fp = os.path.abspath(str(it.get("file_path") or "")) if it.get("file_path") else ""
                if old_fp in path_map:
                    it["file_path"] = path_map[old_fp]
                    it["file_name"] = os.path.basename(path_map[old_fp])
                elif it.get("file_name"):
                    old_abs = os.path.abspath(os.path.join(os.path.dirname(local.get("file_path") or ""), str(it.get("file_name"))))
                    if old_abs in path_map:
                        it["file_name"] = os.path.basename(path_map[old_abs])
                it["rank_no"] = meta_rank_no(meta, local)
                it["rank_label"] = f"No{meta_rank_no(meta, local):03d}"
                it["filename_stem"] = os.path.splitext(os.path.basename(local.get("file_path") or it.get("file_name") or ""))[0]
                files_obj = it.get("files")
                if isinstance(files_obj, dict):
                    for f in files_obj.values():
                        if isinstance(f, dict):
                            fp = os.path.abspath(str(f.get("file_path") or "")) if f.get("file_path") else ""
                            if fp in path_map:
                                f["file_path"] = path_map[fp]
                                f["file_name"] = os.path.basename(path_map[fp])
            # Replace legacy filenames in secondary indexes too; otherwise the next duplicate
            # lookup can still return the old non-No file list before reaching the updated item.
            old_to_new_name = {os.path.basename(k): os.path.basename(v) for k, v in path_map.items()}
            for index_name in ("items_by_shortcode", "items_by_shortcode_asset"):
                idx_obj = doc.get(index_name) if isinstance(doc, dict) else None
                if isinstance(idx_obj, dict):
                    for kk, vv in list(idx_obj.items()):
                        if isinstance(vv, list):
                            idx_obj[kk] = [old_to_new_name.get(str(x), str(x)) for x in vv]
                        elif isinstance(vv, str):
                            idx_obj[kk] = old_to_new_name.get(vv, vv)
            by_orig = doc.get("items_by_instagram_original_filename") if isinstance(doc, dict) else None
            if isinstance(by_orig, dict):
                for kk, vv in list(by_orig.items()):
                    if isinstance(vv, str):
                        by_orig[kk] = old_to_new_name.get(vv, vv)
            if json_path and isinstance(doc, dict):
                doc["updated_at"] = now_text()
                doc["legacy_rank_filename_migrated_at"] = now_text()
                write_json_file(json_path, doc)
            vlog(f"legacy No filename migrated paths={path_map}")
        except Exception as e:
            vlog(f"legacy No filename json update failed error={e}")
    return local


def update_existing_local_metadata(data, meta, local):
    """이미 다운로드된 shortcode 자산에 오늘 조회/정렬 메타만 추가한다."""
    data = data if isinstance(data, dict) else {}
    meta = meta if isinstance(meta, dict) else {}
    local = dict(local or {})
    json_path = str(local.get("json_path") or "")
    folder = expected_download_folder(data)
    now = now_text()
    result = {"metadata_updated": False, "updated_at": now}
    sort = meta.get("sort_snapshot") if isinstance(meta.get("sort_snapshot"), dict) else meta.get("sort") if isinstance(meta.get("sort"), dict) else {}
    snap = {
        "seen_at": now,
        "rank_label": str(meta.get("rank_label") or local.get("rank_label") or ""),
        "rank_no": normalize_int(meta.get("rank_no") or local.get("rank_no"), 0),
        "shortcode": str(meta.get("shortcode") or local.get("shortcode") or ""),
        "asset_kind": str(meta_asset_kind(meta, str(data.get("url") or data.get("prefix") or "")) or local.get("asset_kind") or ""),
        "media_index": meta_media_index(meta),
        "sort": sort,
        "note": "already_exists_metadata_refresh_no_download"
    }
    if json_path and os.path.exists(json_path):
        doc = read_json_file(json_path, {})
        items = doc.get("items") if isinstance(doc, dict) else None
        if isinstance(items, dict):
            keys = []
            k = local.get("key") or local.get("shortcode_asset_key")
            if k and k in items:
                keys.append(k)
            sc = str(meta.get("shortcode") or local.get("shortcode") or "").strip()
            kind = str(snap.get("asset_kind") or "").lower()
            idx = int(snap.get("media_index") or 0)
            for kk, it in items.items():
                if kk in keys or not isinstance(it, dict):
                    continue
                if sc and str(it.get("shortcode") or "").strip() == sc and str(it.get("asset_kind") or "").lower() == kind and meta_media_index(it) == idx:
                    keys.append(kk)
            for kk in keys[:4]:
                it = items.get(kk)
                if not isinstance(it, dict):
                    continue
                it["metadata_updated_at"] = now
                it["last_seen_at"] = now
                it["last_seen_rank_label"] = snap.get("rank_label", "")
                it["last_status"] = "duplicate_seen"
                it["duplicate_seen_count"] = int(it.get("duplicate_seen_count") or 0) + 1
                it["last_duplicate_seen_at"] = now
                it.setdefault("seen_history", [])
                it["seen_history"].append(snap)
                it["seen_history"] = it["seen_history"][-200:]
                result["metadata_updated"] = True
            if result["metadata_updated"]:
                doc["updated_at"] = now
                doc["last_metadata_refresh_at"] = now
                write_json_file(json_path, doc)
            result["json_path"] = json_path
            existing_paths = local.get("existing_file_paths") if isinstance(local.get("existing_file_paths"), list) else []
            report_folder = os.path.dirname(os.path.abspath(str(existing_paths[0]))) if existing_paths else folder
            result["report"] = generate_account_html_report(json_path, report_folder)
    # sidecar .md5가 있으면 해시 재계산 없이 조회 기록만 추가한다.
    paths = local.get("file_paths") if isinstance(local.get("file_paths"), list) else []
    for fp in paths[:4]:
        side = sidecar_md5_path(fp)
        if side and os.path.exists(side):
            doc = read_json_file(side, {})
            if not isinstance(doc, dict):
                doc = {}
            doc["metadata_updated_at"] = now
            doc.setdefault("seen_history", [])
            doc["seen_history"].append(snap)
            doc["seen_history"] = doc["seen_history"][-200:]
            write_json_file(side, doc)
            result["metadata_updated"] = True
            result.setdefault("sidecar_paths", []).append(side)
    return result


def file_hashes(path):
    md5 = hashlib.md5()
    sha256 = hashlib.sha256()
    size = 0
    with open(path, "rb") as f:
        while True:
            b = f.read(1024 * 1024)
            if not b:
                break
            size += len(b)
            md5.update(b)
            sha256.update(b)
    return md5.hexdigest(), sha256.hexdigest(), size


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


def write_json_file(path, data):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def generate_account_html_report(json_path, media_folder=""):
    """Generate the portable account JSON/index.htm report with explicit status."""
    try:
        try:
            from .ws8771_html_report import generate_account_report  # type: ignore
        except Exception:
            from ws8771_html_report import generate_account_report  # type: ignore
        return generate_account_report(str(json_path or ""), str(media_folder or ""))
    except Exception as exc:
        return {"ok": False, "error": "report_module_error", "detail": str(exc), "json_path": str(json_path or ""), "media_folder": str(media_folder or "")}


def script_dir():
    """Runtime data/config directory.

    v127: ws8771 runtime data/config/log는 D:/_St/WS8771 기준이다.
    IG_DOMAIN_DIR 또는 AIWK_IG_DOMAIN_DIR override는 호환용으로만 허용한다.
    AIWK_PC/domains/www.instagram.com 레거시는 제거 대상이다.
    """
    override = os.environ.get("IG_DOMAIN_DIR") or os.environ.get("AIWK_IG_DOMAIN_DIR")
    if override:
        try:
            path = os.path.abspath(str(override))
            if os.path.isdir(path):
                return path
        except Exception:
            pass
    return os.path.dirname(os.path.abspath(__file__))


def domain_local_path(data):
    data = data if isinstance(data, dict) else {}
    root = safe_path_part(data.get("root") or "Down")
    collector_id = safe_path_part(data.get("collector_id") or data.get("user_id") or RUNTIME_USER or default_runtime_user(), "local_pc")
    account = str(data.get("account") or data.get("ig_account_id") or "instagram").replace("\\", "/")
    account_parts = [safe_path_part(x, "") for x in account.split("/") if safe_path_part(x, "")]
    filename = safe_path_part(data.get("filename") or "down.txt", "down.txt")
    return os.path.join(script_dir(), "Down", root, collector_id, *account_parts, filename)


def expected_download_folder(data):
    data = data if isinstance(data, dict) else {}
    account_folder_merge = bool(data.get("account_folder_merge") or data.get("merge_account_folder") or (isinstance(data.get("meta"), dict) and data.get("meta", {}).get("account_folder_merge")))
    account = str(data.get("account") or "instagram").replace("\\", "/")
    account_parts = [] if account_folder_merge else [safe_path_part(x, "") for x in account.split("/") if safe_path_part(x, "")]
    base = derive_stdown_root(ST_ROOT) / "instagram"
    return str(base.joinpath(*account_parts)) if account_parts else str(base)


def relocate_download_to_expected_folder(file_path, data):
    """Move a completed Chrome download into the fixed _StDown/instagram account folder.

    When the Windows Save As edit control could not be rewritten, Chrome may still save
    under its profile Downloads directory. Finalization must converge on the same fixed
    storage location before hashes, JSON reports, and SQLite are updated.
    """
    source = Path(str(file_path or "")).expanduser().resolve()
    if not source.is_file():
        raise FileNotFoundError(str(source))
    target_dir = Path(expected_download_folder(data)).expanduser().resolve()
    target_dir.mkdir(parents=True, exist_ok=True)
    target = target_dir / source.name
    if os.path.normcase(str(source)) == os.path.normcase(str(target)):
        return str(source), False
    if target.exists():
        if source.stat().st_size == target.stat().st_size:
            source_md5, source_sha256, _ = file_hashes(str(source))
            target_md5, target_sha256, _ = file_hashes(str(target))
            if source_md5 == target_md5 and source_sha256 == target_sha256:
                source.unlink()
                return str(target), True
        raise FileExistsError(f"target_exists: {target}")
    shutil.move(str(source), str(target))
    return str(target), True



def final_folder_index_path(folder):
    """Return v57 parent-folder index JSON path."""
    folder = os.path.abspath(str(folder or ""))
    folder_name = os.path.basename(folder.rstrip("\\/")) or "instagram"
    parent = os.path.dirname(folder.rstrip("\\/")) or folder
    return os.path.join(parent, safe_path_part(folder_name, "instagram") + ".json")


def legacy_folder_index_path(folder):
    folder = os.path.abspath(str(folder or ""))
    folder_name = os.path.basename(folder.rstrip("\\/")) or "instagram"
    return os.path.join(folder, safe_path_part(folder_name, "instagram") + ".json")


def sidecar_md5_path(file_path):
    root, _ext = os.path.splitext(os.path.abspath(str(file_path or "")))
    return root + ".md5"


def write_sidecar_md5(file_path, meta, md5, sha256, size):
    """Write/merge one JSON .md5 sidecar for a jpg/mp4 file set with the same stem."""
    meta = meta if isinstance(meta, dict) else {}
    file_path = os.path.abspath(str(file_path or ""))
    asset_kind = detect_asset_kind(file_path, meta) or "file"
    sidecar = sidecar_md5_path(file_path)
    doc = read_json_file(sidecar, {})
    if not isinstance(doc, dict):
        doc = {}
    stem = os.path.splitext(os.path.basename(file_path))[0]
    doc.setdefault("version", 1)
    doc.setdefault("purpose", "instagram_local_duplicate_prevention_and_sort_history")
    doc["filename_stem"] = str(meta.get("filename_stem") or stem)
    doc["shortcode"] = str(meta.get("shortcode") or "")
    doc["rank_label"] = str(meta.get("rank_label") or "")
    doc["rank_meaning"] = str(meta.get("rank_meaning") or "sort_rank_at_collection_time")
    doc["collected_at"] = str(meta.get("collected_at") or "")
    doc["upload_at"] = str(meta.get("upload_at") or "")
    doc["first_loaded_at"] = str(meta.get("first_loaded_at") or meta.get("collected_at") or "")
    doc["first_loaded_stamp"] = str(meta.get("first_loaded_stamp") or "")
    doc["instagram_original_filename"] = str(meta.get("instagram_original_filename") or "")
    doc["instagram_original_filename_with_ext"] = str(meta.get("instagram_original_filename_with_ext") or "")
    doc["instagram_original_ext"] = str(meta.get("instagram_original_ext") or "")
    doc["asset_kind"] = str(meta.get("asset_kind") or asset_kind or "")
    sidecar_owner = str(meta.get("owner_username") or meta.get("ig_account_id") or meta.get("account_name") or "").strip().lstrip("@")
    doc["account_name"] = str(meta.get("account_name") or sidecar_owner or "")
    doc["owner_username"] = str(meta.get("owner_username") or sidecar_owner or "")
    doc["ig_account_id"] = str(meta.get("ig_account_id") or sidecar_owner or "")
    doc["profile_url"] = str(meta.get("profile_url") or (f"https://www.instagram.com/{sidecar_owner}/" if sidecar_owner else ""))
    doc["post_url"] = str(meta.get("post_url") or meta.get("media_url") or meta.get("page_url") or meta.get("current_url") or meta.get("target_url") or "")
    doc["source_url"] = str(meta.get("source_url") or "")
    doc["thumbnail_url"] = str(meta.get("thumbnail_url") or meta.get("image_url") or meta.get("cover_url") or "")
    doc["video_url"] = str(meta.get("video_url") or meta.get("mp4_url") or "")
    doc["rank_no"] = normalize_int(meta.get("rank_no"), 0)
    doc["media_index"] = meta_media_index(meta)
    doc["stable_identity"] = meta.get("stable_identity") if isinstance(meta.get("stable_identity"), dict) else {
        "priority": ["shortcode", "asset_kind", "media_index", "instagram_original_filename", "sha256", "md5", "media_id", "media_url", "filename"],
        "shortcode": str(meta.get("shortcode") or ""),
        "asset_kind": str(meta.get("asset_kind") or asset_kind or ""),
        "media_index": meta_media_index(meta),
        "media_id": str(meta.get("media_id") or meta.get("pk") or ""),
        "instagram_original_filename": str(meta.get("instagram_original_filename") or ""),
        "media_url": str(meta.get("media_url") or ""),
    }
    doc["updated_at"] = now_text()
    doc["sort_history_note"] = "append future page-load snapshots here; No rank is not a stable duplicate key"
    doc.setdefault("sort_history", [])
    sort = meta.get("sort_snapshot") if isinstance(meta.get("sort_snapshot"), dict) else meta.get("sort") if isinstance(meta.get("sort"), dict) else {}
    if sort:
        snap = dict(sort)
        snap.setdefault("captured_at", str(meta.get("collected_at") or now_text()))
        snap.setdefault("first_loaded_at", doc.get("first_loaded_at", ""))
        snap.setdefault("rank_label", doc.get("rank_label", ""))
        snap.setdefault("instagram_original_filename", doc.get("instagram_original_filename", ""))
        # v179a1: 같은 page-load snapshot이 mp4/jpg 저장 때 중복으로 append되지 않게 한다.
        def _sort_snap_key(x):
            x = x if isinstance(x, dict) else {}
            return (str(x.get("field") or ""), str(x.get("value") or ""), str(x.get("rank_label") or ""), str(x.get("captured_at") or ""), str(x.get("instagram_original_filename") or ""))
        existing_keys = {_sort_snap_key(x) for x in doc.get("sort_history", []) if isinstance(x, dict)}
        if _sort_snap_key(snap) not in existing_keys:
            doc["sort_history"].append(snap)
        doc["sort_history"] = doc["sort_history"][-200:]
    doc.setdefault("files", {})
    ext = os.path.splitext(file_path)[1].lower().lstrip(".") or asset_kind
    file_key = "mp4" if ext in ("mp4", "mov", "webm", "m4v") else "jpg" if ext in ("jpg", "jpeg", "png", "webp", "avif") else ext
    doc["files"][file_key] = {
        "file_name": os.path.basename(file_path),
        "file_path": file_path,
        "asset_kind": asset_kind,
        "md5": md5,
        "sha256": sha256,
        "size": int(size or 0),
        "updated_at": now_text(),
    }
    write_json_file(sidecar, doc)
    return sidecar, doc

def local_folder_json_check(data, meta):
    meta = meta if isinstance(meta, dict) else {}
    folder = expected_download_folder(data if isinstance(data, dict) else {})
    json_candidates = [final_folder_index_path(folder), legacy_folder_index_path(folder)]
    json_path = json_candidates[0]
    doc = {}
    for candidate in json_candidates:
        doc = read_json_file(candidate, {})
        if isinstance(doc, dict) and isinstance(doc.get("items"), dict):
            json_path = candidate
            break
    items = doc.get("items") if isinstance(doc, dict) else None
    if not isinstance(items, dict):
        return {"exists": False, "json_path": json_path, "checked_paths": json_candidates}
    shortcode = str(meta.get("shortcode") or "").strip()
    asset_kind = meta_asset_kind(meta, str(data.get("url") or data.get("prefix") or meta.get("file_name") or ""))
    media_index = meta_media_index(meta)
    sc_asset = shortcode_asset_key(shortcode, asset_kind, media_index)
    md5 = str(meta.get("md5") or "").strip().lower()
    sha256 = str(meta.get("sha256") or "").strip().lower()
    file_name = str(meta.get("file_name") or "").strip().lower()
    original_name = str(meta.get("instagram_original_filename") or "").strip().lower()
    original_name_with_ext = str(meta.get("instagram_original_filename_with_ext") or "").strip().lower()
    for k in [sc_asset, sha256, md5, file_name, original_name, original_name_with_ext]:
        if k and k in items:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_key", "json_path": json_path, "key": k, "item": items.get(k), "shortcode_asset_key": sc_asset}, folder, asset_kind)
    by_sc_asset = doc.get("items_by_shortcode_asset") if isinstance(doc, dict) else None
    if sc_asset and isinstance(by_sc_asset, dict) and by_sc_asset.get(sc_asset):
        file_list = by_sc_asset.get(sc_asset) or []
        return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_shortcode_asset", "json_path": json_path, "key": sc_asset, "files": file_list, "item": items.get(sc_asset) if isinstance(items, dict) else {}, "shortcode_asset_key": sc_asset}, folder, asset_kind)
    for k, item in items.items():
        if not isinstance(item, dict):
            continue
        item_sc = str(item.get("shortcode") or "").strip()
        item_kind = str(item.get("asset_kind") or "").strip().lower()
        item_idx = meta_media_index(item)
        if shortcode and item_sc == shortcode and (not asset_kind or item_kind == asset_kind) and item_idx == media_index:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_shortcode_asset", "json_path": json_path, "key": k, "item": item, "shortcode_asset_key": sc_asset}, folder, asset_kind)
        if sha256 and str(item.get("sha256") or "").lower() == sha256:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_sha256", "json_path": json_path, "key": k, "item": item}, folder, asset_kind)
        if md5 and str(item.get("md5") or "").lower() == md5:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_md5", "json_path": json_path, "key": k, "item": item}, folder, asset_kind)
        if file_name and str(item.get("file_name") or "").lower() == file_name:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_file_name", "json_path": json_path, "key": k, "item": item}, folder, asset_kind)
        if original_name and str(item.get("instagram_original_filename") or "").lower() == original_name:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_instagram_original_filename", "json_path": json_path, "key": k, "item": item}, folder, asset_kind)
        if original_name_with_ext and str(item.get("instagram_original_filename_with_ext") or "").lower() == original_name_with_ext:
            return enrich_local_duplicate_info({"exists": True, "match_by": "local_json_instagram_original_filename_with_ext", "json_path": json_path, "key": k, "item": item}, folder, asset_kind)
    return {"exists": False, "json_path": json_path, "shortcode_asset_key": sc_asset}


def yellow_api_call(cfg, mode, fields):
    """v72: 서버/yellow.php 호출은 host/ig_sort.py 역할로 분리한다."""
    try:
        try:
            from . import ws8771_ig_sort as ig_sort  # type: ignore
        except Exception:
            import ws8771_ig_sort as ig_sort  # type: ignore
        return ig_sort.call_api(cfg, mode, fields, logger=lambda msg: vlog(str(msg)))
    except Exception as e:
        vlog(f"ig_sort: 모듈 호출 실패 mode={mode} error={e}")
        return {"ok": False, "error": str(e), "error_code": "ig_sort_module_error"}


def meta_to_yellow_fields(meta, file_name="", folder_name="", md5="", sha256="", collector_id="", file_path="", file_size=0, asset_kind=""):
    meta = meta if isinstance(meta, dict) else {}
    sort = meta.get("sort") if isinstance(meta.get("sort"), dict) else {}
    account_name = str(meta.get("account_name") or meta.get("owner_username") or meta.get("ig_account_id") or folder_name or "").strip()
    ig_account_id = str(meta.get("ig_account_id") or meta.get("owner_username") or account_name or "").strip()
    collector_id = str(collector_id or meta.get("collector_id") or meta.get("user_id") or RUNTIME_USER or default_runtime_user()).strip()
    file_path = str(file_path or meta.get("file_path") or "").strip()
    save_folder = str(meta.get("save_folder") or meta.get("download_folder") or (os.path.dirname(file_path) if file_path else "")).strip()
    asset_kind = str(asset_kind or detect_asset_kind(file_path or file_name, meta)).strip()
    return {
        "collector_id": collector_id,
        "user_id": collector_id,
        "ig_account_id": ig_account_id,
        "shortcode": str(meta.get("shortcode") or "").strip(),
        "media_url": str(meta.get("media_url") or meta.get("current_url") or "").strip(),
        "account_name": account_name,
        "upload_at": str(meta.get("upload_at") or "").strip(),
        "thumbnail_url": str(meta.get("thumbnail_url") or meta.get("image_url") or meta.get("cover_url") or "").strip(),
        "video_url": str(meta.get("video_url") or meta.get("mp4_url") or "").strip(),
        "file_name": file_name or str(meta.get("file_name") or "").strip(),
        "file_path": file_path,
        "save_folder": save_folder,
        "folder_name": folder_name or str(meta.get("folder_name") or "").strip(),
        "asset_kind": asset_kind,
        "asset_status": str(meta.get("asset_status") or "done").strip(),
        "md5": md5,
        "sha256": sha256,
        "file_size": int(file_size or 0),
        "views": normalize_int(meta.get("views") or sort.get("views")),
        "likes": normalize_int(meta.get("likes") or sort.get("likes")),
        "comments": normalize_int(meta.get("comments") or sort.get("comments")),
        "reposts": normalize_int(meta.get("reposts") or sort.get("reposts")),
        "rank_no": normalize_int(meta.get("rank_no") or meta.get("rank"), 0),
        "rank_label": str(meta.get("rank_label") or "").strip(),
        "rank_meaning": str(meta.get("rank_meaning") or "sort_rank_at_collection_time").strip(),
        "collected_at": str(meta.get("collected_at") or sort.get("captured_at") or "").strip(),
        "filename_stem": str(meta.get("filename_stem") or os.path.splitext(str(file_name or meta.get("file_name") or ""))[0]).strip(),
        "media_index": meta_media_index(meta),
        "source_type": str(meta.get("source_type") or meta.get("page_type") or "").strip(),
        "target_url": str(meta.get("target_url") or meta.get("current_url") or meta.get("page_url") or "").strip(),
        "pass_id": str(meta.get("pass_id") or meta.get("snapshot_id") or meta.get("cycle_id") or "").strip(),
        "top_n": normalize_int(meta.get("top_n") or meta.get("rank_total"), 0),
        "threshold_value": normalize_int(meta.get("threshold_value") or meta.get("threshold"), 0),
        "threshold_label": str(meta.get("threshold_label") or "").strip(),
        "sort_field": str(meta.get("sort_field") or sort.get("field") or "").strip(),
        "sort_value": normalize_int(meta.get("sort_value") or sort.get("value"), 0),
    }



def upsert_folder_json(file_path, meta, md5, sha256, size):
    meta = meta if isinstance(meta, dict) else {}
    sort = meta.get("sort_snapshot") if isinstance(meta.get("sort_snapshot"), dict) else meta.get("sort") if isinstance(meta.get("sort"), dict) else {}
    folder = os.path.dirname(os.path.abspath(file_path))
    folder_name = os.path.basename(folder.rstrip("\\/")) or "instagram"
    json_path = final_folder_index_path(folder)
    data = read_json_file(json_path, {
        "version": 2,
        "purpose": "instagram_duplicate_download_prevention_and_sort_history",
        "layout": "parent_index_json__final_folder_media_set",
        "folder_name": folder_name,
        "items": {},
    })
    if "items" not in data or not isinstance(data.get("items"), dict):
        data["items"] = {}
    file_name = os.path.basename(file_path)
    owner_for_report = str(meta.get("owner_username") or meta.get("ig_account_id") or meta.get("account_name") or folder_name).strip().lstrip("@")
    profile_url = str(meta.get("profile_url") or (f"https://www.instagram.com/{owner_for_report}/" if owner_for_report else ""))
    post_url = canonical_instagram_post_url(
        str(meta.get("shortcode") or ""),
        meta.get("post_url"),
        meta.get("media_url"),
        meta.get("href"),
        meta.get("page_url"),
        meta.get("current_url"),
        meta.get("target_url"),
        fallback_kind="reel" if str(meta.get("video_url") or meta.get("mp4_url") or "").strip() else "p",
    )
    item = {
        "collector_id": str(meta.get("collector_id") or meta.get("user_id") or RUNTIME_USER or default_runtime_user()),
        "user_id": str(meta.get("collector_id") or meta.get("user_id") or RUNTIME_USER or default_runtime_user()),
        "ig_account_id": str(meta.get("ig_account_id") or meta.get("owner_username") or meta.get("account_name") or folder_name),
        "shortcode": str(meta.get("shortcode") or ""),
        "media_index": meta_media_index(meta),
        "media_url": str(meta.get("media_url") or ""),
        "account_name": str(meta.get("account_name") or meta.get("owner_username") or meta.get("ig_account_id") or folder_name),
        "owner_username": str(meta.get("owner_username") or ""),
        "profile_url": profile_url,
        "post_url": post_url,
        "source_url": str(meta.get("source_url") or ""),
        "thumbnail_url": str(meta.get("thumbnail_url") or meta.get("image_url") or meta.get("cover_url") or ""),
        "video_url": str(meta.get("video_url") or meta.get("mp4_url") or ""),
        "upload_at": str(meta.get("upload_at") or ""),
        "first_loaded_at": str(meta.get("first_loaded_at") or meta.get("collected_at") or ""),
        "first_loaded_stamp": str(meta.get("first_loaded_stamp") or ""),
        "instagram_original_filename": str(meta.get("instagram_original_filename") or ""),
        "instagram_original_filename_with_ext": str(meta.get("instagram_original_filename_with_ext") or ""),
        "instagram_original_ext": str(meta.get("instagram_original_ext") or ""),
        "stable_identity": meta.get("stable_identity") if isinstance(meta.get("stable_identity"), dict) else {},
        "file_name": file_name,
        "folder_name": folder_name,
        "md5": md5,
        "sha256": sha256,
        "asset_kind": detect_asset_kind(file_path, meta),
        "size": int(size or 0),
        "views": normalize_int(meta.get("views") or sort.get("views")),
        "likes": normalize_int(meta.get("likes") or sort.get("likes")),
        "comments": normalize_int(meta.get("comments") or sort.get("comments")),
        "reposts": normalize_int(meta.get("reposts") or sort.get("reposts")),
        "sort": sort,
        "sort_field": str(meta.get("sort_field") or sort.get("field") or ""),
        "sort_value": normalize_int(meta.get("sort_value") or sort.get("value"), 0),
        "account_folder_merge": bool(meta.get("account_folder_merge")),
        "rank_no": normalize_int(meta.get("rank_no"), 0),
        "rank_label": str(meta.get("rank_label") or ""),
        "rank_meaning": str(meta.get("rank_meaning") or "sort_rank_at_collection_time"),
        "collected_at": str(meta.get("collected_at") or ""),
        "filename_stem": str(meta.get("filename_stem") or os.path.splitext(file_name)[0]),
        "downloaded_at": now_text(),
        "download_status": "downloaded",
        "last_status": "downloaded",
        "duplicate_seen_count": 0,
    }
    key = shortcode_asset_key(item.get("shortcode"), item.get("asset_kind"), item.get("media_index")) or item["sha256"] or item["md5"] or file_name
    previous_item = data["items"].get(key) if isinstance(data.get("items"), dict) else None
    if isinstance(previous_item, dict):
        item["first_downloaded_at"] = str(previous_item.get("first_downloaded_at") or previous_item.get("downloaded_at") or item["downloaded_at"])
        item["duplicate_seen_count"] = int(previous_item.get("duplicate_seen_count") or 0)
        item["seen_history"] = previous_item.get("seen_history") if isinstance(previous_item.get("seen_history"), list) else []
    else:
        item["first_downloaded_at"] = item["downloaded_at"]
    data["version"] = 2
    data["purpose"] = "instagram_duplicate_download_prevention_and_sort_history"
    data["layout"] = "parent_index_json__final_folder_media_set"
    data["folder_name"] = folder_name
    data["updated_at"] = now_text()
    data["report"] = {
        "version": 2,
        "html_file": safe_path_part(folder_name, "instagram") + ".html",
        "legacy_html_file": safe_path_part(folder_name, "instagram") + "/index.htm",
        "portable_json_file": safe_path_part(folder_name, "instagram") + "/" + safe_path_part(folder_name, "instagram") + ".json",
        "echarts_file": safe_path_part(folder_name, "instagram") + "/echarts.min.js",
        "source_policy": "canonical_json_is_source_of_truth",
        "updated_at": data["updated_at"],
    }
    data["items"][key] = item
    data.setdefault("items_by_shortcode", {})
    if item["shortcode"]:
        data["items_by_shortcode"].setdefault(item["shortcode"], [])
        if file_name not in data["items_by_shortcode"][item["shortcode"]]:
            data["items_by_shortcode"][item["shortcode"]].append(file_name)
    data.setdefault("items_by_shortcode_asset", {})
    sc_asset_key = shortcode_asset_key(item.get("shortcode"), item.get("asset_kind"), item.get("media_index"))
    if sc_asset_key:
        data["items_by_shortcode_asset"].setdefault(sc_asset_key, [])
        if file_name not in data["items_by_shortcode_asset"][sc_asset_key]:
            data["items_by_shortcode_asset"][sc_asset_key].append(file_name)
    data.setdefault("items_by_instagram_original_filename", {})
    if item.get("instagram_original_filename"):
        data["items_by_instagram_original_filename"][item["instagram_original_filename"]] = file_name
    write_json_file(json_path, data)
    return json_path, item



# -----------------------------
# Minimal local WebSocket server
# -----------------------------
def _ws_recv_exact(conn, n):
    data = b""
    while len(data) < n:
        chunk = conn.recv(n - len(data))
        if not chunk:
            raise ConnectionError("socket_closed")
        data += chunk
    return data


def _ws_read_frame(conn):
    h = _ws_recv_exact(conn, 2)
    b1, b2 = h[0], h[1]
    opcode = b1 & 0x0F
    masked = bool(b2 & 0x80)
    length = b2 & 0x7F
    if length == 126:
        length = struct.unpack("!H", _ws_recv_exact(conn, 2))[0]
    elif length == 127:
        length = struct.unpack("!Q", _ws_recv_exact(conn, 8))[0]
    mask = _ws_recv_exact(conn, 4) if masked else b""
    payload = _ws_recv_exact(conn, length) if length else b""
    if masked:
        payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
    if opcode == 8:
        raise ConnectionError("client_close")
    if opcode not in (1, 2):
        return None
    return payload.decode("utf-8", errors="replace")


def _ws_send_text(conn, text):
    payload = text.encode("utf-8")
    header = bytearray([0x81])
    n = len(payload)
    if n < 126:
        header.append(n)
    elif n <= 0xFFFF:
        header.append(126)
        header.extend(struct.pack("!H", n))
    else:
        header.append(127)
        header.extend(struct.pack("!Q", n))
    conn.sendall(bytes(header) + payload)


class LocalControlWsServer:
    def __init__(self, host, port, request_handler, logger=None):
        self.host = host
        self.port = int(port)
        self.request_handler = request_handler
        self.log = logger or (lambda message: None)
        self._stop = threading.Event()
        self._sock = None
        self._thread = None

    def start(self):
        self.log(f"WS thread starting host={self.host} port={self.port}")
        self._thread = threading.Thread(target=self._run, name="auto-enter-control-ws", daemon=True)
        self._thread.start()

    def stop(self):
        self._stop.set()
        try:
            if self._sock:
                self._sock.close()
        except Exception:
            pass

    def _run(self):
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind((self.host, self.port))
            sock.listen(8)
            self._sock = sock
            self.log(f"WS listen OK ws://{self.host}:{self.port}")
            while not self._stop.is_set():
                try:
                    conn, addr = sock.accept()
                except OSError:
                    break
                self.log(f"WS client accepted addr={addr}")
                threading.Thread(target=self._handle_client, args=(conn, addr), daemon=True).start()
        except Exception:
            # GUI는 계속 살아 있어야 하므로 서버 오류는 콘솔에만 남긴다.
            print("[auto_enter_ws] server failed", flush=True)
            traceback.print_exc()

    def _handle_client(self, conn, addr):
        with conn:
            try:
                self.log(f"WS client connected addr={addr}")
                conn.settimeout(20)
                raw = b""
                while b"\r\n\r\n" not in raw and len(raw) < 16384:
                    chunk = conn.recv(1024)
                    if not chunk:
                        return
                    raw += chunk
                headers = raw.decode("latin1", errors="ignore").split("\r\n")
                key = ""
                for line in headers:
                    if line.lower().startswith("sec-websocket-key:"):
                        key = line.split(":", 1)[1].strip()
                        break
                if not key:
                    self.log(f"WS handshake failed: missing Sec-WebSocket-Key addr={addr}")
                    return
                accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()).decode("ascii")
                response = (
                    "HTTP/1.1 101 Switching Protocols\r\n"
                    "Upgrade: websocket\r\n"
                    "Connection: Upgrade\r\n"
                    f"Sec-WebSocket-Accept: {accept}\r\n\r\n"
                )
                conn.sendall(response.encode("ascii"))
                self.log(f"WS handshake OK addr={addr}")
                while not self._stop.is_set():
                    msg = _ws_read_frame(conn)
                    if msg is None:
                        continue
                    try:
                        req = json.loads(msg)
                    except Exception:
                        req = {"act": "bad_json", "raw": msg}
                    act = str(req.get("act", req.get("cmd", ""))) if isinstance(req, dict) else "bad_request"
                    t0 = time.time()
                    if isinstance(req, dict):
                        data_for_log = req.get("data") if isinstance(req.get("data"), dict) else {}
                        tab_for_log = req.get("tab_id") or data_for_log.get("tab_id") or data_for_log.get("request_tab_id") or data_for_log.get("target_tab_id") or ""
                        self.log(f"WS_IN act={act} tab_id={tab_for_log} rank={req.get('rank', data_for_log.get('rank',''))} shortcode={req.get('shortcode', data_for_log.get('shortcode',''))}")
                    else:
                        self.log(f"WS_IN act={act}")
                    resp = self.request_handler(req)
                    dt = int((time.time() - t0) * 1000)
                    if isinstance(resp, dict):
                        err_text = resp.get("error_code", "") or resp.get("error", "")
                        ok_text = resp.get("ok")
                    else:
                        err_text = ""
                        ok_text = ""
                    if isinstance(req, dict):
                        data_for_log = req.get("data") if isinstance(req.get("data"), dict) else {}
                        tab_for_log = req.get("tab_id") or data_for_log.get("tab_id") or data_for_log.get("request_tab_id") or data_for_log.get("target_tab_id") or ""
                        self.log(f"WS_OUT act={act} ok={ok_text} err={err_text} ms={dt} tab_id={tab_for_log}")
                    else:
                        self.log(f"WS_OUT act={act} ok={ok_text} err={err_text} ms={dt}")
                    _ws_send_text(conn, json.dumps(resp, ensure_ascii=False))
            except Exception as e:
                self.log(f"WS client closed/error addr={addr} error={e}")
                try:
                    _ws_send_text(conn, json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False))
                except Exception:
                    pass

# -----------------------------
# Tkinter UI
# -----------------------------
class FloatingApp:
    def __init__(self, collector_id=None, verbose=False):
        self.cfg = load_config()
        self.collector_id = safe_path_part(collector_id or RUNTIME_USER or default_runtime_user(), "local_pc")
        self.verbose = bool(verbose)
        self.acted = {}  # hwnd -> last action time
        self.control_queue = queue.Queue()
        self.ui_queue = queue.Queue()
        self.file_queue = queue.Queue()
        self.server_queue = queue.Queue()
        self.ws_job_queue = queue.Queue()
        self.thread_queue_guard = threading.RLock()
        self.thread_queue_results = {}
        self.thread_queue_seq = 0
        self.stop_workers = threading.Event()
        self.runtime_state_guard = threading.RLock()
        self.runtime_state = {}
        self.active_target_guard = threading.RLock()
        self.active_target = {"tab_id": 0, "cycle_id": "", "url": "", "updated_at_ms": 0, "source": ""}
        self.revoked_auto_tabs = {}
        self.current_job_guard = threading.RLock()
        self.current_job = {"type": "idle", "stage": "", "cycle_id": "", "started_at_ms": 0, "message": "대기"}
        self.control_server = None
        self.rtc_proc = None
        self.rtc_menu_win = None
        self.rtc_state_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "rtc8790_8791_state.json")
        self.drag_start_x = 0
        self.drag_start_y = 0
        self.drag_pointer_start_x = 0
        self.drag_pointer_start_y = 0
        self.drag_moved = False
        if not bool(self.cfg.get("tts_level_user_configured", False)):
            self.cfg["tts_level"] = 0
        self.tts_level = max(0, min(3, int(self.cfg.get("tts_level", 0) or 0)))
        self._tts_last = {}
        self._tts_proc = None
        self._intentional_shutdown_started = False
        self.last_media_state = self.cfg.get("last_media_state") if isinstance(self.cfg.get("last_media_state"), dict) else {}
        self.copy_hotkey = normalize_hotkey_text(self.cfg.get("copy_hotkey", "ctrl+shift+1"))
        self._copy_hotkey_down = False
        self.emergency_stop_hotkey = "ctrl+shift+f12"
        self._emergency_exit_hotkey_thread = None
        self._emergency_exit_started = False
        self.emergency_stop_requested = False
        self._ws_label_single_click_after = None
        self._fold_single_click_after = None
        self.auto_enter_until = 0.0
        self.auto_enter_reason = ""
        self.auto_enter_expected = {}
        self.cfg["enter_mode"] = self.normalize_enter_mode(self.cfg.get("enter_mode"))
        self.cfg["enabled"] = self.cfg["enter_mode"] != "off"

        self.root = tk.Tk()
        self.root.report_callback_exception = self.report_callback_exception
        self.root.title(APP_NAME)
        self.root.overrideredirect(True)
        self.root.attributes("-topmost", True)
        self.root.attributes("-alpha", float(self.cfg.get("alpha", 0.78)))
        try:
            self.root.resizable(True, False)
            self.root.minsize(34, int(self.cfg.get("ui_bar_height", 34) or 34))
        except Exception:
            pass
        self.root.configure(bg="#20242a")

        x = int(self.cfg.get("x", 80))
        y = int(self.cfg.get("y", 80))
        legacy_collapsed = bool(self.cfg.get("ui_collapsed", False))
        self.ui_collapse_stage = int(self.cfg.get("ui_collapse_stage", 2 if legacy_collapsed else 0) or 0)
        self.ui_collapse_stage = max(0, min(2, self.ui_collapse_stage))
        self.ui_collapsed = self.ui_collapse_stage >= 2
        self.ui_expanded_width = int(self.cfg.get("ui_expanded_width", 880) or 880)
        self.ui_compact_width = int(self.cfg.get("ui_compact_width", 260) or 260)
        self.ui_collapsed_width = int(self.cfg.get("ui_collapsed_width", 34) or 34)
        self.ui_bar_height = int(self.cfg.get("ui_bar_height", 34) or 34)
        width0, height0, x, y = clamp_tk_window_geometry(self.root, self.current_ui_width(), self.ui_bar_height, x, y, 34, self.ui_bar_height)
        self.root.geometry(f"{width0}x{height0}+{x}+{y}")
        vlog(f"APP start user={self.collector_id} config={CONFIG_FILE}")

        self.frame = tk.Frame(self.root, bg="#20242a", bd=1, relief="solid")
        self.frame.pack(fill="both", expand=True)
        for col, weight in ((0, 0), (1, 0), (2, 0), (3, 1), (4, 0), (5, 0), (6, 0), (7, 0)):
            self.frame.grid_columnconfigure(col, weight=weight)

        self.ws_label = tk.Label(
            self.frame,
            text=WS_LABEL,
            fg="#dbeafe",
            bg="#20242a",
            font=("맑은 고딕", 10, "bold"),
            anchor="center",
            padx=8,
            pady=4
        )

        self.rtc_btn = tk.Button(
            self.frame,
            text="🖥 RTC",
            command=self.open_rtc_menu,
            width=7,
            bd=0,
            bg="#1f4b5f",
            fg="white",
            activebackground="#0e7490",
            activeforeground="white"
        )

        self.status = tk.Label(
            self.frame,
            text="",
            fg="white",
            bg="#20242a",
            font=("맑은 고딕", 10, "bold"),
            anchor="w",
            padx=8,
            pady=4
        )

        self.toggle_btn = tk.Button(
            self.frame,
            text="ON/OFF",
            command=self.toggle_enabled,
            width=7,
            bd=0,
            bg="#3b82f6",
            fg="white",
            activebackground="#2563eb",
            activeforeground="white"
        )

        self.setting_btn = tk.Button(
            self.frame,
            text="설정",
            command=self.open_settings,
            width=5,
            bd=0,
            bg="#4b5563",
            fg="white",
            activebackground="#374151",
            activeforeground="white"
        )

        self.close_btn = tk.Button(
            self.frame,
            text="×",
            command=self.quit_app,
            width=3,
            bd=0,
            bg="#7f1d1d",
            fg="white",
            activebackground="#991b1b",
            activeforeground="white"
        )

        self.fold_btn = tk.Button(
            self.frame,
            text="_",
            width=3,
            bd=0,
            bg="#374151",
            fg="white",
            activebackground="#1f2937",
            activeforeground="white"
        )

        self.last_label = tk.Label(
            self.frame,
            text="대기 중",
            fg="#cbd5e1",
            bg="#20242a",
            font=("맑은 고딕", 9),
            anchor="w",
            padx=4,
            pady=4
        )

        for w in (self.frame, self.status, self.last_label):
            w.bind("<ButtonPress-1>", self.start_move)
            w.bind("<B1-Motion>", self.do_move)
            w.bind("<ButtonRelease-1>", self.end_move)
        self.ws_label.bind("<ButtonPress-1>", self.start_move)
        self.ws_label.bind("<B1-Motion>", self.do_move)
        self.ws_label.bind("<ButtonRelease-1>", self.ws_label_release)
        self.rtc_btn.bind("<ButtonPress-1>", self.start_move, add="+")
        self.rtc_btn.bind("<B1-Motion>", self.do_move, add="+")
        self.fold_btn.bind("<ButtonPress-1>", self.start_move)
        self.fold_btn.bind("<B1-Motion>", self.do_move)
        self.fold_btn.bind("<ButtonRelease-1>", self.fold_button_release)
        for copy_widget in (self.frame, self.ws_label, self.rtc_btn, self.status, self.last_label, self.fold_btn):
            try:
                copy_widget.bind("<Double-Button-1>", self.copy_status_double_click, add="+")
            except Exception:
                pass

        self.root.bind("<F8>", lambda e: self.toggle_enabled())
        self.root.bind("<Escape>", lambda e: self.quit_app())
        self.root.bind("<Configure>", self.on_root_configure, add="+")

        self.apply_ui_layout(save=False)
        self.update_status()
        self.start_workers()
        self.start_control_ws_if_enabled()
        self.root.after(80, self.process_control_queue)
        self.root.after(90, self.process_ui_queue)
        self.root.after(int(self.cfg.get("poll_ms", 250)), self.scan_loop)
        self.root.after(160, self.copy_hotkey_loop)
        self.start_emergency_exit_hotkey()

    def start_workers(self):
        """Start only file, server-sync and generic WS job workers."""
        self.worker_log("auto", "무마우스 구조: file/ig_sort/ws 작업 스레드만 시작합니다.")
        self.file_thread = threading.Thread(target=self.file_worker_loop, name="igblue-file-worker", daemon=True)
        self.server_thread = threading.Thread(target=self.server_worker_loop, name="igblue-ig-sort-worker", daemon=True)
        self.ws_job_thread = threading.Thread(target=self.ws_job_worker_loop, name="igblue-ws-job-worker", daemon=True)
        self.file_thread.start()
        self.server_thread.start()
        self.ws_job_thread.start()


    def project_progress_check(self, feature_name, check_id, status, message_ko, detail=None):
        feature = str(feature_name or "프로젝트").strip() or "프로젝트"
        check = str(check_id or "CHECK").strip() or "CHECK"
        state = str(status or "[체크완료]").strip() or "[체크완료]"
        message = str(message_ko or "").strip()
        detail_doc = detail if isinstance(detail, dict) else {}
        cycle_id = str(detail_doc.get("cycle_id") or "")
        asset_kind = str(detail_doc.get("asset_kind") or "")
        key = (cycle_id, check, state, asset_kind)
        seen = getattr(self, "_project_progress_seen", None)
        if not isinstance(seen, set):
            seen = set()
            self._project_progress_seen = seen
        if key in seen:
            return {"ok": True, "deduplicated": True}
        seen.add(key)
        if len(seen) > 3000:
            self._project_progress_seen = set(list(seen)[-1500:])

        context = project_log_context_snapshot()
        core_parts = []
        for label, value in (
            ("cycle", cycle_id),
            ("대상", detail_doc.get("rank_label") or detail_doc.get("rank_no")),
            ("계정", detail_doc.get("owner_username") or detail_doc.get("account_queue_username")),
            ("shortcode", detail_doc.get("shortcode")),
            ("자산", asset_kind),
        ):
            if value not in (None, "", 0, False):
                core_parts.append(f"{label}={value}")
        suffix = (" / " + " ".join(core_parts)) if core_parts else ""
        judgement = f"[{feature}][{check}]{state} {message}{suffix}"

        failure_path = ""
        keyword = ""
        failed_map = getattr(self, "_project_failed_checks", None)
        if not isinstance(failed_map, dict):
            failed_map = {}
            self._project_failed_checks = failed_map

        if feature == "자동다운로드" and check == "AD-06" and state == "[체크완료]" and cycle_id:
            recovered = [key for key in failed_map if key[0] == cycle_id]
            for failed_key in recovered:
                failed_check = failed_key[1]
                _append_project_progress_line(
                    f"[자동다운로드][{failed_check}][복구완료] 후속 파일 저장 완료로 이전 실패 상태 해제 / cycle={cycle_id} / 다음단계=진행가능"
                )
                failed_map.pop(failed_key, None)

        if state == "[체크실패]":
            blocking = detail_doc.get("blocking") is not False
            if blocking and cycle_id:
                failed_map[(cycle_id, check)] = dict(detail_doc)
            keyword = _project_failure_keyword(check)
            safe_detail = {
                "실행ID": context.get("run_id", ""),
                "기능": feature,
                "체크ID": check,
                "상태": state,
                "내용": message,
                "cycle_id": cycle_id,
                "run_seq": detail_doc.get("run_seq", ""),
                "rank_label": detail_doc.get("rank_label", ""),
                "owner_username": detail_doc.get("owner_username", ""),
                "shortcode": detail_doc.get("shortcode", ""),
                "asset_kind": asset_kind,
                "error_code": detail_doc.get("error_code", ""),
                "http_status": detail_doc.get("http_status", ""),
                "error_stage": detail_doc.get("error_stage", ""),
                "server_error": detail_doc.get("server_error", ""),
                "server_message": detail_doc.get("server_message", ""),
                "table": detail_doc.get("table", ""),
                "missing_columns": detail_doc.get("missing_columns", ""),
                "next_check": detail_doc.get("next_check", ""),
                "cycle_stage": detail_doc.get("cycle_stage", ""),
                "cycle_reason": detail_doc.get("cycle_reason", ""),
                "stop_reason_ko": detail_doc.get("stop_reason_ko", ""),
                "blocking": blocking,
            }
            _write_run_detail_marker(keyword, check, safe_detail)
            failure_path = _write_project_failure_excerpt(check, keyword, judgement, safe_detail)
            next_step = "중단" if blocking else "로컬처리계속·실패항목별도추적"
            judgement += (
                f" / 다음단계={next_step} / 상세로그={context.get('run_log','')}"
                f" / 검색어=*** 상세로그 *** {keyword}"
                f" / 실패로그={failure_path}"
            )
        elif state in ("[체크완료]", "[복구완료]"):
            failed_map.pop((cycle_id, check), None)
            judgement += " / 다음단계=진행가능"

        _append_project_progress_line(judgement)
        return {
            "ok": True,
            "project_progress_log": context.get("project_progress_log", ""),
            "run_log": context.get("run_log", ""),
            "failure_log": failure_path,
            "search_keyword": keyword,
        }

    def worker_log(self, who, message):
        who = str(who or "log").strip() or "log"
        msg = f"{who}: {message}"
        try:
            now_mono = time.monotonic()
            key = re.sub(r"\d{1,2}시\s*\d{1,2}분\s*\d{1,2}초\s*", "", msg).strip()
            repeat_map = getattr(self, "_worker_log_repeat_map", {})
            last_at = float(repeat_map.get(key, 0.0) or 0.0)
            if key in repeat_map and now_mono - last_at < 12.0:
                return
            repeat_map[key] = now_mono
            self._worker_log_repeat_map = repeat_map
        except Exception:
            pass
        try:
            vlog(msg)
        except Exception:
            pass
        try:
            igblue_append_jsonl("thread_korean_log.jsonl", {"ts": now_text(), "who": who, "message": str(message or "")})
        except Exception:
            pass
        try:
            self.ui_queue.put(msg)
        except Exception:
            pass

    def process_ui_queue(self):
        last = None
        while True:
            try:
                last = self.ui_queue.get_nowait()
            except queue.Empty:
                break
        if last:
            try:
                self.ui_note(str(last))
            except Exception:
                pass
        try:
            self.root.after(90, self.process_ui_queue)
        except Exception:
            pass


    def persist_last_media_state(self, state=None):
        """v125: 최근 미디어 상태는 자동화 중 파일 저장하지 않고 메모리에만 보존한다."""
        try:
            st = dict(state if isinstance(state, dict) else getattr(self, "last_media_state", {}) if isinstance(getattr(self, "last_media_state", {}), dict) else {})
            if not st:
                return
            self.last_media_state = st
            self.set_runtime_state("last_media_state", st)
        except Exception:
            pass

    def remember_download_paths(self, file_path="", json_path="", save=True):
        """최근 다운로드 폴더/folder.json 경로를 WS 설정에 기억한다."""
        try:
            fp = os.path.abspath(str(file_path or "")) if file_path else ""
            jp = os.path.abspath(str(json_path or "")) if json_path else ""
            folder = os.path.dirname(fp) if fp else ""
            changed = False
            updates = {
                "last_download_file_path": fp,
                "last_download_json_path": jp,
                "last_media_folder": folder,
                "last_folder_json": jp,
            }
            for k, v in updates.items():
                if v and self.cfg.get(k) != v:
                    self.cfg[k] = v
                    changed = True
            if changed and save:
                save_config(self.cfg)
            if folder or jp:
                self.set_runtime_state("last_folder_paths", {"media_folder": folder, "folder_json": jp, "file_path": fp, "updated_at": now_text()})
            return {"media_folder": folder, "folder_json": jp, "file_path": fp, "changed": changed}
        except Exception as e:
            return {"error": str(e)}

    def last_media_state_for_display(self):
        try:
            media = getattr(self, "last_media_state", {}) if isinstance(getattr(self, "last_media_state", {}), dict) else {}
            cfg_media = self.cfg.get("last_media_state") if isinstance(self.cfg.get("last_media_state"), dict) else {}
            out = dict(cfg_media)
            out.update({k:v for k,v in media.items() if v not in (None, "", [], {})})
            # v106: duplicate/server-check/cycle states must not inherit a stale previous file path.
            # That confused Ctrl+Shift+1 logs by showing an old shortcode file as the current item.
            state = str(out.get("state") or "").strip().lower()
            if not out.get("file_path") and self.cfg.get("last_download_file_path") and state not in ("duplicate_checked", "cycle_state"):
                out["file_path"] = self.cfg.get("last_download_file_path")
            if not out.get("json_path") and self.cfg.get("last_download_json_path"):
                out["json_path"] = self.cfg.get("last_download_json_path")
            if not out.get("href") and self.cfg.get("last_instagram_url"):
                out["href"] = self.cfg.get("last_instagram_url")
            return out
        except Exception:
            return {}

    def hotkey_pressed(self, hotkey=None):
        try:
            parts = hotkey_parts(hotkey or getattr(self, "copy_hotkey", "ctrl+shift+1"))
            if not parts:
                return False
            for part in parts:
                vk = vk_for_hotkey_part(part)
                if not vk:
                    return False
                if not (user32.GetAsyncKeyState(vk) & 0x8000):
                    return False
            return True
        except Exception:
            return False


    def request_emergency_stop(self, trigger="global_hotkey"):
        self.emergency_stop_requested = True
        state = {
            "ok": True,
            "state": "emergency_stop_requested",
            "trigger": str(trigger or "global_hotkey"),
            "hotkey": self.emergency_stop_hotkey,
            "requested_at": now_text(),
            "ws_build_version": WS_BUILD_VERSION,
            "ws_build_tag": WS_BUILD_TAG,
        }
        self.set_runtime_state("emergency_stop", state)
        self.worker_log("stop", f"EMERGENCY_STOP trigger={state['trigger']} hotkey={self.emergency_stop_hotkey}")
        self.ui_note(f"긴급 중단: {self.emergency_stop_hotkey}")
        return state

    def force_exit_ws_process(self, trigger="global_hotkey"):
        """Ctrl+Shift+F12 전용: 상태 정리 대기 없이 WS 자기 프로세스를 즉시 종료한다."""
        trigger = str(trigger or "global_hotkey")
        message = (
            f"EMERGENCY_PROCESS_EXIT trigger={trigger} "
            f"hotkey={self.emergency_stop_hotkey} pid={os.getpid()} "
            f"ws_build={WS_BUILD_VERSION}"
        )
        try:
            print(f"[{now_text()}] {message}", flush=True)
        except Exception:
            pass
        try:
            self.worker_log("stop", message)
        except Exception:
            pass
        try:
            _close_python_file_logs()
        except Exception:
            pass
        os._exit(0)

    def emergency_exit_hotkey_thread_loop(self):
        """Windows 전역 hotkey를 메시지 루프로 받아 즉시 WS 프로세스를 종료한다."""
        hotkey_id = 0x8771
        mod_control = 0x0002
        mod_shift = 0x0004
        wm_hotkey = 0x0312
        vk_f12 = vk_for_hotkey_part("f12")
        registered = False
        try:
            registered = bool(user32.RegisterHotKey(None, hotkey_id, mod_control | mod_shift, vk_f12))
        except Exception as e:
            try:
                print(f"[{now_text()}] HOTKEY_REGISTER_ERROR hotkey={self.emergency_stop_hotkey} error={e}", flush=True)
            except Exception:
                pass

        if registered:
            try:
                print(f"[{now_text()}] HOTKEY_REGISTER_OK hotkey={self.emergency_stop_hotkey} mode=RegisterHotKey", flush=True)
            except Exception:
                pass
            msg = wintypes.MSG()
            try:
                while True:
                    result = int(user32.GetMessageW(ctypes.byref(msg), None, 0, 0))
                    if result <= 0:
                        break
                    if int(msg.message) == wm_hotkey and int(msg.wParam) == hotkey_id:
                        self.force_exit_ws_process("global_hotkey")
            finally:
                try:
                    user32.UnregisterHotKey(None, hotkey_id)
                except Exception:
                    pass
            return

        # 다른 프로그램이 전역 등록을 선점했을 때만 저수준 키 상태 감지를 대체 경로로 사용한다.
        try:
            print(f"[{now_text()}] HOTKEY_REGISTER_FAIL hotkey={self.emergency_stop_hotkey} fallback=GetAsyncKeyState", flush=True)
        except Exception:
            pass
        was_down = False
        while True:
            try:
                down = bool(self.hotkey_pressed(self.emergency_stop_hotkey))
                if down and not was_down:
                    self.force_exit_ws_process("global_hotkey_fallback")
                was_down = down
            except Exception as e:
                try:
                    print(f"[{now_text()}] HOTKEY_FALLBACK_ERROR hotkey={self.emergency_stop_hotkey} error={e}", flush=True)
                except Exception:
                    pass
            time.sleep(0.05)

    def start_emergency_exit_hotkey(self):
        if bool(getattr(self, "_emergency_exit_started", False)):
            return
        self._emergency_exit_started = True
        self._emergency_exit_hotkey_thread = threading.Thread(
            target=self.emergency_exit_hotkey_thread_loop,
            name="ws8771-emergency-exit-hotkey",
            daemon=True,
        )
        self._emergency_exit_hotkey_thread.start()

    def build_clipboard_status_text(self):
        media = self.last_media_state_for_display()
        ext_tag = str(media.get("build_tag", "") or "")
        bg_tag = str(media.get("bg_build_tag", "") or media.get("bg_version", "") or "")
        lines = [
            f"ts={now_text()}",
            f"app={APP_NAME}",
            f"ws_build_version={WS_BUILD_VERSION}",
            f"ws_build_tag={WS_BUILD_TAG}",
            f"ext_build_version={media.get('build_version','')}",
            f"ext_build_tag={ext_tag}",
            f"bg_version={bg_tag}",
            f"version_mismatch={bool(ext_tag and bg_tag and ext_tag != bg_tag)}",
            f"state={media.get('state','')}",
            f"instagram_url={media.get('href','') or media.get('instagram_url','')}",
            f"rank={media.get('rank_label','') or media.get('rank_no','')}",
            f"shortcode={media.get('shortcode','')}",
            f"asset_kind={media.get('asset_kind','')}",
            f"media_index={media.get('media_index','')}",
            f"file_path={media.get('file_path','')}",
            f"file_saved={media.get('file_saved','')}",
            f"json_path={media.get('json_path','')}",
            f"json_saved={media.get('json_saved','')}",
            f"report_ok={media.get('report_ok','')}",
            f"report_saved={media.get('report_saved','')}",
            f"report_path={media.get('report_path','')}",
            f"report_error={media.get('report_error','')}",
            f"cycle_id={media.get('cycle_id','')}",
            f"cycle_stage={media.get('cycle_stage','')}",
            f"cycle_decision={media.get('cycle_decision','')}",
            f"cycle_action={media.get('cycle_action','')}",
            f"cycle_reason={media.get('cycle_reason','')}",
        ]
        cycle_log = str(media.get("cycle_log_text") or "").strip()
        if cycle_log:
            lines.append("cycle_log_begin")
            lines.extend(cycle_log.splitlines()[-40:])
            lines.append("cycle_log_end")
        return "\n".join(lines)


    def copy_current_status_to_clipboard(self, reason="hotkey"):
        try:
            text = self.build_clipboard_status_text()
            self.root.clipboard_clear()
            self.root.clipboard_append(text)
            self.root.update()
            self.ui_note(f"저장 상태 클립보드 복사 완료 ({self.copy_hotkey})")
            self.py_state("clipboard_copied", "저장 상태를 클립보드에 복사했습니다", level="info", tts_level=2, reason=reason)
            return {"ok": True, "text": text, "hotkey": self.copy_hotkey}
        except Exception as e:
            self.ui_note(f"클립보드 복사 실패: {e}")
            return {"ok": False, "error": str(e)}

    def copy_hotkey_loop(self):
        try:
            down = self.hotkey_pressed()
            if down and not bool(getattr(self, "_copy_hotkey_down", False)):
                self._copy_hotkey_down = True
                self.copy_current_status_to_clipboard("hotkey")
            elif not down:
                self._copy_hotkey_down = False
        except Exception:
            pass
        try:
            self.root.after(120, self.copy_hotkey_loop)
        except Exception:
            pass

    def stop_tts(self):
        """현재 py가 실행한 Windows TTS 프로세스가 있으면 즉시 중단한다."""
        try:
            proc = getattr(self, "_tts_proc", None)
            if proc is not None and getattr(proc, "poll", lambda: None)() is None:
                try:
                    proc.terminate()
                except Exception:
                    try:
                        proc.kill()
                    except Exception:
                        pass
        except Exception:
            pass
        try:
            self._tts_proc = None
        except Exception:
            pass

    def tts(self, text, level=1):
        """Windows 기본 TTS. pip 설치 없이 PowerShell/SAPI를 사용한다.

        PY 설정창의 tts_level을 따른다. 0이면 무음, 1/2/3은 중요/진행/디버그.
        WSL/Windows 양쪽을 고려해 powershell.exe, powershell, pwsh 순서로 찾는다.
        """
        try:
            lvl = int(getattr(self, "tts_level", 0) or 0)
            if lvl <= 0:
                self.stop_tts()
                return
            if lvl < int(level or 1):
                return
            msg = str(text or "").replace("\r", " ").replace("\n", " ").strip()[:90]
            if not msg:
                return
            now = time.time()
            last = getattr(self, "_tts_last", {}).get(msg, 0)
            if now - last < 5.0:
                return
            self._tts_last[msg] = now
            exe = None
            for cand in ("powershell.exe", "powershell", "pwsh.exe", "pwsh"):
                path = shutil.which(cand)
                if path:
                    exe = path
                    break
            if not exe:
                self.worker_log("tts", "PowerShell TTS 실행 파일을 찾지 못했다. pip 설치는 필요없지만 Windows PowerShell이 필요하다.")
                return
            safe_msg = msg.replace("'", "''")
            voice_gender = str((self.cfg.get("tts_voice_gender") if isinstance(self.cfg, dict) else "male") or "male").strip().lower()
            gender_ps = "Male" if voice_gender in ("male", "man", "남자", "m") else "Female" if voice_gender in ("female", "woman", "여자", "f") else ""
            select_voice = ""
            if gender_ps:
                select_voice = "$v=$s.GetInstalledVoices()|ForEach-Object{$_.VoiceInfo}|Where-Object{$_.Gender -eq '" + gender_ps + "'}|Select-Object -First 1; if($v){$s.SelectVoice($v.Name)}; "
            ps = (
                "Add-Type -AssemblyName System.Speech; "
                "$s=New-Object System.Speech.Synthesis.SpeechSynthesizer; "
                + select_voice
                + "$s.Rate=0; "
                + "$s.Speak('" + safe_msg + "')"
            )
            encoded = base64.b64encode(ps.encode("utf-16le")).decode("ascii")
            cmd = [exe, "-NoProfile", "-EncodedCommand", encoded]
            # Windows PowerShell은 -WindowStyle을 지원하지만 pwsh/WSL 조합에서는 문제가 될 수 있어 생략한다.
            self.stop_tts()
            self._tts_proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        except Exception as e:
            try:
                self.worker_log("tts", f"TTS 실패 {e}")
            except Exception:
                pass

    def set_runtime_state(self, key, doc):
        try:
            data = _igblue_memory_copy(doc if isinstance(doc, dict) else {"value": doc})
            with self.runtime_state_guard:
                self.runtime_state[str(key or "runtime")] = data
            igblue_runtime_set(key, data)
            return data
        except Exception:
            return doc if isinstance(doc, dict) else {}

    def get_runtime_state_snapshot(self):
        try:
            with self.runtime_state_guard:
                local = _igblue_memory_copy(self.runtime_state)
            global_state = igblue_runtime_get()
            if isinstance(global_state, dict):
                global_state.update(local if isinstance(local, dict) else {})
                return global_state
            return local if isinstance(local, dict) else {}
        except Exception:
            return {}


    def py_state(self, state, message, level="info", tts_level=1, **extra):
        doc = {"ok": state not in ("coord_sync_fail", "position_not_found", "click_blocked", "auto_stopped"), "state": state, "message": str(message or state), "level": level, "ts": now_text(), "tts_level": tts_level}
        doc.update(extra or {})
        try:
            self.set_runtime_state("latest_py_state", doc)
            igblue_append_jsonl("ig_rtc_py_state.jsonl", doc)
        except Exception:
            pass
        try:
            self.worker_log("state", f"{state}: {message}")
        except Exception:
            pass
        self.tts(message, tts_level)
        return doc

    def thread_queue_mode_config(self):
        cfg = self.cfg.get("thread_queue_mode") if isinstance(self.cfg.get("thread_queue_mode"), dict) else {}
        return cfg if isinstance(cfg, dict) else {}

    def thread_queue_mode_enabled_for(self, req, act):
        try:
            if not isinstance(req, dict) or req.get("__thread_queue_worker"):
                return False
            cfg = self.thread_queue_mode_config()
            data = req.get("data") if isinstance(req.get("data"), dict) else {}
            enabled = bool(cfg.get("enabled", False) or data.get("thread_queue_mode") is True or req.get("thread_queue_mode") is True)
            if not enabled:
                return False
            actions = cfg.get("actions") if isinstance(cfg.get("actions"), list) else []
            return str(act or "") in {str(x) for x in actions}
        except Exception:
            return False


    def _next_thread_queue_job_id(self, act):
        with self.thread_queue_guard:
            self.thread_queue_seq += 1
            return f"{WS_BUILD_TAG}_{int(time.time() * 1000)}_{self.thread_queue_seq}_{str(act or 'job')}"

    def _thread_queue_ttl_sec(self):
        try:
            cfg = self.thread_queue_mode_config()
            return max(30.0, min(3600.0, float(cfg.get("result_ttl_sec", 180) or 180)))
        except Exception:
            return 180.0

    def cleanup_thread_queue_results(self):
        try:
            ttl = self._thread_queue_ttl_sec()
            now = time.time()
            with self.thread_queue_guard:
                old_keys = [
                    k for k, v in self.thread_queue_results.items()
                    if bool(v.get("done")) and now - float(v.get("updated_at", v.get("created_at", now)) or now) > ttl
                ]
                for k in old_keys:
                    self.thread_queue_results.pop(k, None)
        except Exception:
            pass

    def store_thread_queue_result(self, job_id, status="done", result=None, error=""):
        if not job_id:
            return {}
        doc = {
            "ok": bool(status == "done" and (not isinstance(result, dict) or result.get("ok", True) is not False)),
            "job_id": str(job_id),
            "status": str(status or "done"),
            "done": str(status or "done") in ("done", "error"),
            "result": result if isinstance(result, dict) else ({"value": result} if result is not None else {}),
            "error": str(error or ""),
            "updated_at": time.time(),
            "updated_at_text": now_text(),
        }
        with self.thread_queue_guard:
            old = self.thread_queue_results.get(str(job_id)) or {}
            old.update(doc)
            self.thread_queue_results[str(job_id)] = old
        return doc

    def enqueue_thread_queue_request(self, act, req):
        job_id = self._next_thread_queue_job_id(act)
        req_copy = json.loads(json.dumps(req, ensure_ascii=False, default=str)) if isinstance(req, dict) else {"act": act}
        req_copy["__thread_queue_worker"] = True
        doc = {
            "job_id": job_id,
            "act": str(act or ""),
            "status": "queued",
            "done": False,
            "created_at": time.time(),
            "created_at_text": now_text(),
            "updated_at": time.time(),
            "result": {},
            "error": "",
        }
        with self.thread_queue_guard:
            self.thread_queue_results[job_id] = doc
        self.ws_job_queue.put({"job_id": job_id, "act": str(act or ""), "req": req_copy})
        try:
            qsize = self.ws_job_queue.qsize()
        except Exception:
            qsize = -1
        self.worker_log("ws_queue", f"접수 job_id={job_id} act={act} qsize={qsize}")
        return {"ok": True, "queued": True, "thread_queue_mode": True, "job_id": job_id, "act": act, "state": "queued", "qsize": qsize, "poll_act": "ig_thread_queue_result"}

    def handle_thread_queue_result(self, req):
        data = req.get("data") if isinstance(req, dict) and isinstance(req.get("data"), dict) else {}
        job_id = str(data.get("job_id") or req.get("job_id") or "").strip()
        self.cleanup_thread_queue_results()
        if not job_id:
            return {"ok": False, "error": "job_id_empty", "act": "ig_thread_queue_result"}
        with self.thread_queue_guard:
            doc = dict(self.thread_queue_results.get(job_id) or {})
        if not doc:
            return {"ok": False, "error": "job_not_found", "job_id": job_id, "act": "ig_thread_queue_result"}
        return {"ok": True, "act": "ig_thread_queue_result", "job_id": job_id, "status": doc.get("status", "queued"), "done": bool(doc.get("done")), "result": doc.get("result") or {}, "error": doc.get("error", "")}

    def ws_job_worker_loop(self):
        self.worker_log("ws_queue", "WS job 큐 스레드가 준비됐다. 요청 접수와 실제 처리를 분리한다.")
        while not self.stop_workers.is_set():
            try:
                job = self.ws_job_queue.get(timeout=0.25)
            except queue.Empty:
                continue
            job_id = str(job.get("job_id") or "") if isinstance(job, dict) else ""
            act = str(job.get("act") or "") if isinstance(job, dict) else ""
            req = job.get("req") if isinstance(job, dict) and isinstance(job.get("req"), dict) else {"act": act, "__thread_queue_worker": True}
            try:
                self.store_thread_queue_result(job_id, "running", {"ok": True, "act": act, "state": "running"})
                res = self.handle_control_request(req)
                if not isinstance(res, dict):
                    res = {"ok": False, "error": "bad_thread_queue_result", "value": str(res)}
                self.store_thread_queue_result(job_id, "done", res)
                self.worker_log("ws_queue", f"완료 job_id={job_id} act={act} ok={res.get('ok')}")
            except Exception as e:
                err = {"ok": False, "act": act, "error": str(e), "where": "ws_job_worker"}
                self.store_thread_queue_result(job_id, "error", err, str(e))
                self.worker_log("ws_queue", f"실패 job_id={job_id} act={act} error={e}")


    def file_worker_loop(self):
        self.worker_log("file", "파일/md5/json 스레드가 준비됐다.")
        while not self.stop_workers.is_set():
            try:
                job = self.file_queue.get(timeout=0.25)
            except queue.Empty:
                continue
            reply_q = job.get("reply_q") if isinstance(job, dict) else None
            data = job.get("data") if isinstance(job, dict) else {}
            try:
                result = self.process_ig_media_downloaded_job(data)
            except Exception as e:
                result = {"ok": False, "error": str(e), "worker": "file"}
                self.worker_log("file", f"실패했다. {e}")
            try:
                if reply_q:
                    reply_q.put(result)
            except Exception:
                pass

    def server_worker_loop(self):
        self.worker_log("ig_sort", "서버/MySQL 전송 스레드가 준비됐다.")
        while not self.stop_workers.is_set():
            try:
                job = self.server_queue.get(timeout=0.25)
            except queue.Empty:
                continue
            reply_q = job.get("reply_q") if isinstance(job, dict) else None
            mode = job.get("mode") if isinstance(job, dict) else ""
            fields = job.get("fields") if isinstance(job, dict) else {}
            try:
                res = yellow_api_call(self.cfg, str(mode or ""), fields if isinstance(fields, dict) else {})
                if isinstance(res, dict) and res.get("ok"):
                    self.worker_log("ig_sort", f"{mode} 저장했다. shortcode={fields.get('shortcode','') if isinstance(fields, dict) else ''}")
                elif isinstance(res, dict):
                    self.worker_log(
                        "ig_sort",
                        f"{mode} 실패했다. 재시도 대상 "
                        f"status={res.get('http_status','')} stage={res.get('error_stage','')} "
                        f"error={res.get('error','')} message={str(res.get('message') or '')[:1000]} "
                        f"missing={res.get('missing_columns','')}",
                    )
                else:
                    res = {"ok": False, "error": "bad_server_result"}
            except Exception as e:
                res = {"ok": False, "error": str(e), "mode": mode}
                self.worker_log("ig_sort", f"{mode} 예외가 났다. {e}")
            try:
                if reply_q:
                    reply_q.put(res)
            except Exception:
                pass

    def queue_server_request(self, mode, fields, wait_sec=0):
        reply_q = queue.Queue(maxsize=1) if wait_sec else None
        self.server_queue.put({"mode": mode, "fields": fields if isinstance(fields, dict) else {}, "reply_q": reply_q})
        if reply_q:
            try:
                return reply_q.get(timeout=max(0.1, float(wait_sec)))
            except Exception:
                return {"ok": False, "error": "ig_sort_timeout", "mode": mode}
        return {"ok": True, "queued": True, "mode": mode}






    def _request_tab_id(self, req):
        try:
            data = self._req_data(req)
            return int(float(data.get("tab_id") or data.get("target_tab_id") or 0))
        except Exception:
            return 0

    def _request_cycle_id(self, req):
        try:
            data = self._req_data(req)
            return str(data.get("cycle_id") or "")
        except Exception:
            return ""

    def set_active_target_from_request(self, req):
        data = self._req_data(req)
        try:
            tab_id = int(float(data.get("tab_id") or data.get("target_tab_id") or 0))
        except Exception:
            tab_id = 0
        cycle_id = str(data.get("cycle_id") or "")
        url = str(data.get("url") or data.get("href") or data.get("account_url") or "")
        with self.active_target_guard:
            self.active_target = {
                "tab_id": tab_id,
                "request_tab_id": tab_id,
                "cycle_id": cycle_id,
                "url": url,
                "updated_at_ms": int(time.time() * 1000),
                "source": str(data.get("request_reason") or data.get("action") or "auto_start"),
            }
        self.set_runtime_state("active_auto_target", self.active_target)
        return {"ok": True, "alive": True, "accepted": True, "state": "target_registered", **self.active_target, "ws_build_tag": WS_BUILD_TAG, "ws_build_version": WS_BUILD_VERSION}




    def set_current_job(self, job_type, stage="", cycle_id="", message=""):
        try:
            with self.current_job_guard:
                self.current_job = {"type": str(job_type or "idle"), "stage": str(stage or ""), "cycle_id": str(cycle_id or ""), "started_at_ms": int(time.time() * 1000), "message": str(message or "")}
            self.set_runtime_state("current_job", self.current_job)
        except Exception:
            pass

    def handle_thread_direct_request(self, req):
        act = str(req.get("act", req.get("cmd", ""))).strip() if isinstance(req, dict) else ""
        if act == "ig_thread_queue_result":
            return self.handle_thread_queue_result(req)
        if act == "ig_auto_target_set":
            return self.set_active_target_from_request(req)
        if act in ("ping", "status"):
            return self.control_status_fast(req)
        if self.thread_queue_mode_enabled_for(req, act):
            return self.enqueue_thread_queue_request(act, req)
        if act == "ig_rtc_py_setting":
            return self.handle_ig_rtc_py_setting(req)
        if act in ("ig_rtc_enter_auto_arm", "ig_media_exists", "ig_media_download_direct", "ig_media_downloaded", "ig_auto_cycle_state"):
            return self.handle_control_request(req)
        return None


    def normalize_enter_mode(self, mode=None):
        raw = str(mode if mode is not None else self.cfg.get("enter_mode", "")).strip().lower()
        if raw in ("on", "켜기", "true", "1", "yes"):
            return "on"
        if raw in ("auto", "자동"):
            return "auto"
        if raw in ("off", "끄기", "false", "0", "no"):
            return "off"
        # legacy compatibility: old enabled=True meant always-on.
        return "on" if bool(self.cfg.get("enabled", False)) else "off"

    def set_enter_mode(self, mode, save=True):
        old_mode = self.normalize_enter_mode()
        mode = self.normalize_enter_mode(mode)
        self.cfg["enter_mode"] = mode
        self.cfg["enabled"] = mode != "off"
        if mode != "auto":
            self.auto_enter_until = 0.0
            self.auto_enter_reason = ""
        if mode == "off" and old_mode != "off":
            self.request_emergency_stop("ui_stop_button")
        if save:
            save_config(self.cfg)
        try:
            self.update_status()
            self.apply_ui_layout(save=False)
        except Exception:
            pass
        return mode

    def cycle_enter_mode(self):
        cur = self.normalize_enter_mode()
        nxt = {"off": "auto", "auto": "on", "on": "off"}.get(cur, "off")
        self.set_enter_mode(nxt, save=True)
        return nxt

    def arm_auto_enter(self, reason="ig_auto_download_click", detail=None):
        try:
            sec = max(3.0, min(120.0, float(self.cfg.get("auto_enter_window_sec", 25) or 25)))
        except Exception:
            sec = 25.0
        self.auto_enter_until = time.time() + sec
        self.auto_enter_reason = str(reason or "ig_auto_download_click")
        d = detail if isinstance(detail, dict) else {}
        self.auto_enter_expected = {
            "prefix": str(d.get("prefix") or "").strip(),
            "shortcode": str(d.get("shortcode") or "").strip(),
            "asset_kind": str(d.get("asset_kind") or "").strip().lower(),
            "account": str(d.get("account") or "").strip(),
            "account_folder_merge": bool(d.get("account_folder_merge")),
        }
        try:
            self.ui_note(f"자동 저장 허용 {int(sec)}초: {self.auto_enter_reason}")
            self.update_status()
            self.apply_ui_layout(save=False)
        except Exception:
            pass

    def is_auto_enter_armed(self):
        try:
            return time.time() <= float(getattr(self, "auto_enter_until", 0.0) or 0.0)
        except Exception:
            return False

    def clear_auto_enter_arm(self):
        self.auto_enter_until = 0.0
        self.auto_enter_reason = ""
        self.auto_enter_expected = {}
        try:
            self.update_status()
            self.apply_ui_layout(save=False)
        except Exception:
            pass

    def enter_mode_colors(self):
        mode = self.normalize_enter_mode()
        if mode == "on":
            return {"mode": mode, "label": "켜기", "status": "저장 켜짐", "fg": "#86efac", "bg": "#166534", "active": "#14532d"}
        if mode == "auto":
            label = "자동"
            status = "자동 대기"
            if self.is_auto_enter_armed():
                left = max(0, int(float(getattr(self, "auto_enter_until", 0.0) or 0.0) - time.time()))
                status = f"자동 허용 {left}s"
            return {"mode": mode, "label": label, "status": status, "fg": "#fde68a", "bg": "#ca8a04", "active": "#a16207"}
        return {"mode": "off", "label": "끄기", "status": "저장 꺼짐", "fg": "#fca5a5", "bg": "#6b7280", "active": "#4b5563"}

    def save_window_state(self, save=True):
        """Persist WS final geometry and fold/expand state. If left expanded, next run starts expanded."""
        try:
            stage = max(0, min(2, int(getattr(self, "ui_collapse_stage", 0) or 0)))
            self.cfg["ui_collapse_stage"] = int(stage)
            self.cfg["ui_collapsed"] = bool(stage >= 2)
            w = max(1, int(self.root.winfo_width() or self.current_ui_width()))
            h = max(1, int(self.root.winfo_height() or getattr(self, "ui_bar_height", 34)))
            if stage == 0:
                self.ui_expanded_width = max(180, int(w))
            elif stage == 1:
                self.ui_compact_width = max(120, int(w))
            else:
                self.ui_collapsed_width = max(34, int(w))
            self.cfg["ui_expanded_width"] = int(getattr(self, "ui_expanded_width", 880))
            self.cfg["ui_compact_width"] = int(getattr(self, "ui_compact_width", 260))
            self.cfg["ui_collapsed_width"] = int(getattr(self, "ui_collapsed_width", 34))
            self.cfg["ui_bar_height"] = int(getattr(self, "ui_bar_height", h))
            self.cfg["x"] = int(self.root.winfo_x())
            self.cfg["y"] = int(self.root.winfo_y())
            if save:
                save_config(self.cfg)
        except Exception:
            pass

    def on_root_configure(self, event=None):
        """Track final WS width/state without changing current behavior."""
        try:
            if event is not None and event.widget is not self.root:
                return
            if not hasattr(self, "ui_collapse_stage"):
                return
            stage = max(0, min(2, int(getattr(self, "ui_collapse_stage", 0) or 0)))
            width = int(getattr(event, "width", 0) or self.root.winfo_width() or 0)
            if width <= 0:
                return
            if stage == 0 and width >= 180:
                self.ui_expanded_width = width
            elif stage == 1 and width >= 120:
                self.ui_compact_width = width
        except Exception:
            pass

    def current_ui_width(self):
        try:
            stage = int(getattr(self, "ui_collapse_stage", 0) or 0)
            if stage >= 2:
                return int(getattr(self, "ui_collapsed_width", 34) or 34)
            if stage == 1:
                return int(getattr(self, "ui_compact_width", 260) or 260)
            return int(getattr(self, "ui_expanded_width", 880) or 880)
        except Exception:
            return 820

    def apply_ui_layout(self, save=True):
        """WS 3-stage layout.

        Stage 0: full WS bar.
        Stage 1: compact WS bar, toggled by clicking the WS label.
        Stage 2: one small colored ㅁ button only, toggled by clicking _.
        """
        try:
            widgets = (self.ws_label, self.rtc_btn, self.status, self.last_label, self.toggle_btn, self.setting_btn, self.fold_btn, self.close_btn)
            for w in widgets:
                try:
                    w.grid_forget()
                except Exception:
                    pass
            x = int(self.root.winfo_x() if self.root.winfo_x() >= 0 else self.cfg.get("x", 80))
            y = int(self.root.winfo_y() if self.root.winfo_y() >= 0 else self.cfg.get("y", 80))
            height = int(getattr(self, "ui_bar_height", 34) or 34)
            stage = max(0, min(2, int(getattr(self, "ui_collapse_stage", 0) or 0)))
            self.ui_collapse_stage = stage
            self.ui_collapsed = stage >= 2
            c = self.enter_mode_colors()

            if stage >= 2:
                width = height
                self.ui_collapsed_width = width
                width, height, x, y = clamp_tk_window_geometry(self.root, width, height, x, y, height, height)
                self.root.geometry(f"{width}x{height}+{x}+{y}")
                self.fold_btn.config(text="ㅁ", width=3, bg=c["bg"], activebackground=c["active"], fg="white")
                self.fold_btn.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
            elif stage == 1:
                width = int(getattr(self, "ui_compact_width", 260) or 260)
                width, height, x, y = clamp_tk_window_geometry(self.root, width, height, x, y, 120, height)
                self.root.geometry(f"{width}x{height}+{x}+{y}")
                self.ws_label.grid(row=0, column=0, sticky="w", padx=(8, 2), pady=5)
                self.rtc_btn.config(text="🖥", width=3)
                self.rtc_btn.grid(row=0, column=1, sticky="w", padx=(2, 2), pady=5)
                self.status.grid(row=0, column=2, sticky="w", padx=(4, 4), pady=5)
                self.fold_btn.config(text="_", width=3, bg=c["bg"], activebackground=c["active"], fg="white")
                self.fold_btn.grid(row=0, column=3, sticky="e", padx=(4, 2), pady=5)
                self.close_btn.grid(row=0, column=4, sticky="e", padx=(0, 6), pady=5)
            else:
                width = int(getattr(self, "ui_expanded_width", 880) or 880)
                width, height, x, y = clamp_tk_window_geometry(self.root, width, height, x, y, 180, height)
                self.root.geometry(f"{width}x{height}+{x}+{y}")
                self.fold_btn.config(text="_", width=3, bg=c["bg"], activebackground=c["active"], fg="white")
                self.ws_label.grid(row=0, column=0, sticky="w", padx=(8, 2), pady=5)
                self.rtc_btn.config(text="🖥 RTC", width=7)
                self.rtc_btn.grid(row=0, column=1, sticky="w", padx=(2, 4), pady=5)
                self.status.grid(row=0, column=2, sticky="w", padx=(4, 10), pady=5)
                self.last_label.grid(row=0, column=3, sticky="we", padx=(0, 10), pady=5)
                self.toggle_btn.grid(row=0, column=4, sticky="e", padx=(0, 4), pady=5)
                self.setting_btn.grid(row=0, column=5, sticky="e", padx=(0, 4), pady=5)
                self.fold_btn.grid(row=0, column=6, sticky="e", padx=(0, 2), pady=5)
                self.close_btn.grid(row=0, column=7, sticky="e", padx=(0, 6), pady=5)
            self.save_window_state(save=save)
            self.root.update_idletasks()
        except Exception as e:
            vlog(f"apply_ui_layout error {e}")

    def toggle_ui_compact(self):
        try:
            stage = int(getattr(self, "ui_collapse_stage", 0) or 0)
            self.ui_collapse_stage = 0 if stage == 1 else 1
            self.ui_collapsed = self.ui_collapse_stage >= 2
            self.apply_ui_layout(save=True)
        except Exception:
            pass

    def set_ui_full_collapsed(self):
        try:
            stage = int(getattr(self, "ui_collapse_stage", 0) or 0)
            self.ui_collapse_stage = 0 if stage >= 2 else 2
            self.ui_collapsed = self.ui_collapse_stage >= 2
            self.apply_ui_layout(save=True)
        except Exception:
            pass

    def toggle_ui_collapsed(self):
        # Backward-compatible name. The _ / ㅁ button controls full collapse only.
        self.set_ui_full_collapsed()

    def ui_note(self, text):
        try:
            msg = str(text or "")[:180]
            self.last_label.config(text=msg)
            self.root.update_idletasks()
        except Exception:
            pass

    def report_callback_exception(self, exc, val, tb):
        """v63: Tkinter callback 예외를 콘솔로만 흘리지 않고 로그 파일에 남긴다."""
        try:
            text = "".join(traceback.format_exception(exc, val, tb))
            payload = {"ts": now_text(), "ok": False, "act": "tkinter_callback_exception", "error": str(val), "traceback": text}
            igblue_append_jsonl("tkinter_callback_exception.jsonl", payload)
            self.set_runtime_state("tkinter_callback_exception_last", payload)
            self.ui_note("Tkinter callback 예외를 메모리에 기록했다")
            vlog("Tkinter callback exception " + str(val))
        except Exception:
            try:
                traceback.print_exception(exc, val, tb)
            except Exception:
                pass


    def update_status(self):
        c = self.enter_mode_colors()
        self.status.config(text=c["status"], fg=c["fg"])
        self.toggle_btn.config(text=c["label"], bg=c["bg"], activebackground=c["active"])
        try:
            if int(getattr(self, "ui_collapse_stage", 0) or 0) >= 2:
                self.fold_btn.config(bg=c["bg"], activebackground=c["active"], fg="white")
        except Exception:
            pass

    def start_control_ws_if_enabled(self):
        ws_cfg = self.cfg.get("control_ws") or {}
        if not bool(ws_cfg.get("enabled", True)):
            self.last_label.config(text="WS 제어 꺼짐")
            return
        host = str(ws_cfg.get("host", "127.0.0.1"))
        port = int(ws_cfg.get("port", 8771))
        self.control_server = LocalControlWsServer(host, port, self.enqueue_control_request, logger=vlog)
        self.control_server.start()
        self.last_label.config(text=f"대기 중 / user={self.collector_id} / WS {host}:{port}")

    def enqueue_control_request(self, req):
        # v207: 원점 보정은 Tk UI queue에 넣지 않는다. WS 연결 worker에서 실행해 X/정지 UI를 항상 살린다.
        try:
            act = str(req.get("act", req.get("cmd", ""))).strip() if isinstance(req, dict) else ""
            if act == "ig_rtc_auto_calibration_click_points":
                return self.handle_control_request(req)
            direct = self.handle_thread_direct_request(req)
            if direct is not None:
                return direct
        except Exception as e:
            return {"ok": False, "error": str(e), "where": "thread_direct"}
        reply_q = queue.Queue(maxsize=1)
        self.control_queue.put((req, reply_q))
        try:
            act = str(req.get("act", req.get("cmd", ""))).strip() if isinstance(req, dict) else ""
            # v164: 원점찾기는 TL/TR/BL/BR 4개를 순차 검증하므로 전체 10초 제한이 짧다.
            # 각 마커 성공 시 즉시 다음으로 넘어가되, 시스템 부하/느린 마우스 이동을 고려해
            # 원점찾기 control 요청 전체만 45초까지 기다린다. 일반 control 요청은 기존 10초 유지.
            timeout_sec = 120.0 if act == "ig_rtc_auto_calibration_click_points" else 10.0
            return reply_q.get(timeout=timeout_sec)
        except Exception:
            return {"ok": False, "error": "control_timeout"}

    def process_control_queue(self):
        while True:
            try:
                req, reply_q = self.control_queue.get_nowait()
            except queue.Empty:
                break
            try:
                resp = self.handle_control_request(req)
            except Exception as e:
                resp = {"ok": False, "error": str(e)}
            try:
                reply_q.put(resp)
            except Exception:
                pass
        self.root.after(80, self.process_control_queue)

    def _token_ok(self, req):
        ws_cfg = self.cfg.get("control_ws") or {}
        expected = str(ws_cfg.get("token", ""))
        if not expected:
            return True
        return str(req.get("token", "")) == expected

    def handle_control_request(self, req):
        if not isinstance(req, dict):
            return {"ok": False, "error": "request_must_be_object"}
        act = str(req.get("act", req.get("cmd", "status"))).strip()
        if act in ("ping", "status"):
            return self.control_status()
        if act == "ig_thread_queue_result":
            return self.handle_thread_queue_result(req)
        if not self._token_ok(req):
            return {"ok": False, "error": "bad_token"}
        if act == "get_config":
            return {"ok": True, "config": self.cfg}
        if act == "set_enabled":
            self.set_enter_mode("on" if bool(req.get("enabled")) else "off", save=True)
            return self.control_status()
        if act == "set_enter_mode":
            self.set_enter_mode(req.get("enter_mode") or req.get("mode"), save=True)
            return self.control_status()
        if act == "toggle":
            self.toggle_enabled()
            return self.control_status()
        if act == "set_config":
            patch = req.get("config") or {}
            allowed = {"enabled", "enter_mode", "auto_enter_window_sec", "alpha", "x", "y", "poll_ms", "cooldown_sec", "match_contains", "target_titles", "button_keywords", "try_idok_after_button_fail", "try_enter_after_idok", "yellow_api"}
            for k, v in patch.items():
                if k in allowed:
                    self.cfg[k] = v
            if "enter_mode" in patch:
                self.set_enter_mode(patch.get("enter_mode"), save=False)
            elif "enabled" in patch:
                self.set_enter_mode("on" if bool(patch.get("enabled")) else "off", save=False)
            save_config(self.cfg)
            self.update_status()
            try:
                self.root.attributes("-alpha", float(self.cfg.get("alpha", 0.78)))
            except Exception:
                pass
            return {"ok": True, "config": self.cfg}
        if act in ("domain_down_txt_write", "domain_down_txt_read", "domain_folder_json_read", "domain_folder_json_save_hint", "domain_auto_start"):
            return self.handle_domain_file_request(act, req)
        if act == "ig_rtc_py_setting":
            return self.handle_ig_rtc_py_setting(req)
        if act == "ig_rtc_enter_auto_arm":
            return self.handle_ig_rtc_enter_auto_arm(req)
        if act == "ig_auto_target_set":
            return self.set_active_target_from_request(req)
        if act == "ig_auto_cycle_state":
            return self.handle_ig_auto_cycle_state(req)
        if act == "ig_media_exists":
            return self.handle_ig_media_exists(req)
        if act == "ig_media_download_direct":
            return self.handle_ig_media_download_direct(req)
        if act == "ig_media_downloaded":
            return self.handle_ig_media_downloaded(req)
        if act in ("ig_open_file", "ig_open_folder", "open_file", "open_folder"):
            return self.handle_open_local_path(req, act)
        if act in ("copy_status", "copy_clipboard_status"):
            return self.copy_current_status_to_clipboard("ws")
        if act in ("folder_reconcile", "folder_cleanup", "폴더정리"):
            data = self._req_data(req)
            return self.run_folder_reconcile(media_folder=str(data.get("media_folder") or data.get("folder") or ""), json_path=str(data.get("json_path") or data.get("folder_json") or ""), apply=bool(data.get("apply", False)))
        if act == "scan_once":
            self.scan_once()
            return self.control_status()
        if act in ("auto_enter_once", "accept_once"):
            return self.accept_once(req)
        return {"ok": False, "error": "unknown_act", "act": act}


    def handle_open_local_path(self, req, act):
        data = self._req_data(req) if isinstance(req, dict) else {}
        path = str(data.get("path") or req.get("path") or "").strip() if isinstance(req, dict) else ""
        if not path:
            return {"ok": False, "error": "path_empty", "act": act}
        path = os.path.abspath(path)
        try:
            if act in ("ig_open_folder", "open_folder"):
                target = path if os.path.isdir(path) else os.path.dirname(path)
                if not target or not os.path.exists(target):
                    return {"ok": False, "error": "folder_not_found", "path": path}
                if os.name == "nt":
                    if os.path.isfile(path):
                        subprocess.Popen(["explorer.exe", "/select,", path])
                    else:
                        os.startfile(target) if hasattr(os, "startfile") else subprocess.Popen(["explorer.exe", target])
                else:
                    subprocess.Popen(["xdg-open", target])
                self.ui_note(f"폴더 열기: {target}")
                return {"ok": True, "act": act, "path": path, "folder": target}
            if not os.path.exists(path):
                return {"ok": False, "error": "file_not_found", "path": path}
            if os.name == "nt" and hasattr(os, "startfile"):
                os.startfile(path)
            elif os.name == "nt":
                subprocess.Popen(["explorer.exe", path])
            else:
                subprocess.Popen(["xdg-open", path])
            self.ui_note(f"파일 열기: {os.path.basename(path)}")
            return {"ok": True, "act": act, "path": path}
        except Exception as e:
            return {"ok": False, "error": str(e), "path": path, "act": act}



    def control_status_fast(self, req=None):
        try:
            ws_cfg = self.cfg.get("control_ws") or {}
            mode = self.normalize_enter_mode()
            snap = self.get_runtime_state_snapshot()
            cur_job = snap.get("current_job") if isinstance(snap.get("current_job"), dict) else {}
            busy = bool(cur_job and str(cur_job.get("type") or "idle") != "idle")
            return {
                "ok": True,
                "alive": True,
                "fast_status": True,
                "app": APP_NAME,
                "ws_build_version": WS_BUILD_VERSION,
                "ws_build_tag": WS_BUILD_TAG,
                "ws_label": WS_LABEL,
                "collector_id": self.collector_id,
                "user_id": self.collector_id,
                "enabled": mode != "off",
                "enter_mode": mode,
                "ws": {"enabled": bool(ws_cfg.get("enabled", True)), "host": str(ws_cfg.get("host", "127.0.0.1")), "port": int(ws_cfg.get("port", 8771))},
                "busy": busy,
                "busy_reason": str(cur_job.get("type") or ""),
                "current_job": cur_job or {},
                "state": "busy_wait" if busy else "connected",
                "paused": False,
                "message": "WS 작업 중 · 잠시 대기" if busy else "WS 연결됨",
            }
        except Exception as e:
            return {"ok": False, "fast_status": True, "error": str(e), "ws_build_tag": WS_BUILD_TAG}



    def handle_ig_rtc_py_setting(self, req):
        """v81: EXT TTS와 PY TTS를 분리한다.

        브라우저/side에서 오는 tts_level은 더 이상 PY 음성 레벨을 덮어쓰지 않는다.
        PY TTS 단계는 이 플로팅 프로그램의 [설정] 창에서만 바꾼다.
        """
        try:
            lvl = max(0, min(3, int(getattr(self, "tts_level", 0) or 0)))
            msg = f"PY TTS 단계는 설정창에서 관리합니다. 현재 {lvl}단계"
            return {"ok": True, "act": "ig_rtc_py_setting", "state": "py_tts_independent", "tts_level": lvl, "message": msg}
        except Exception as e:
            return {"ok": False, "act": "ig_rtc_py_setting", "state": "setting_failed", "error": str(e), "message": "TTS 설정 확인 실패"}


    def handle_ig_rtc_enter_auto_arm(self, req):
        data = self._req_data(req) if isinstance(req, dict) else {}
        reason = str(data.get("reason") or data.get("source") or "manual_main_click")
        mode = self.normalize_enter_mode()
        armed = False
        if mode == "auto":
            self.arm_auto_enter(reason, data)
            armed = True
        return {
            "ok": bool(armed or mode == "on"),
            "act": "ig_rtc_enter_auto_arm",
            "enter_mode": mode,
            "armed": armed,
            "reason": reason,
            "expected": dict(getattr(self, "auto_enter_expected", {}) or {}),
            "auto_enter_until": float(getattr(self, "auto_enter_until", 0.0) or 0.0),
        }


    def _req_data(self, req):
        data = req.get("data") if isinstance(req, dict) else {}
        data = dict(data) if isinstance(data, dict) else {}
        collector_id = str(data.get("collector_id") or data.get("user_id") or self.collector_id or RUNTIME_USER or default_runtime_user()).strip()
        data["collector_id"] = collector_id
        data["user_id"] = collector_id
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        meta = dict(meta)
        meta["collector_id"] = str(meta.get("collector_id") or meta.get("user_id") or collector_id)
        meta["user_id"] = meta["collector_id"]
        ig_account_id = str(
            data.get("ig_account_id")
            or meta.get("ig_account_id")
            or meta.get("owner_username")
            or meta.get("account_name")
            or data.get("account")
            or data.get("folder_name")
            or ""
        ).strip()
        if ig_account_id:
            data["ig_account_id"] = ig_account_id
            meta["ig_account_id"] = str(meta.get("ig_account_id") or ig_account_id)
        data["meta"] = meta
        return data

    def handle_domain_file_request(self, act, req):
        data = self._req_data(req)
        path = domain_local_path(data)
        if act == "domain_down_txt_write":
            text = str(data.get("text") or "")
            os.makedirs(os.path.dirname(path), exist_ok=True)
            with open(path, "w", encoding="utf-8", newline="") as f:
                f.write(text)
            vlog(f"down.txt write user={data.get('collector_id')} ig={data.get('ig_account_id', '')} bytes={len(text.encode('utf-8'))}")
            self.last_label.config(text=f"down.txt 저장: {os.path.basename(path)}")
            return {"ok": True, "path": path, "collector_id": data.get("collector_id"), "ig_account_id": data.get("ig_account_id", "")}
        if act == "domain_down_txt_read":
            text = ""
            exists = os.path.exists(path)
            if exists:
                with open(path, "r", encoding="utf-8", errors="replace") as f:
                    text = f.read()
            vlog(f"down.txt read user={data.get('collector_id')} ig={data.get('ig_account_id', '')} exists={exists} bytes={len(text.encode('utf-8'))}")
            return {"ok": True, "path": path, "text": text, "collector_id": data.get("collector_id"), "ig_account_id": data.get("ig_account_id", "")}
        if act == "domain_folder_json_read":
            folder = expected_download_folder(data)
            candidates = [final_folder_index_path(folder), legacy_folder_index_path(folder)]
            doc = {}
            json_path = candidates[0]
            for candidate in candidates:
                d = read_json_file(candidate, {})
                if isinstance(d, dict) and isinstance(d.get("items"), dict):
                    doc = d
                    json_path = candidate
                    break
            items_map = doc.get("items") if isinstance(doc, dict) else {}
            items = [v for v in (items_map or {}).values() if isinstance(v, dict)]
            items.sort(key=lambda it: (normalize_int(it.get("rank_no"), 999999), str(it.get("shortcode") or ""), str(it.get("asset_kind") or "")))
            vlog(f"folder json read user={data.get('collector_id')} ig={data.get('ig_account_id', '')} count={len(items)}")
            return {"ok": True, "path": json_path, "items": items, "count": len(items), "collector_id": data.get("collector_id"), "ig_account_id": data.get("ig_account_id", "")}
        if act == "domain_folder_json_save_hint":
            return {"ok": True, "message": "folder_json_is_authoritative", "count": len(data.get("items") or []) if isinstance(data.get("items"), list) else 0}
        if act == "domain_auto_start":
            vlog(f"domain_auto_start noop user={data.get('collector_id')} path={path}")
            return {"ok": True, "path": path, "collector_id": data.get("collector_id"), "message": "auto_start noop in auto_enter_ws"}
        return {"ok": False, "error": "unknown_domain_file_act", "act": act}

    def handle_ig_auto_cycle_state(self, req):
        data = self._req_data(req)
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        try:
            stage = str(data.get("cycle_stage") or "").strip()
            decision = str(data.get("decision") or data.get("cycle_decision") or "").strip()
            action = str(data.get("action") or data.get("cycle_action") or "").strip()
            feature_check_mode = bool(data.get("feature_check_mode"))
            check_id = str(data.get("check_id") or "").strip()
            check_status = str(data.get("check_status") or "").strip()
            check_message_ko = str(data.get("check_message_ko") or "").strip()
            if action.lower() == "stop" or decision.lower() == "auto_stopped":
                self.request_emergency_stop("button")
            rank_no = normalize_int(data.get("rank_no") or meta.get("rank_no"), 0)
            state = {
                "state": "cycle_state",
                "build_version": str(data.get("build_version") or data.get("ext_build_version") or ""),
                "build_tag": str(data.get("build_tag") or data.get("ext_build_tag") or ""),
                "bg_version": str(data.get("bg_version") or data.get("bg_build_tag") or data.get("build_tag") or ""),
                "bg_build_tag": str(data.get("bg_build_tag") or data.get("bg_version") or data.get("build_tag") or ""),
                "schema": str(data.get("schema") or ""),
                "cycle_id": str(data.get("cycle_id") or ""),
                "tab_id": normalize_int(data.get("tab_id") or data.get("request_tab_id"), 0),
                "request_tab_id": normalize_int(data.get("request_tab_id") or data.get("tab_id"), 0),
                "active_tab_id": normalize_int((self.get_runtime_state_snapshot().get("active_auto_target") or {}).get("tab_id") if isinstance(self.get_runtime_state_snapshot().get("active_auto_target"), dict) else 0, 0),
                "accepted": True,
                "run_seq": str(data.get("run_seq") or ""),
                "account_queue_index": data.get("account_queue_index", ""),
                "account_queue_total": data.get("account_queue_total", ""),
                "account_queue_url": str(data.get("account_queue_url") or ""),
                "account_queue_username": str(data.get("account_queue_username") or ""),
                "cycle_stage": stage,
                "cycle_decision": decision,
                "cycle_action": action,
                "cycle_reason": str(data.get("reason") or data.get("cycle_reason") or ""),
                "stop_reason": str(data.get("stop_reason") or data.get("autoplay_stop_reason") or ""),
                "stop_reason_ko": str(data.get("stop_reason_ko") or data.get("autoplay_stop_reason_ko") or ""),
                "autoplay_stop_reason": str(data.get("autoplay_stop_reason") or data.get("stop_reason") or ""),
                "autoplay_stop_reason_ko": str(data.get("autoplay_stop_reason_ko") or data.get("stop_reason_ko") or ""),
                "target_A_rank": data.get("target_A_rank", ""),
                "target_B_shortcode": data.get("target_B_shortcode", ""),
                "target_C_asset_kind": data.get("target_C_asset_kind", ""),
                "target_D_button_rect": data.get("target_D_button_rect", ""),
                "target_missing_fields": str(data.get("target_missing_fields") or ""),
                "rank_label": str(data.get("rank_label") or meta.get("rank_label") or (f"No{rank_no:03d}" if rank_no else "")),
                "rank_no": rank_no,
                "shortcode": str(data.get("shortcode") or meta.get("shortcode") or ""),
                "asset_kind": str(data.get("asset_kind") or meta.get("asset_kind") or ""),
                "media_index": meta_media_index(meta) if meta else normalize_int(data.get("media_index"), 0),
                "href": str(data.get("href") or meta.get("href") or meta.get("page_url") or ""),
                "next_requested": data.get("next_requested", ""),
                "next_clicked": data.get("next_clicked", ""),
                "next_to_rank_no": data.get("next_to_rank_no", ""),
                "next_to_rank_label": str(data.get("next_to_rank_label") or ""),
                "next_before_rank": data.get("next_before_rank", ""),
                "next_after_rank": data.get("next_after_rank", ""),
                "next_before_shortcode": str(data.get("next_before_shortcode") or ""),
                "next_after_shortcode": str(data.get("next_after_shortcode") or ""),
                "active_rank_no": data.get("active_rank_no", ""),
                "requested_rank_no": data.get("requested_rank_no", ""),
                "active_shortcode": str(data.get("active_shortcode") or ""),
                "expected_shortcode": str(data.get("expected_shortcode") or ""),
                "active_target_age_ms": data.get("active_target_age_ms", ""),
                "cycle_log_text": str(data.get("cycle_log_text") or ""),
                "feature_check_mode": feature_check_mode,
                "feature_name_ko": str(data.get("feature_name_ko") or "자동다운로드"),
                "check_id": check_id,
                "check_status": check_status,
                "check_message_ko": check_message_ko,
                "error_code": str(data.get("error_code") or ""),
                "next_check": str(data.get("next_check") or ""),
                "owner_username": str(data.get("owner_username") or meta.get("owner_username") or meta.get("username") or ""),
                "updated_at": now_text(),
            }
            dup = data.get("duplicate") if isinstance(data.get("duplicate"), dict) else {}
            if dup:
                state["duplicate_confirmed"] = dup.get("duplicate_confirmed", "")
                state["duplicate_reason"] = dup.get("reason", "")
                fp = str(dup.get("file_path") or "")
                if fp:
                    state["file_path"] = fp
            self.last_media_state = state
            self.persist_last_media_state(self.last_media_state)
            if check_id and check_status:
                self.project_progress_check(
                    state.get("feature_name_ko") or "자동다운로드",
                    check_id,
                    check_status,
                    check_message_ko,
                    state,
                )
            elif (not feature_check_mode) and (stage or decision or action):
                stop_ko = state.get("autoplay_stop_reason_ko") or state.get("stop_reason_ko") or ""
                abc = f" A={state.get('target_A_rank','')} B={state.get('target_B_shortcode','')} C={state.get('target_C_asset_kind','')} D={state.get('target_D_direct_media_url', state.get('target_D_button_rect',''))}"
                self.worker_log("ig_cycle", f"{stage or 'cycle'} decision={decision} action={action} rank={state.get('rank_label')} tab={state.get('request_tab_id')} active_tab={state.get('active_tab_id')} run_seq={state.get('run_seq')} account={state.get('account_queue_index')}/{state.get('account_queue_total')} next_clicked={state.get('next_clicked')}{abc}" + (f" stop={stop_ko}" if stop_ko else ""))
            elif action.lower() == "stop" or decision.lower() == "auto_stopped":
                stop_ko = state.get("autoplay_stop_reason_ko") or state.get("stop_reason_ko") or state.get("cycle_reason") or "자동 중단"
                self.worker_log("기능체크", f"[자동다운로드][중단][체크실패] {stop_ko}")
            return {"ok": True, "state": state}
        except Exception as e:
            self.project_progress_check(
                "자동다운로드",
                str(locals().get("check_id") or "AD-LOG-E01"),
                "[체크실패]",
                f"프로젝트 진행로그 처리 예외 / 다음점검=WS8771 진행로그 기록부 / 원인={e}",
                {"cycle_id": str(data.get("cycle_id") or ""), "error_code": "project_progress_log_exception", "next_check": "WS8771 진행로그 기록부"},
            )
            return {"ok": False, "error": str(e), "act": "ig_auto_cycle_state"}

    def handle_ig_media_download_direct(self, req):
        """Download one Instagram media URL directly into _StDown/instagram/account.

        This path intentionally bypasses chrome.downloads, Save As dialogs, CDP download
        behavior, and WS Enter automation. The worker writes a temporary .part file,
        atomically renames it, then runs the existing hash/JSON/HTML/SQLite finalizer.
        """
        data = self._req_data(req)
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        url = str(data.get("url") or meta.get("media_url") or meta.get("video_url") or meta.get("image_url") or "").strip()
        if not url:
            return {"ok": False, "status": "error", "error": "download_url_empty", "error_code": "download_url_empty"}

        original_name = str(
            data.get("filename")
            or meta.get("instagram_original_filename_with_ext")
            or meta.get("original_filename_with_ext")
            or ""
        ).strip()
        if not original_name:
            try:
                original_name = urllib.parse.unquote(Path(urllib.parse.urlparse(url).path).name)
            except Exception:
                original_name = ""
        fallback_ext = "mp4" if meta_asset_kind(meta, url) == "video" else "jpg"
        original_name = safe_file_name(original_name, f"media.{fallback_ext}")
        if "." not in original_name:
            original_name = f"{original_name}.{fallback_ext}"

        target_dir = Path(expected_download_folder(data)).expanduser().resolve()
        target_dir.mkdir(parents=True, exist_ok=True)
        target_path = target_dir / original_name

        if target_path.exists():
            try:
                if target_path.is_file() and target_path.stat().st_size > 0:
                    final_data = dict(data)
                    final_data["file_path"] = str(target_path)
                    result = self.process_ig_media_downloaded_job(final_data)
                    finalize_ok = bool(result.get("ok", True))
                    return {
                        "ok": True,
                        "status": "ok" if finalize_ok else "partial",
                        "finalize_ok": finalize_ok,
                        "already_exists": True,
                        "file_path": str(target_path),
                        "file_name": target_path.name,
                        "folder_name": target_path.parent.name,
                        "file_size": int(target_path.stat().st_size),
                        "finalize": result,
                    }
            except Exception as exc:
                return {"ok": False, "status": "error", "error": "existing_file_finalize_failed", "detail": str(exc), "file_path": str(target_path)}

        headers = {
            "User-Agent": str(data.get("user_agent") or "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/138 Safari/537.36"),
            "Accept": "*/*",
            "Referer": str(data.get("referer") or meta.get("post_url") or meta.get("page_url") or "https://www.instagram.com/"),
            "Origin": "https://www.instagram.com",
            "Connection": "close",
        }
        bytes_written = 0
        try:
            self.worker_log("file", f"DIRECT_DOWNLOAD_BEGIN account={str(data.get('account') or '')} file={original_name}")
            download_result = download_url_to_file(url, target_path, headers=headers, timeout=120.0)
            bytes_written = int(download_result.get("bytes_written") or 0)
        except Exception as exc:
            self.worker_log("file", f"DIRECT_DOWNLOAD_FAIL file={original_name} error={exc}")
            return {
                "ok": False,
                "status": "error",
                "error": "direct_download_failed",
                "error_code": "direct_download_failed",
                "detail": str(exc),
                "url_host": urllib.parse.urlparse(url).hostname or "",
                "file_path": str(target_path),
            }

        final_data = dict(data)
        final_data["file_path"] = str(target_path)
        try:
            result = self.process_ig_media_downloaded_job(final_data)
        except Exception as exc:
            return {
                "ok": False,
                "status": "error",
                "error": "download_finalize_failed",
                "detail": str(exc),
                "file_path": str(target_path),
                "file_size": bytes_written,
            }
        self.worker_log("file", f"DIRECT_DOWNLOAD_OK file={target_path} bytes={bytes_written}")
        finalize_ok = bool(result.get("ok", True))
        return {
            "ok": True,
            "status": "ok" if finalize_ok else "partial",
            "finalize_ok": finalize_ok,
            "file_path": str(target_path),
            "file_name": target_path.name,
            "folder_name": target_path.parent.name,
            "file_size": bytes_written,
            "bytes_written": bytes_written,
            "finalize": result,
        }


    def handle_ig_media_exists(self, req):
        data = self._req_data(req)
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        self.worker_log("file", f"중복 확인 시작 rank={meta.get('rank_label') or ('No%03d' % normalize_int(meta.get('rank_no'), 0) if normalize_int(meta.get('rank_no'), 0) else 'No---')} asset={meta_asset_kind(meta, str(data.get('url') or data.get('prefix') or ''))}")
        cycle = data.get("cycle") if isinstance(data.get("cycle"), dict) else {}
        local = local_folder_json_check(data, meta)
        if local.get("exists"):
            # v100: local JSON can point to a legacy physical filename made before _No000_ enforcement.
            # Migrate it before refreshing/returning so clipboard/log/file_path show the canonical ranked name.
            local = migrate_local_duplicate_rank_filename(data, meta, local)
            refresh = update_existing_local_metadata(data, meta, local)
            shortcode = str(meta.get("shortcode") or local.get("shortcode") or "")
            asset_kind = str(local.get("asset_kind") or meta_asset_kind(meta, str(data.get("url") or data.get("prefix") or "")) or "")
            file_path = str(local.get("file_path") or "")
            rank_label = str(meta.get("rank_label") or local.get("rank_label") or (f"No{normalize_int(meta.get('rank_no') or local.get('rank_no'), 0):03d}" if normalize_int(meta.get('rank_no') or local.get('rank_no'), 0) else "No---"))
            msg = f"{rank_label} 중복 확인"
            self.worker_log("file", msg)
            report = refresh.get("report") if isinstance(refresh.get("report"), dict) else {}
            self.last_media_state = {
                "state": "already_exists",
                "shortcode": shortcode,
                "asset_kind": asset_kind,
                "media_index": local.get("media_index", meta_media_index(meta)),
                "rank_label": str(meta.get("rank_label") or local.get("rank_label") or ""),
                "rank_no": normalize_int(meta.get("rank_no") or local.get("rank_no"), 0),
                "file_path": file_path,
                "file_saved": bool(file_path and os.path.exists(file_path)),
                "json_path": str(local.get("json_path") or ""),
                "json_saved": bool(local.get("json_path")),
                "report_ok": bool(report.get("ok")),
                "report_saved": bool(report.get("ok") and report.get("report_path")),
                "report_path": str(report.get("report_path") or ""),
                "report_error": str(report.get("error") or ""),
                "href": str(data.get("href") or meta.get("href") or meta.get("page_url") or ""),
                "cycle_id": str(cycle.get("cycle_id") or ""),
                "cycle_stage": str(cycle.get("cycle_stage") or "S07_DUPLICATE_PRECHECK"),
                "cycle_decision": "duplicate_confirmed",
                "cycle_action": "ig_media_exists",
                "cycle_reason": "local_exists",
                "updated_at": now_text(),
            }
            self.persist_last_media_state(self.last_media_state)
            return {"ok": True, "exists": 1, "duplicate_confirmed": True, "state": "already_exists", "metadata_updated": bool(refresh.get("metadata_updated")), "metadata_refresh": refresh, "message": msg, "match_by": local.get("match_by", "local_json"), "shortcode": shortcode, "asset_kind": asset_kind, "media_index": local.get("media_index", meta_media_index(meta)), "file_path": file_path, "file_paths": local.get("file_paths", []), "json_path": local.get("json_path", ""), "report_ok": bool(report.get("ok")), "report_path": str(report.get("report_path") or ""), "report_error": str(report.get("error") or ""), "local": local}
        fields = meta_to_yellow_fields(meta, collector_id=data.get("collector_id"))
        res = self.queue_server_request("media_exists", fields, wait_sec=5)
        exists = bool(res.get("exists")) if isinstance(res, dict) else False
        self.worker_log("ig_sort", "서버에서 이미 완료를 찾았지만 로컬 파일이 없으므로 다운로드는 허용한다." if exists else "서버 중복 없음. 신규로 본다.")
        self.last_media_state = {
            "state": "duplicate_checked",
            "shortcode": str(meta.get("shortcode") or ""),
            "asset_kind": meta_asset_kind(meta, str(data.get("url") or data.get("prefix") or "")),
            "media_index": meta_media_index(meta),
            "rank_label": str(meta.get("rank_label") or ""),
            "rank_no": normalize_int(meta.get("rank_no"), 0),
            "href": str(data.get("href") or meta.get("href") or meta.get("page_url") or ""),
            "server_exists": bool(exists),
            "cycle_id": str(cycle.get("cycle_id") or ""),
            "cycle_stage": str(cycle.get("cycle_stage") or "S07_DUPLICATE_PRECHECK"),
            "cycle_decision": "download_allowed_server_only" if exists else "download_allowed_new",
            "cycle_action": "ig_media_exists",
            "cycle_reason": "server_exists_without_local_file" if exists else "no_duplicate",
            "updated_at": now_text(),
        }
        self.persist_last_media_state(self.last_media_state)
        return {"ok": True if isinstance(res, dict) else False, "exists": 0, "duplicate_confirmed": False, "server_exists": 1 if exists else 0, "cycle_id": str(cycle.get("cycle_id") or ""), "cycle_stage": str(cycle.get("cycle_stage") or "S07_DUPLICATE_PRECHECK"), "cycle_decision": "download_allowed_server_only" if exists else "download_allowed_new", "cycle_reason": "server_exists_without_local_file" if exists else "no_duplicate", "match_by": res.get("match_by", "") if isinstance(res, dict) else "", "message": "서버 중복만 확인됨 - 로컬 다운로드 허용" if exists else "신규 다운로드 허용", "local": local, "yellow": res}

    def handle_ig_media_downloaded(self, req):
        data = self._req_data(req)
        reply_q = queue.Queue(maxsize=1)
        self.file_queue.put({"data": data, "reply_q": reply_q})
        self.worker_log("file", "다운로드 완료 처리 작업을 file 스레드에 보냈다.")
        try:
            return reply_q.get(timeout=25)
        except Exception:
            return {"ok": False, "queued": True, "error": "file_worker_timeout"}

    def record_shared_media_saved(self, data, meta, file_path, md5, sha256, size, asset_kind):
        source_mode = str(data.get("source_mode") or meta.get("source_mode") or "MANUAL_SINGLE").strip().upper()
        shortcode = str(meta.get("shortcode") or "").strip()
        owner = str(meta.get("owner_username") or meta.get("account_name") or meta.get("username") or "").strip().lstrip("@")
        if not shortcode:
            raise ValueError("MEDIA_SAVED shortcode_missing")
        if not owner:
            raise ValueError("MEDIA_SAVED owner_username_missing")
        stats = meta.get("sort") if isinstance(meta.get("sort"), dict) else meta.get("sort_snapshot") if isinstance(meta.get("sort_snapshot"), dict) else {
            "views": normalize_int(meta.get("views"), 0),
            "likes": normalize_int(meta.get("likes"), 0),
            "comments": normalize_int(meta.get("comments"), 0),
            "reposts": normalize_int(meta.get("reposts"), 0),
            "captured_at": str(meta.get("collected_at") or ""),
        }
        result = record_downloaded_file(
            st_root=ST_ROOT,
            file_path=file_path,
            source_mode=source_mode,
            shortcode=shortcode,
            owner_username=owner,
            asset_kind=asset_kind,
            media_index=meta_media_index(meta),
            rank_no=normalize_int(meta.get("rank_no"), 0) or None,
            source_url=str(data.get("url") or meta.get("source_url") or ""),
            post_url=canonical_instagram_post_url(
                shortcode,
                meta.get("post_url"),
                meta.get("media_url"),
                meta.get("href"),
                data.get("href"),
                meta.get("page_url"),
                meta.get("current_url"),
                meta.get("target_url"),
                fallback_kind="reel" if str(asset_kind or "").strip().lower() in {"video", "mp4"} else "p",
            ),
            caption=str(meta.get("caption") or ""),
            thumbnail_url=str(meta.get("thumbnail_url") or ""),
            video_url=str(meta.get("video_url") or ""),
            published_at=str(meta.get("upload_at") or "") or None,
            md5=md5,
            sha256=sha256,
            file_size=size,
            account_path=str(data.get("account") or meta.get("folder_name") or meta.get("account_name") or owner),
            account_folder_merge=bool(data.get("account_folder_merge") or meta.get("account_folder_merge")),
            batch_key=str(data.get("batch_key") or meta.get("client_batch_key") or ""),
            requested_count=normalize_int(meta.get("batch_requested_count"), 1),
            sort_by=str(meta.get("sort_by") or meta.get("sort_field") or ""),
            filter_days=normalize_int(meta.get("filter_days"), 0) or None,
            event_uuid=str(data.get("event_uuid") or meta.get("client_event_id") or data.get("client_job_key") or meta.get("client_job_key") or ""),
            stats=stats,
            payload={
                "client_job_key": str(data.get("client_job_key") or meta.get("client_job_key") or ""),
                "download_id": data.get("download_id"),
                "root": str(data.get("root") or ""),
                "folder_name": str(meta.get("folder_name") or owner),
                "collection_folder_name": str(meta.get("collection_folder_name") or data.get("root") or ""),
                "account_folder_merge": bool(data.get("account_folder_merge") or meta.get("account_folder_merge")),
            },
        )
        self.worker_log("file", f"MEDIA_SAVED_OK source_mode={source_mode} shortcode={shortcode} event_id={result.get('event_id')} job_id={result.get('job_id')}")
        return result

    def process_ig_media_downloaded_job(self, data):
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        shortcode = str(meta.get("shortcode") or "")
        self.worker_log("file", f"다운로드 완료 처리 rank={meta.get('rank_label') or ('No%03d' % normalize_int(meta.get('rank_no'), 0) if normalize_int(meta.get('rank_no'), 0) else 'No---')}")
        file_path = str(data.get("file_path") or "").strip()
        if not file_path:
            return {"ok": False, "error": "file_path_empty"}
        if not os.path.exists(file_path):
            return {"ok": False, "error": "file_not_found", "file_path": file_path}
        try:
            file_path, relocated = relocate_download_to_expected_folder(file_path, data)
            if relocated:
                self.worker_log("file", f"_StDown 저장 위치로 이동 완료: {file_path}")
        except Exception as exc:
            return {"ok": False, "error": "download_relocate_failed", "detail": str(exc), "file_path": file_path}
        md5, sha256, size = file_hashes(file_path)
        asset_kind = detect_asset_kind(file_path, meta)
        sidecar_path, sidecar_doc = write_sidecar_md5(file_path, meta, md5, sha256, size)
        json_path, item = upsert_folder_json(file_path, meta, md5, sha256, size)
        self.worker_log("file", f"REPORT_GENERATE_BEGIN json_path={json_path} media_folder={os.path.dirname(os.path.abspath(file_path))} trigger=downloaded")
        report_result = generate_account_html_report(json_path, os.path.dirname(os.path.abspath(file_path)))
        folder_name = item.get("folder_name") or os.path.basename(os.path.dirname(file_path))
        file_name = os.path.basename(file_path)
        self.worker_log("file", f"{str(meta.get('rank_label') or ('No%03d' % normalize_int(meta.get('rank_no'), 0) if normalize_int(meta.get('rank_no'), 0) else 'No---'))} 저장 완료. md5/.md5 갱신")
        if report_result.get("ok"):
            self.worker_log("file", f"HTML 보고서 갱신 완료: {report_result.get('report_path')}")
        else:
            self.worker_log("file", f"HTML 보고서 갱신 실패: {report_result.get('error')} {report_result.get('detail','')}")
        self.worker_log("file", "REPORT_GENERATE_RESULT " + " ".join([
            f"report_ok={bool(report_result.get('ok'))}",
            f"report_path={str(report_result.get('report_path') or '')}",
            f"json_path={json_path}",
            f"error={str(report_result.get('error') or '')}",
        ]))
        self.remember_download_paths(file_path=file_path, json_path=json_path, save=True)
        try:
            media_saved_result = self.record_shared_media_saved(data, meta, file_path, md5, sha256, size, asset_kind)
            storage_error = ""
        except Exception as exc:
            media_saved_result = {}
            storage_error = str(exc)
            self.worker_log("file", f"MEDIA_SAVED_FAIL shortcode={shortcode or '?'} error={storage_error}")
        self.last_media_state = {
            "state": "downloaded",
            "shortcode": shortcode,
            "asset_kind": asset_kind,
            "media_index": meta_media_index(meta),
            "rank_label": str(meta.get("rank_label") or ""),
            "rank_no": normalize_int(meta.get("rank_no"), 0),
            "file_path": file_path,
            "file_name": file_name,
            "file_saved": True,
            "shortcode": shortcode,
            "json_path": json_path,
            "sidecar_path": sidecar_path,
            "json_saved": bool(json_path),
            "report_ok": bool(report_result.get("ok")),
            "report_saved": bool(report_result.get("ok") and report_result.get("report_path")),
            "report_path": str(report_result.get("report_path") or ""),
            "report_error": str(report_result.get("error") or ""),
            "storage_ok": bool(media_saved_result),
            "storage_error": storage_error,
            "media_saved": media_saved_result,
            "source_mode": str(data.get("source_mode") or meta.get("source_mode") or "MANUAL_SINGLE"),
            "href": str(data.get("href") or meta.get("href") or meta.get("page_url") or ""),
            "updated_at": now_text(),
        }
        self.persist_last_media_state(self.last_media_state)
        fields = meta_to_yellow_fields(
            meta,
            file_name=file_name,
            folder_name=folder_name,
            md5=md5,
            sha256=sha256,
            collector_id=data.get("collector_id"),
            file_path=file_path,
            file_size=size,
            asset_kind=asset_kind,
        )
        yellow_res = self.queue_server_request("media_insert", fields, wait_sec=8)
        if isinstance(yellow_res, dict) and yellow_res.get("ok") is False:
            self.project_progress_check(
                "서버등록",
                "AD-06-S01",
                "[체크실패]",
                f"media_insert 서버 등록 실패 / 원인={str(yellow_res.get('error') or 'unknown')} / 다음점검=서버 media_insert 처리",
                {
                    "cycle_id": str(data.get("cycle_id") or meta.get("cycle_id") or meta.get("client_batch_key") or ""),
                    "run_seq": data.get("run_seq") or meta.get("run_seq") or "",
                    "rank_label": str(meta.get("rank_label") or ""),
                    "owner_username": str(meta.get("owner_username") or meta.get("username") or ""),
                    "shortcode": shortcode,
                    "asset_kind": asset_kind,
                    "error_code": str(yellow_res.get("error_code") or yellow_res.get("error") or "media_insert_failed"),
                    "http_status": yellow_res.get("http_status", ""),
                    "error_stage": str(yellow_res.get("error_stage") or ""),
                    "server_error": str(yellow_res.get("error") or ""),
                    "server_message": str(yellow_res.get("message") or "")[:1000],
                    "table": str(yellow_res.get("table") or ""),
                    "missing_columns": yellow_res.get("missing_columns") if isinstance(yellow_res.get("missing_columns"), list) else str(yellow_res.get("missing_columns") or ""),
                    "next_check": "서버 media_insert 처리",
                    "cycle_stage": "SERVER_MEDIA_INSERT",
                    "blocking": False,
                },
            )
        return {
            "ok": bool(media_saved_result),
            "error": storage_error,
            "storage_ok": bool(media_saved_result),
            "media_saved": media_saved_result,
            "source_mode": str(data.get("source_mode") or meta.get("source_mode") or "MANUAL_SINGLE"),
            "file_path": file_path,
            "file_name": file_name,
            "shortcode": shortcode,
            "json_path": json_path,
            "sidecar_path": sidecar_path,
            "md5": md5,
            "sha256": sha256,
            "size": size,
            "asset_kind": asset_kind,
            "file_saved": True,
            "json_saved": bool(json_path),
            "report_ok": bool(report_result.get("ok")),
            "report_saved": bool(report_result.get("ok") and report_result.get("report_path")),
            "report_path": str(report_result.get("report_path") or ""),
            "report_json_path": str(report_result.get("snapshot_json_path") or ""),
            "report_error": str(report_result.get("error") or ""),
            "yellow": yellow_res,
            "item": item,
            "worker": "file",
        }


    def control_status(self):
        ws_cfg = self.cfg.get("control_ws") or {}
        mode = self.normalize_enter_mode()
        save_active = bool((self.get_runtime_state_snapshot().get("save_dialog_active") or {}).get("active"))
        return {
            "ok": True,
            "app": APP_NAME,
            "ws_build_version": WS_BUILD_VERSION,
            "ws_build_tag": WS_BUILD_TAG,
            "ws_label": WS_LABEL,
            "collector_id": self.collector_id,
            "user_id": self.collector_id,
            "enabled": mode != "off",
            "enter_mode": mode,
            "auto_enter_armed": self.is_auto_enter_armed(),
            "auto_enter_until": float(getattr(self, "auto_enter_until", 0.0) or 0.0),
            "ws": {"enabled": bool(ws_cfg.get("enabled", True)), "host": str(ws_cfg.get("host", "127.0.0.1")), "port": int(ws_cfg.get("port", 8771))},
            "last": self.last_label.cget("text") if hasattr(self, "last_label") else "",
            "ui_collapsed": bool(getattr(self, "ui_collapsed", False)),
            "ui_collapse_stage": int(getattr(self, "ui_collapse_stage", 0) or 0),
            "tts_level": int(getattr(self, "tts_level", 0) or 0),
            "copy_hotkey": str(getattr(self, "copy_hotkey", self.cfg.get("copy_hotkey", "ctrl+shift+1"))),
            "save_dialog_active": self.get_runtime_state_snapshot().get("save_dialog_active") or {},
            "state": "save_dialog_active" if save_active else "connected",
            "paused": False,
            "message": "저장창 처리 중" if save_active else "WS 연결됨",
            "rtc_state": self.read_rtc_state(),
            "last_media_state": getattr(self, "last_media_state", {}),
        }


    # -----------------------------
    # v123 RTC launcher / 상태 대화
    # -----------------------------
    def read_rtc_state(self):
        try:
            path = getattr(self, "rtc_state_file", "") or ""
            if not path or not os.path.exists(path):
                return {}
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
            return data if isinstance(data, dict) else {}
        except Exception:
            return {}

    def _rtc_state_pid(self) -> int:
        try:
            st = self.read_rtc_state()
            pid = int(st.get("pid") or 0) if isinstance(st, dict) else 0
            return pid if pid > 0 else 0
        except Exception:
            return 0

    def rtc_is_running(self):
        try:
            proc = getattr(self, "rtc_proc", None)
            if proc and proc.poll() is None:
                return True
            # 외부에서 이미 host/main.py가 실행되어 state_file을 쓰고 있으면 상태 표시/URL 열기는 허용한다.
            pid = self._rtc_state_pid()
            return bool(pid and self._process_exists(pid))
        except Exception:
            return False

    def _process_exists(self, pid: int) -> bool:
        try:
            pid = int(pid)
            if pid <= 0:
                return False
            if os.name == "nt":
                out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=2)
                return str(pid) in (out.stdout or "")
            os.kill(pid, 0)
            return True
        except Exception:
            return False

    def _terminate_process_tree(self, pid: int) -> bool:
        try:
            pid = int(pid)
            if pid <= 0:
                return False
            if os.name == "nt":
                subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True, timeout=5)
                return True
            try:
                os.kill(pid, 15)
                return True
            except Exception:
                return False
        except Exception:
            return False

    def rtc_default_args(self, viewer="open"):
        """원래 RTC 본체 시작점인 host/main.py를 실행한다.

        v122의 rtc_host.py는 임시 경량 호스트였고, 사용자가 쓰는 yjm_win2rtc 본체와 연결되지 않았다.
        v123 기준은 run_preview.bat의 핵심 명령과 동일한 host/main.py 실행 체인이다.
        """
        rtc_cfg = self.cfg.get("rtc") if isinstance(self.cfg.get("rtc"), dict) else {}
        ws_dir = os.path.dirname(os.path.abspath(__file__))
        st_root = os.path.dirname(ws_dir)
        rtc_root = os.path.join(st_root, "yjm_win2rtc")
        host_dir = os.path.join(rtc_root, "host")
        root_dir = rtc_root
        script = os.path.join(host_dir, "main.py")
        host = str(rtc_cfg.get("host", "0.0.0.0") or "0.0.0.0")
        # 로컬 viewer 자동 연결을 안정화하려면 HTTP/WS는 기존 RTC 포트 8790/8791을 유지한다.
        http_port = int(rtc_cfg.get("http_port", 8790) or 8790)
        ws_port = int(rtc_cfg.get("ws_port", 8791) or 8791)
        fps = int(rtc_cfg.get("fps", 12) or 12)
        quality = int(rtc_cfg.get("quality", 70) or 70)
        max_width = int(rtc_cfg.get("max_width", 1280) or 1280)
        control = str(rtc_cfg.get("control", "on") or "on").lower()
        if control not in ("on", "off"):
            control = "on"
        source = str(rtc_cfg.get("source", "desktop") or "desktop").lower()
        if source not in ("desktop", "display", "window"):
            source = "desktop"
        display_index = int(rtc_cfg.get("display_index", 0) or 0)
        viewer_mode = str(viewer or rtc_cfg.get("viewer", "open") or "open").lower()
        if viewer_mode not in ("open", "no", "manual"):
            viewer_mode = "open"
        cmd = [
            sys.executable,
            script,
            "-v",
            "--host", host,
            "--http-port", str(http_port),
            "--ws-port", str(ws_port),
            "--fps", str(fps),
            "--quality", str(quality),
            "--max-width", str(max_width),
            "--auto-start",
            "--viewer", "no",
            "--control", control,
            "--source", source,
            "--display-index", str(display_index),
            "--state-file", str(getattr(self, "rtc_state_file", "")),
        ]
        return cmd

    def start_rtc(self, viewer="open"):
        if self.rtc_is_running():
            self.ui_note("RTC 이미 실행 중")
            if viewer == "open":
                self.open_rtc_viewer_url()
            return {"ok": True, "already_running": True, "state": self.read_rtc_state()}
        try:
            try:
                if os.path.exists(self.rtc_state_file):
                    os.remove(self.rtc_state_file)
            except Exception:
                pass
            cmd = self.rtc_default_args(viewer=viewer)
            flags = 0
            if os.name == "nt":
                # python.exe 콘솔은 숨기되 Tk main window와 viewer 브라우저는 뜬다.
                flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
            cwd = root_dir
            self.rtc_proc = subprocess.Popen(cmd, cwd=cwd, creationflags=flags)
            self.worker_log("rtc", "RTC 실행(host/main.py): " + " ".join(cmd))
            self.ui_note("RTC 실행 요청")
            st = {}
            for _ in range(40):
                time.sleep(0.1)
                st = self.read_rtc_state()
                if st.get("local_url"):
                    break
            if viewer == "open":
                self.open_rtc_viewer_url()
            self.refresh_rtc_menu()
            return {"ok": True, "pid": getattr(self.rtc_proc, "pid", 0), "state": self.read_rtc_state()}
        except Exception as e:
            self.worker_log("rtc", f"RTC 실행 실패: {e}")
            self.ui_note(f"RTC 실행 실패: {e}")
            return {"ok": False, "error": str(e)}

    def stop_rtc(self):
        try:
            stopped = False
            proc = getattr(self, "rtc_proc", None)
            if proc and proc.poll() is None:
                stopped = self._terminate_process_tree(getattr(proc, "pid", 0))
                try:
                    proc.wait(timeout=2)
                except Exception:
                    pass
                self.rtc_proc = None
            else:
                pid = self._rtc_state_pid()
                if pid:
                    stopped = self._terminate_process_tree(pid)
            try:
                if os.path.exists(self.rtc_state_file):
                    os.remove(self.rtc_state_file)
            except Exception:
                pass
            if stopped:
                self.ui_note("RTC 중지 완료")
                self.worker_log("rtc", "RTC 중지 완료")
                return {"ok": True, "stopped": True}
            self.ui_note("RTC 실행 중 아님")
            return {"ok": True, "stopped": False}
        except Exception as e:
            self.ui_note(f"RTC 중지 실패: {e}")
            return {"ok": False, "error": str(e)}

    def open_rtc_viewer_url(self):
        try:
            st = self.read_rtc_state()
            url = str(st.get("local_url") or "") if isinstance(st, dict) else ""
            if not url:
                rtc_cfg = self.cfg.get("rtc") if isinstance(self.cfg.get("rtc"), dict) else {}
                host = str(rtc_cfg.get("host", "127.0.0.1") or "127.0.0.1")
                if host in ("0.0.0.0", ""):
                    host = "127.0.0.1"
                http_port = int(rtc_cfg.get("http_port", 8790) or 8790)
                url = f"http://{host}:{http_port}/viewer.html"
            import webbrowser
            webbrowser.open(url)
            self.ui_note("RTC viewer 열기")
            return {"ok": True, "url": url}
        except Exception as e:
            self.ui_note(f"RTC viewer 열기 실패: {e}")
            return {"ok": False, "error": str(e)}

    def copy_rtc_url(self):
        try:
            if not self.rtc_is_running():
                self.start_rtc(viewer="no")
            st = self.read_rtc_state()
            url = str(st.get("lan_url") or st.get("local_url") or "") if isinstance(st, dict) else ""
            if not url:
                raise RuntimeError("RTC URL 아직 없음")
            self.root.clipboard_clear()
            self.root.clipboard_append(url)
            self.root.update()
            self.ui_note("RTC URL 복사 완료")
            return {"ok": True, "url": url}
        except Exception as e:
            self.ui_note(f"RTC URL 복사 실패: {e}")
            return {"ok": False, "error": str(e)}

    def open_rtc_menu(self):
        try:
            if self.rtc_menu_win and self.rtc_menu_win.winfo_exists():
                self.rtc_menu_win.lift()
                self.refresh_rtc_menu()
                return
        except Exception:
            pass
        win = tk.Toplevel(self.root)
        self.rtc_menu_win = win
        win.title("🖥 RTC")
        win.attributes("-topmost", True)
        win.configure(bg="#111827")
        win.geometry("430x260")
        outer = tk.Frame(win, bg="#111827", padx=10, pady=10)
        outer.pack(fill="both", expand=True)
        tk.Label(outer, text="🖥 RTC / 자동 화면 싱크 진단", bg="#111827", fg="#fde68a", font=("맑은 고딕", 10, "bold")).pack(anchor="w")
        self.rtc_menu_status = tk.Label(outer, text="", bg="#111827", fg="#d1d5db", justify="left", anchor="w", font=("맑은 고딕", 9))
        self.rtc_menu_status.pack(fill="x", pady=(8, 8))
        btns = tk.Frame(outer, bg="#111827")
        btns.pack(fill="x")
        tk.Button(btns, text="RTC 열기", command=lambda: (self.start_rtc(viewer="open"), self.refresh_rtc_menu()), bg="#0e7490", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=(0, 6))
        tk.Button(btns, text="URL 복사", command=lambda: (self.copy_rtc_url(), self.refresh_rtc_menu()), bg="#374151", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=(0, 6))
        tk.Button(btns, text="RTC 중지", command=lambda: (self.stop_rtc(), self.refresh_rtc_menu()), bg="#7f1d1d", fg="white", bd=0, padx=10, pady=6).pack(side="left", padx=(0, 6))
        tk.Button(btns, text="상태 새로고침", command=self.refresh_rtc_menu, bg="#374151", fg="white", bd=0, padx=10, pady=6).pack(side="left")
        tk.Label(outer, text="쓸데없이 모양만 있는 메뉴는 넣지 않는다. viewer 오른쪽 패널의 ?에서 명령 목록을 본다.", bg="#111827", fg="#9ca3af", wraplength=390, justify="left").pack(anchor="w", pady=(12, 0))
        self.refresh_rtc_menu()

    def refresh_rtc_menu(self):
        try:
            if not (self.rtc_menu_win and self.rtc_menu_win.winfo_exists()):
                return
            running = self.rtc_is_running()
            st = self.read_rtc_state()
            cap = st.get("capture") if isinstance(st.get("capture"), dict) else {}
            marker = st.get("marker") if isinstance(st.get("marker"), dict) else {}
            text = [
                f"실행 상태: {'실행중' if running else '꺼짐'}",
                f"URL: {st.get('local_url','-') if st else '-'}",
                f"capture: {cap.get('mode','-')} {cap.get('rect',{}) if cap else ''}",
                f"marker: {marker.get('found','-')}/4 reason={marker.get('reason','') if marker else ''}",
            ]
            self.rtc_menu_status.config(text="\n".join(text))
        except Exception:
            pass

    def save_dialog_state(self, is_open, source, dialogs):
        """Keep Save As diagnostics in memory without making dialog automation a required download path."""
        state = {
            "open": bool(is_open),
            "source": str(source or ""),
            "dialogs": list(dialogs or []),
            "updated_at": now_text(),
        }
        try:
            with self.runtime_state_guard:
                self.runtime_state["save_dialog"] = state
        except Exception:
            pass
        igblue_runtime_set("save_dialog", state)
        return state

    def accept_once(self, req=None):
        target_titles = self.cfg.get("target_titles", [])
        match_contains = bool(self.cfg.get("match_contains", False))
        expected = dict(getattr(self, "auto_enter_expected", {}) or {})
        hwnds = enum_matching_dialogs(target_titles, match_contains, expected, self.is_auto_enter_armed())
        if not hwnds:
            self.save_dialog_state(False, "accept_once_no_dialog", [])
            return {"ok": True, "acted": False, "reason": "no_matching_dialog", "count": 0}
        hwnd = hwnds[0]
        title = get_window_text(hwnd)
        self.save_dialog_state(True, "accept_once", [{"hwnd": int(hwnd), "title": title}])
        ok, method = auto_accept_save_dialog(hwnd, self.cfg, expected)
        self.acted[hwnd] = time.time()
        if method in ("duplicate_suffix_cancelled", "duplicate_confirm_no_clicked", "duplicate_confirm_idno", "duplicate_confirm_cancel", "duplicate_confirm_escape"):
            rank_label = str((getattr(self, "last_media_state", {}) or {}).get("rank_label") or "No---")
            self.last_label.config(text=f"{rank_label} 중복 저장 확인 → 아니요")
        else:
            self.last_label.config(text=f"원격 처리: {title} / {method}" if ok else f"원격 실패: {title} / {method}")
        return {"ok": bool(ok), "acted": bool(ok), "title": title, "method": method, "count": len(hwnds)}

    def toggle_enabled(self):
        self.cycle_enter_mode()

    def start_move(self, event):
        self.drag_start_x = event.x
        self.drag_start_y = event.y
        try:
            self.drag_pointer_start_x = self.root.winfo_pointerx()
            self.drag_pointer_start_y = self.root.winfo_pointery()
        except Exception:
            self.drag_pointer_start_x = 0
            self.drag_pointer_start_y = 0
        self.drag_moved = False

    def do_move(self, event):
        x = self.root.winfo_pointerx() - self.drag_start_x
        y = self.root.winfo_pointery() - self.drag_start_y
        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
        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_window_state(save=True)

    def _cancel_after_id(self, attr):
        try:
            after_id = getattr(self, attr, None)
            if after_id:
                try:
                    self.root.after_cancel(after_id)
                except Exception:
                    pass
            setattr(self, attr, None)
        except Exception:
            pass

    def copy_status_double_click(self, event=None):
        """Mobile remote helper: double-click WS text or the collapsed ㅁ button to copy Ctrl+Shift+1 status."""
        try:
            self._cancel_after_id("_ws_label_single_click_after")
            self._cancel_after_id("_fold_single_click_after")
            self.drag_moved = False
            self.copy_current_status_to_clipboard("double_click")
        except Exception as e:
            try:
                self.ui_note(f"더블클릭 복사 실패: {e}")
            except Exception:
                pass
        return "break"

    def _run_ws_label_single_click(self):
        self._ws_label_single_click_after = None
        try:
            self.toggle_ui_compact()
        except Exception:
            pass

    def _run_fold_single_click(self):
        self._fold_single_click_after = None
        try:
            self.set_ui_full_collapsed()
        except Exception:
            pass

    def ws_label_release(self, event):
        moved = bool(getattr(self, "drag_moved", False))
        self.end_move(event)
        if not moved:
            self._cancel_after_id("_ws_label_single_click_after")
            self._ws_label_single_click_after = self.root.after(260, self._run_ws_label_single_click)
        return "break"

    def fold_button_release(self, event):
        moved = bool(getattr(self, "drag_moved", False))
        self.end_move(event)
        if not moved:
            self._cancel_after_id("_fold_single_click_after")
            self._fold_single_click_after = self.root.after(260, self._run_fold_single_click)
        return "break"

    def scan_loop(self):
        try:
            self.scan_once()
        finally:
            poll_ms = max(100, int(self.cfg.get("poll_ms", 250)))
            self.root.after(poll_ms, self.scan_loop)

    def scan_once(self):
        now = time.time()

        # 닫힌 창 기록 정리
        for hwnd in list(self.acted.keys()):
            if not user32.IsWindow(hwnd):
                self.acted.pop(hwnd, None)

        mode = self.normalize_enter_mode()
        if mode == "off":
            return
        auto_unarmed = bool(mode == "auto" and not self.is_auto_enter_armed())
        expected = dict(getattr(self, "auto_enter_expected", {}) or {})

        hwnds = enum_matching_dialogs(
            self.cfg.get("target_titles", []),
            bool(self.cfg.get("match_contains", False)),
            expected,
            self.is_auto_enter_armed(),
        )
        if not hwnds:
            self.save_dialog_state(False, "scan_no_dialog", [])
            return

        cooldown = float(self.cfg.get("cooldown_sec", 2.5))
        for hwnd in hwnds:
            last = self.acted.get(hwnd, 0)
            if now - last < cooldown:
                continue

            title = get_window_text(hwnd)
            duplicate_info = save_confirm_duplicate_info(hwnd)
            _edit_hwnd, filename = find_save_dialog_filename_edit(hwnd, expected)
            ig_match, ig_match_reason = instagram_download_dialog_match(filename, expected)
            if not duplicate_info.get("matched"):
                if auto_unarmed and not ig_match:
                    continue
                if mode == "auto" and self.is_auto_enter_armed() and filename and not ig_match:
                    # 다른 프로그램의 Save As가 동시에 떠도 Instagram 파일명이 아니면 건드리지 않는다.
                    continue
            self.save_dialog_state(True, "scan_once", [{"hwnd": int(hwnd), "title": title, "filename": filename, "instagram_match": ig_match, "match_reason": ig_match_reason}])
            ok, method = auto_accept_save_dialog(hwnd, self.cfg, expected)
            self.acted[hwnd] = now

            if method in ("duplicate_suffix_cancelled", "duplicate_confirm_no_clicked", "duplicate_confirm_idno", "duplicate_confirm_cancel", "duplicate_confirm_escape"):
                rank_label = str((getattr(self, "last_media_state", {}) or {}).get("rank_label") or "No---")
                self.last_label.config(text=f"{rank_label} 중복 저장 확인 → 아니요")
                self.worker_log("file", f"{rank_label} 중복 저장 확인 → 아니요 method={method}")
                if mode == "auto":
                    self.clear_auto_enter_arm()
            elif ok:
                self.last_label.config(text=f"처리: {title} / {method}")
                if mode == "auto":
                    self.clear_auto_enter_arm()
            else:
                self.last_label.config(text=f"실패: {title} / {method}")
                if mode == "auto":
                    self.clear_auto_enter_arm()

    def current_folder_reconcile_paths(self):
        try:
            media = getattr(self, "last_media_state", {}) if isinstance(getattr(self, "last_media_state", {}), dict) else {}
            file_path = str(media.get("file_path") or self.cfg.get("last_download_file_path") or "").strip()
            json_path = str(media.get("json_path") or self.cfg.get("last_folder_json") or self.cfg.get("last_download_json_path") or "").strip()
            folder = str(self.cfg.get("last_media_folder") or "").strip()
            if file_path:
                folder = os.path.dirname(os.path.abspath(file_path))
            return {"media_folder": folder, "json_path": json_path, "file_path": file_path}
        except Exception:
            return {"media_folder": "", "json_path": "", "file_path": ""}

    def run_folder_reconcile(self, media_folder="", json_path="", apply=False):
        try:
            try:
                from . import ws8771_folder_reconcile as folder_reconcile  # type: ignore
            except Exception:
                import ws8771_folder_reconcile as folder_reconcile  # type: ignore
            paths = self.current_folder_reconcile_paths()
            folder = str(media_folder or paths.get("media_folder") or "").strip()
            jpath = str(json_path or paths.get("json_path") or "").strip()
            res = folder_reconcile.reconcile_folder_json(folder, jpath, apply=bool(apply))
            if isinstance(res, dict) and res.get("ok"):
                self.cfg["last_folder_reconcile"] = {k: res.get(k) for k in ("json_path", "media_folder", "item_count", "actual_file_count", "missing_json_count", "extra_file_count", "duplicate_suffix_json_count", "applied", "backup_path", "removed_missing_json_count") if k in res}
                if res.get("json_path"):
                    self.cfg["last_folder_json"] = str(res.get("json_path") or "")
                    self.cfg["last_download_json_path"] = str(res.get("json_path") or "")
                if res.get("media_folder"):
                    self.cfg["last_media_folder"] = str(res.get("media_folder") or "")
                save_config(self.cfg)
                self.set_runtime_state("folder_reconcile_last", res)
                try:
                    self.worker_log("folder", f"폴더정리 applied={res.get('applied')} missing={res.get('missing_json_count')} extra={res.get('extra_file_count')} json={res.get('json_path')}")
                except Exception:
                    pass
            return res
        except Exception as e:
            return {"ok": False, "error": str(e), "act": "folder_reconcile"}

    def run_folder_cleanup_from_settings(self, parent=None):
        try:
            try:
                from . import ws8771_folder_reconcile as folder_reconcile  # type: ignore
            except Exception:
                import ws8771_folder_reconcile as folder_reconcile  # type: ignore
            preview = self.run_folder_reconcile(apply=False)
            summary = folder_reconcile.format_summary(preview)
            if not preview.get("ok"):
                messagebox.showerror("폴더정리", summary, parent=parent)
                return
            missing = int(preview.get("missing_json_count") or 0)
            if missing > 0:
                do_apply = messagebox.askyesno(
                    "폴더정리",
                    summary + "\n\n백업을 만든 뒤 실제 없는 JSON 항목을 제거할까요?",
                    parent=parent,
                )
                if do_apply:
                    applied = self.run_folder_reconcile(apply=True)
                    messagebox.showinfo("폴더정리", folder_reconcile.format_summary(applied), parent=parent)
                    try:
                        self.last_label.config(text=f"폴더정리 완료: 제거 {applied.get('removed_missing_json_count', 0)}")
                    except Exception:
                        pass
                else:
                    try:
                        self.last_label.config(text=f"폴더정리 점검: 실제 없는 JSON {missing}개")
                    except Exception:
                        pass
            else:
                messagebox.showinfo("폴더정리", summary, parent=parent)
                try:
                    self.last_label.config(text=f"폴더정리 점검 OK / extra={preview.get('extra_file_count', 0)}")
                except Exception:
                    pass
        except Exception as e:
            messagebox.showerror("폴더정리 오류", str(e), parent=parent)

    def open_settings(self):
        win = tk.Toplevel(self.root)
        win.title("WS 설정 - PY TTS 포함")
        win.attributes("-topmost", True)
        win.configure(bg="#f3f4f6")
        win.resizable(False, False)

        tk.Label(win, text="투명도", bg="#f3f4f6").grid(row=0, column=0, sticky="w", padx=10, pady=(10, 4))
        alpha_var = tk.DoubleVar(value=float(self.cfg.get("alpha", 0.78)) * 100)
        alpha_scale = tk.Scale(win, from_=35, to=100, orient="horizontal", variable=alpha_var, length=260)
        alpha_scale.grid(row=0, column=1, padx=10, pady=(10, 4))

        tk.Label(win, text="검사 간격(ms)", bg="#f3f4f6").grid(row=1, column=0, sticky="w", padx=10, pady=4)
        poll_var = tk.StringVar(value=str(self.cfg.get("poll_ms", 250)))
        tk.Entry(win, textvariable=poll_var, width=32).grid(row=1, column=1, padx=10, pady=4)

        tk.Label(win, text="WS 창 가로폭", bg="#f3f4f6", fg="#0f766e", font=("맑은 고딕", 9, "bold")).grid(row=2, column=0, sticky="w", padx=10, pady=4)
        ws_width_var = tk.StringVar(value=str(int(getattr(self, "ui_expanded_width", self.cfg.get("ui_expanded_width", 880)) or 880)))
        tk.Entry(win, textvariable=ws_width_var, width=32).grid(row=2, column=1, padx=10, pady=4, sticky="we")

        tk.Label(win, text="PY TTS 단계\n(로컬 음성)", bg="#f3f4f6", fg="#1d4ed8", font=("맑은 고딕", 9, "bold"), justify="left").grid(row=3, column=0, sticky="w", padx=10, pady=4)
        tts_labels = ["0 미사용", "1 중요", "2 진행", "3 디버그"]
        tts_cur = max(0, min(3, int(getattr(self, "tts_level", self.cfg.get("tts_level", 0)) or 0)))
        tts_var = tk.StringVar(value=tts_labels[tts_cur])
        tts_menu = tk.OptionMenu(win, tts_var, *tts_labels)
        tts_menu.configure(width=30, bg="#ffffff", activebackground="#e5e7eb")
        tts_menu.grid(row=3, column=1, padx=10, pady=4, sticky="we")

        tk.Label(win, text="클립보드 복사 단축키", bg="#f3f4f6", fg="#1d4ed8", font=("맑은 고딕", 9, "bold")).grid(row=4, column=0, sticky="w", padx=10, pady=4)
        copy_hotkey_var = tk.StringVar(value=normalize_hotkey_text(self.cfg.get("copy_hotkey", "ctrl+shift+1")))
        tk.Entry(win, textvariable=copy_hotkey_var, width=32).grid(row=4, column=1, padx=10, pady=4, sticky="we")

        tk.Label(win, text="대상 창 제목", bg="#f3f4f6").grid(row=5, column=0, sticky="nw", padx=10, pady=4)
        titles_text = tk.Text(win, width=36, height=4)
        titles_text.insert("1.0", "\n".join(normalize_list(self.cfg.get("target_titles", []))))
        titles_text.grid(row=5, column=1, padx=10, pady=4)

        contains_var = tk.BooleanVar(value=bool(self.cfg.get("match_contains", False)))
        tk.Checkbutton(
            win,
            text="제목 전체 일치가 아니라 포함으로 찾기",
            variable=contains_var,
            bg="#f3f4f6"
        ).grid(row=6, column=1, sticky="w", padx=10, pady=4)

        tk.Label(win, text="버튼 글자", bg="#f3f4f6").grid(row=7, column=0, sticky="nw", padx=10, pady=4)
        btn_text = tk.Text(win, width=36, height=3)
        btn_text.insert("1.0", "\n".join(normalize_list(self.cfg.get("button_keywords", []))))
        btn_text.grid(row=7, column=1, padx=10, pady=4)

        idok_var = tk.BooleanVar(value=bool(self.cfg.get("try_idok_after_button_fail", True)))
        enter_var = tk.BooleanVar(value=bool(self.cfg.get("try_enter_after_idok", True)))
        mode_labels = ["끄기", "자동", "켜기"]
        mode_map = {"off": "끄기", "auto": "자동", "on": "켜기"}
        mode_rev = {"끄기": "off", "자동": "auto", "켜기": "on"}
        enter_mode_var = tk.StringVar(value=mode_map.get(self.normalize_enter_mode(), "끄기"))

        tk.Label(win, text="저장창 자동처리", bg="#f3f4f6", fg="#92400e", font=("맑은 고딕", 9, "bold")).grid(row=8, column=0, sticky="w", padx=10, pady=4)
        mode_menu = tk.OptionMenu(win, enter_mode_var, *mode_labels)
        mode_menu.configure(width=30, bg="#fef3c7", activebackground="#fde68a")
        mode_menu.grid(row=8, column=1, padx=10, pady=4, sticky="we")

        tk.Checkbutton(win, text="버튼 못 찾으면 IDOK 명령 시도", variable=idok_var, bg="#f3f4f6").grid(
            row=9, column=1, sticky="w", padx=10, pady=2
        )
        tk.Checkbutton(win, text="마지막에 Enter 키 시도", variable=enter_var, bg="#f3f4f6").grid(
            row=10, column=1, sticky="w", padx=10, pady=2
        )

        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)))
        log_row = tk.Frame(win, bg="#f3f4f6")
        log_row.grid(row=12, column=1, sticky="w", padx=10, pady=4)
        tk.Checkbutton(log_row, text="실행 로그 미사용", variable=no_run_log_var, bg="#f3f4f6").pack(side="left")
        tk.Checkbutton(log_row, text="에러 로그 미사용", variable=no_err_log_var, bg="#f3f4f6").pack(side="left", padx=(8,0))
        tk.Label(log_row, text="유지", bg="#f3f4f6").pack(side="left", padx=(8,2))
        tk.Entry(log_row, textvariable=keep_log_var, width=5).pack(side="left")

        hint = (
            "F8: 끄기→자동→켜기 순환, ESC: 종료\n"
            "자동은 IG 자동다운로드 클릭 직후 제한 시간 동안만 저장창 처리를 허용합니다.\n"
            "로그는 logs 폴더의 run_log/err_log이며 유지 개수는 설정값을 따릅니다."
        )
        tk.Label(win, text=hint, bg="#f3f4f6", fg="#374151", justify="left").grid(
            row=13, column=0, columnspan=2, sticky="w", padx=10, pady=(8, 4)
        )

        folder_paths = self.current_folder_reconcile_paths()
        folder_hint = str(folder_paths.get("json_path") or folder_paths.get("media_folder") or "최근 저장 경로 없음")
        if len(folder_hint) > 62:
            folder_hint = "..." + folder_hint[-59:]
        tk.Label(win, text="폴더 정리", bg="#f3f4f6", fg="#065f46", font=("맑은 고딕", 9, "bold")).grid(row=14, column=0, sticky="w", padx=10, pady=(8, 2))
        folder_frame = tk.Frame(win, bg="#f3f4f6")
        folder_frame.grid(row=14, column=1, sticky="we", padx=10, pady=(8, 2))
        tk.Button(folder_frame, text="폴더정리", command=lambda: self.run_folder_cleanup_from_settings(win), bg="#047857", fg="white", bd=0, padx=10, pady=5).pack(side="left", padx=(0, 8))
        tk.Label(folder_frame, text=folder_hint, bg="#f3f4f6", fg="#374151", anchor="w", justify="left").pack(side="left")

        def apply():
            try:
                alpha = max(0.35, min(1.0, float(alpha_var.get()) / 100.0))
                poll = max(100, int(str(poll_var.get()).strip()))
                titles = normalize_list(titles_text.get("1.0", "end"))
                btns = normalize_list(btn_text.get("1.0", "end"))

                if not titles:
                    messagebox.showwarning("확인", "대상 창 제목이 비어 있으면 안 됩니다.")
                    return
                if not btns:
                    messagebox.showwarning("확인", "버튼 글자가 비어 있으면 안 됩니다.")
                    return

                self.cfg["alpha"] = alpha
                self.cfg["poll_ms"] = poll
                try:
                    self.ui_expanded_width = max(360, min(1800, int(str(ws_width_var.get()).strip() or self.ui_expanded_width)))
                except Exception:
                    self.ui_expanded_width = max(360, int(getattr(self, "ui_expanded_width", 880) or 880))
                self.cfg["ui_expanded_width"] = int(self.ui_expanded_width)
                self.cfg["target_titles"] = titles
                self.cfg["match_contains"] = bool(contains_var.get())
                self.cfg["button_keywords"] = btns
                self.cfg["try_idok_after_button_fail"] = bool(idok_var.get())
                self.cfg["try_enter_after_idok"] = bool(enter_var.get())
                self.set_enter_mode(mode_rev.get(str(enter_mode_var.get()), "off"), save=False)
                self.copy_hotkey = normalize_hotkey_text(copy_hotkey_var.get())
                self.cfg["copy_hotkey"] = self.copy_hotkey
                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", "ws_run_log")
                log_cfg.setdefault("err_prefix", "ws_err_log")
                log_cfg.pop("use_manager_unified", None)
                self.cfg["python_logs"] = log_cfg
                self.cfg["python_file_logs"] = {"enabled": True, "keep_files": log_cfg["keep_count"], "root_dir": "logs", "prefix": "ws8771"}
                try:
                    tts_level = int(str(tts_var.get()).split()[0])
                except Exception:
                    tts_level = 0
                tts_level = max(0, min(3, tts_level))
                old_tts_level = int(getattr(self, "tts_level", 0) or 0)
                self.tts_level = tts_level
                self.cfg["tts_level"] = tts_level
                self.cfg["tts_level_user_configured"] = True
                if tts_level == 0:
                    self.stop_tts()
                save_config(self.cfg)
                try:
                    self.ui_note(f"PY TTS 단계: {tts_labels[tts_level]}")
                except Exception:
                    pass
                self.root.attributes("-alpha", alpha)
                self.apply_ui_layout(save=True)
                self.worker_log("setting", f"WS 설정 저장: alpha={alpha:.2f} poll={poll} width={self.ui_expanded_width} stage={self.ui_collapse_stage} tts={tts_level} hotkey={self.copy_hotkey}")
                self.py_state("setting_saved", f"WS 설정을 저장했습니다. TTS {tts_level}단계", level="info", tts_level=2)
                win.destroy()
            except Exception as e:
                messagebox.showerror("오류", str(e))

        bottom = tk.Frame(win, bg="#f3f4f6")
        bottom.grid(row=15, column=0, columnspan=2, sticky="e", padx=10, pady=10)

        tk.Button(bottom, text="저장", command=apply, width=10).pack(side="left", padx=4)
        tk.Button(bottom, text="닫기", command=win.destroy, width=10).pack(side="left", padx=4)

    def quit_app(self):
        if bool(getattr(self, "_intentional_shutdown_started", False)):
            return
        self._intentional_shutdown_started = True
        self.save_window_state(save=True)
        self.request_emergency_stop("ui_x_close")
        marker = write_ws_intentional_stop_marker("ui_x_close")
        try:
            print(f"[{now_text()}] WS_INTENTIONAL_STOP marker={marker} pid={os.getpid()}", flush=True)
        except Exception:
            pass
        try:
            self.stop_workers.set()
        except Exception:
            pass
        try:
            if self.control_server:
                self.control_server.stop()
        except Exception:
            pass
        try:
            self.root.destroy()
        finally:
            try:
                _close_python_file_logs()
            except Exception:
                pass

    def run(self):
        self.root.mainloop()


def main():
    global RUNTIME_VERBOSE, RUNTIME_USER
    args = parse_cli_args()
    RUNTIME_VERBOSE = bool(args.verbose)
    RUNTIME_USER = safe_path_part(args.collector_id or default_runtime_user(), "local_pc")
    startup_cfg = load_config()
    reset_python_log_files_on_start(startup_cfg)
    if not bool(getattr(args, "no_single_instance", False)):
        ws8771_acquire_single_instance()
    vlog(f"CLI parsed verbose={RUNTIME_VERBOSE} user={RUNTIME_USER}")
    app = FloatingApp(collector_id=RUNTIME_USER, verbose=RUNTIME_VERBOSE)
    app.run()


if __name__ == "__main__":
    main()
