# -*- 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 json
import os
import sys
import time
import tkinter as tk
import base64
import hashlib
import queue
import socket
import struct
import threading
import traceback
import urllib.parse
import urllib.request
from datetime import datetime
from tkinter import messagebox


APP_NAME = "YJM Save Dialog Auto Enter"
CONFIG_FILE = "yjm_save_dialog_auto_enter_config.json"

DEFAULT_CONFIG = {
    "enabled": False,
    "alpha": 0.78,
    "x": 80,
    "y": 80,
    "poll_ms": 250,
    "cooldown_sec": 2.5,
    "match_contains": False,
    "target_titles": [
        "다른 이름으로 저장",
        "Save As"
    ],
    "button_keywords": [
        "저장",
        "Save"
    ],
    "try_idok_after_button_fail": True,
    "try_enter_after_idok": True,
    "ui_collapsed": False,
    "ui_expanded_width": 820,
    "ui_collapsed_width": 190,
    "ui_bar_height": 34,
    "control_ws": {
        "enabled": True,
        "host": "127.0.0.1",
        "port": 8771,
        "token": "auto_enter_local_SEC_20260623"
    },
    "yellow_api": {
        "enabled": True,
        "url": "https://n.yjm.kr/i/yellow.php",
        "token": "ABC1234XYZ",
        "timeout_sec": 10
    }
}


# -----------------------------
# 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 v68 RTC-owned one-line-toggle-status"
    )
    parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="터미널에 WS 접속/요청/API 호출 로그를 자세히 출력합니다."
    )
    parser.add_argument(
        "-u", "--user",
        dest="collector_id",
        default="",
        help="수집 사용자/작업자 ID입니다. 생략하면 Windows 컴퓨터 이름을 사용합니다."
    )
    return parser.parse_args(argv)


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

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

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.keybd_event.argtypes = [ctypes.c_ubyte, ctypes.c_ubyte, ctypes.c_ulong, ctypes.c_ulong]
user32.keybd_event.restype = None
user32.SetCursorPos.argtypes = [ctypes.c_int, ctypes.c_int]
user32.SetCursorPos.restype = wintypes.BOOL
user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)]
user32.GetCursorPos.restype = wintypes.BOOL
user32.mouse_event.argtypes = [ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong, ctypes.c_ulong]
user32.mouse_event.restype = None


SW_RESTORE = 9
BM_CLICK = 0x00F5
WM_COMMAND = 0x0111
IDOK = 1
VK_RETURN = 0x0D
KEYEVENTF_KEYUP = 0x0002
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004



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):
    found = []

    @EnumWindowsProc
    def callback(hwnd, lparam):
        if user32.IsWindowVisible(hwnd):
            title = get_window_text(hwnd)
            if window_title_matches(title, target_titles, match_contains):
                found.append(int(hwnd))
        return True

    user32.EnumWindows(callback, 0)
    return found


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 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 get_cursor_pos():
    pt = wintypes.POINT()
    if not user32.GetCursorPos(ctypes.byref(pt)):
        return None
    return {"x": int(pt.x), "y": int(pt.y)}


def mouse_left_click():
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    time.sleep(0.04)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)


def mouse_move_smooth(x, y, duration_sec=0.35, steps=24):
    x = int(round(float(x)))
    y = int(round(float(y)))
    start = get_cursor_pos() or {"x": x, "y": y}
    sx, sy = int(start["x"]), int(start["y"])
    duration_sec = max(0.0, min(5.0, float(duration_sec or 0)))
    steps = max(1, min(120, int(steps or 1)))
    if duration_sec <= 0 or steps <= 1:
        user32.SetCursorPos(x, y)
        return
    delay = duration_sec / steps
    for i in range(1, steps + 1):
        nx = sx + (x - sx) * i / steps
        ny = sy + (y - sy) * i / steps
        user32.SetCursorPos(int(round(nx)), int(round(ny)))
        time.sleep(delay)


def mouse_wander_around(x, y, radius=12, count=4, step_duration=0.18):
    import random
    x = int(round(float(x)))
    y = int(round(float(y)))
    radius = max(0, min(80, int(radius or 0)))
    count = max(0, min(30, int(count or 0)))
    for _ in range(count):
        dx = random.randint(-radius, radius) if radius else 0
        dy = random.randint(-radius, radius) if radius else 0
        mouse_move_smooth(x + dx, y + dy, step_duration, 8)


def igblue_log_dir():
    path = os.path.join(script_dir(), "logs")
    os.makedirs(path, exist_ok=True)
    return path


def igblue_append_jsonl(filename, payload):
    try:
        path = os.path.join(igblue_log_dir(), safe_path_part(filename, "ig_auto_download.jsonl"))
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n")
        return path
    except Exception:
        return ""

def igblue_hover_current_path():
    try:
        return os.path.join(igblue_log_dir(), "ig_rtc_hover_status_current.json")
    except Exception:
        return "ig_rtc_hover_status_current.json"


def igblue_write_hover_status(data):
    data = data if isinstance(data, dict) else {}
    doc = dict(data)
    doc.setdefault("schema", "ig_hover_target_v63")
    doc["received_at"] = now_text()
    try:
        path = igblue_hover_current_path()
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(doc, f, ensure_ascii=False, sort_keys=True, indent=2)
        os.replace(tmp, path)
    except Exception:
        pass
    try:
        if doc.get("click_allowed") or doc.get("zone") in ("download_button", "thumbnail"):
            igblue_append_jsonl("ig_rtc_hover_status.jsonl", doc)
    except Exception:
        pass
    return doc


def igblue_browser_origin_current_path():
    try:
        return os.path.join(igblue_log_dir(), "ig_rtc_browser_origin_current.json")
    except Exception:
        return "ig_rtc_browser_origin_current.json"


def igblue_write_browser_origin(data):
    data = data if isinstance(data, dict) else {}
    doc = dict(data)
    doc.setdefault("schema", "ig_browser_origin_v63")
    doc["received_at"] = now_text()
    try:
        path = igblue_browser_origin_current_path()
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(doc, f, ensure_ascii=False, sort_keys=True, indent=2)
        os.replace(tmp, path)
    except Exception:
        pass
    try:
        igblue_append_jsonl("ig_rtc_browser_origin.jsonl", doc)
    except Exception:
        pass
    return doc


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 igblue_center_from_rect(rect):
    rect = rect if isinstance(rect, dict) else {}
    cx = rect.get("cx")
    cy = rect.get("cy")
    if cx is None:
        cx = _ig_num(rect.get("x", rect.get("left", 0))) + _ig_num(rect.get("w", rect.get("width", 0))) / 2.0
    if cy is None:
        cy = _ig_num(rect.get("y", rect.get("top", 0))) + _ig_num(rect.get("h", rect.get("height", 0))) / 2.0
    return int(round(_ig_num(cx, 0))), int(round(_ig_num(cy, 0)))




def igblue_load_rtc_click_module():
    """Load RTC-owned Instagram click module from /_St/yjm_win2rtc/host."""
    candidates = []
    env_root = os.environ.get("IG_RTC_ROOT") or os.environ.get("YJM_RTC_ROOT")
    if env_root:
        candidates.append(os.path.join(env_root, "host"))
    # script_dir(): D:\_St\AIWK_PC\domains\www.instagram.com
    st_root = os.path.abspath(os.path.join(script_dir(), "..", "..", ".."))
    candidates.append(os.path.join(st_root, "yjm_win2rtc", "host"))
    candidates.append(r"D:\_St\yjm_win2rtc\host")
    last_err = ""
    for host_dir in candidates:
        try:
            if host_dir and os.path.isdir(host_dir):
                if host_dir not in sys.path:
                    sys.path.insert(0, host_dir)
                import ig_auto_click_download
                return ig_auto_click_download
        except Exception as e:
            last_err = str(e)
    raise RuntimeError("rtc_click_module_not_found " + last_err)

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):
    """
    1순위: '저장' 버튼 직접 클릭
    2순위: WM_COMMAND / IDOK
    3순위: 포커스 후 Enter
    """
    if not user32.IsWindow(hwnd):
        return False, "window_gone"

    activate_window(hwnd)
    time.sleep(0.05)

    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"

    if cfg.get("try_enter_after_idok", True):
        press_enter_key()
        return True, "enter"

    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):
                cfg.update(old)
        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 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 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 script_dir():
    """Runtime data/config directory.

    v58 큰수술: ws8771 script can live under yjm_win2rtc/host, but Instagram
    domain data/log/config remain under AIWK_PC/domains/www.instagram.com when
    IG_DOMAIN_DIR or AIWK_IG_DOMAIN_DIR is provided by run_instagram_auto.bat.
    """
    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):
    root = safe_path_part(data.get("root") or "")
    account = str(data.get("account") or "instagram").replace("\\", "/")
    account_parts = [safe_path_part(x, "") for x in account.split("/") if safe_path_part(x, "")]
    base = os.path.join(os.path.expanduser("~"), "Downloads")
    return os.path.join(base, root, *account_parts) if root else os.path.join(base, *account_parts)



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 "")
    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", ""))
        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 {"exists": True, "match_by": "local_json_key", "json_path": json_path, "item": items.get(k), "shortcode_asset_key": sc_asset}
    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 {"exists": True, "match_by": "local_json_shortcode_asset", "json_path": json_path, "files": file_list, "shortcode_asset_key": sc_asset}
    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 {"exists": True, "match_by": "local_json_shortcode_asset", "json_path": json_path, "item": item, "shortcode_asset_key": sc_asset}
        if sha256 and str(item.get("sha256") or "").lower() == sha256:
            return {"exists": True, "match_by": "local_json_sha256", "json_path": json_path, "item": item}
        if md5 and str(item.get("md5") or "").lower() == md5:
            return {"exists": True, "match_by": "local_json_md5", "json_path": json_path, "item": item}
        if file_name and str(item.get("file_name") or "").lower() == file_name:
            return {"exists": True, "match_by": "local_json_file_name", "json_path": json_path, "item": item}
        if original_name and str(item.get("instagram_original_filename") or "").lower() == original_name:
            return {"exists": True, "match_by": "local_json_instagram_original_filename", "json_path": json_path, "item": item}
        if original_name_with_ext and str(item.get("instagram_original_filename_with_ext") or "").lower() == original_name_with_ext:
            return {"exists": True, "match_by": "local_json_instagram_original_filename_with_ext", "json_path": json_path, "item": item}
    return {"exists": False, "json_path": json_path, "shortcode_asset_key": sc_asset}


def yellow_api_call(cfg, mode, fields):
    api = cfg.get("yellow_api") or {}
    if not bool(api.get("enabled", True)):
        vlog(f"YELLOW disabled mode={mode}")
        return {"ok": False, "disabled": True, "error": "yellow_api_disabled"}
    url = str(api.get("url") or "").strip()
    token = str(api.get("token") or "").strip()
    if not url:
        vlog(f"YELLOW url empty mode={mode}")
        return {"ok": False, "error": "yellow_api_url_empty"}
    payload = {"mode": mode, "token": token}
    payload.update({k: v for k, v in (fields or {}).items() if v is not None})
    log_payload = dict(payload)
    if "token" in log_payload:
        log_payload["token"] = mask_secret(log_payload.get("token"))
    vlog(f"YELLOW request mode={mode} url={url} payload={short_json_for_log(log_payload)}")
    body = urllib.parse.urlencode(payload).encode("utf-8")
    timeout = float(api.get("timeout_sec") or 10)
    req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as res:
            raw = res.read().decode("utf-8", errors="replace")
            try:
                parsed = json.loads(raw)
            except Exception:
                parsed = {"ok": False, "raw": raw, "error": "bad_json_from_yellow"}
            parsed["http_status"] = getattr(res, "status", None)
            vlog(f"YELLOW response mode={mode} http={parsed.get('http_status')} ok={parsed.get('ok')} error={parsed.get('error', '')} exists={parsed.get('exists', '')}")
            return parsed
    except Exception as e:
        vlog(f"YELLOW error mode={mode} error={e}")
        return {"ok": False, "error": str(e), "error_code": "yellow_api_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")),
    }



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)
    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 ""),
        "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,
        "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(),
    }
    key = shortcode_asset_key(item.get("shortcode"), item.get("asset_kind"), item.get("media_index")) or item["sha256"] or item["md5"] or file_name
    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["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"
                    if isinstance(req, dict):
                        log_req = {k: v for k, v in req.items() if k not in ("token",)}
                        if "token" in req:
                            log_req["token"] = mask_secret(req.get("token"))
                        self.log(f"WS request addr={addr} act={act} req={short_json_for_log(log_req)}")
                    else:
                        self.log(f"WS request addr={addr} act={act}")
                    resp = self.request_handler(req)
                    self.log(f"WS response addr={addr} act={act} ok={resp.get('ok') if isinstance(resp, dict) else ''} error={resp.get('error', '') if isinstance(resp, dict) else ''}")
                    _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.control_server = None
        self.drag_start_x = 0
        self.drag_start_y = 0

        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)))
        self.root.configure(bg="#20242a")

        x = int(self.cfg.get("x", 80))
        y = int(self.cfg.get("y", 80))
        self.ui_collapsed = bool(self.cfg.get("ui_collapsed", False))
        self.ui_expanded_width = int(self.cfg.get("ui_expanded_width", 820) or 820)
        self.ui_collapsed_width = int(self.cfg.get("ui_collapsed_width", 190) or 190)
        self.ui_bar_height = int(self.cfg.get("ui_bar_height", 34) or 34)
        self.root.geometry(f"{self.current_ui_width()}x{self.ui_bar_height}+{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, 1), (3, 0), (4, 0), (5, 0), (6, 0)):
            self.frame.grid_columnconfigure(col, weight=weight)

        # v63: RTC 작은창 좌상단 꼭지점이 수동 기준점이다.
        # 사용자는 노란 직각삼각형의 꼭지점을 Chrome DOM 0,0 위치에 맞춘다.
        self.origin_marker = tk.Canvas(
            self.frame, width=18, height=18, bg="#20242a",
            highlightthickness=0, bd=0
        )
        self.origin_marker.place(x=0, y=0)
        self.origin_marker.create_polygon(0, 0, 18, 0, 0, 18, fill="#facc15", outline="#ef4444")
        self.origin_marker.create_line(0, 0, 18, 0, fill="#ffffff", width=1)
        self.origin_marker.create_line(0, 0, 0, 18, fill="#ffffff", width=1)
        self.origin_marker.create_text(10, 6, text="+", fill="#111827", font=("Consolas", 7, "bold"))

        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="_",
            command=self.toggle_ui_collapsed,
            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
        )

        self.origin_text_var = tk.StringVar(value="x: -  y: -")
        self.origin_entry = tk.Entry(
            self.frame,
            textvariable=self.origin_text_var,
            fg="#dbeafe",
            bg="#111827",
            insertbackground="#ffffff",
            font=("Consolas", 9, "bold"),
            width=18,
            justify="left",
            relief="flat",
            bd=2
        )
        for w in (self.frame, self.status, self.last_label, self.origin_marker):
            w.bind("<ButtonPress-1>", self.start_move)
            w.bind("<B1-Motion>", self.do_move)
            w.bind("<ButtonRelease-1>", self.end_move)

        self.root.bind("<F8>", lambda e: self.toggle_enabled())
        self.root.bind("<Escape>", lambda e: self.quit_app())

        self.apply_ui_layout(save=False)
        self.update_status()
        self.refresh_origin_text()
        self.start_control_ws_if_enabled()
        self.root.after(80, self.process_control_queue)
        self.root.after(300, self.refresh_origin_text_loop)
        self.root.after(int(self.cfg.get("poll_ms", 250)), self.scan_loop)

    def current_ui_width(self):
        try:
            return int(self.ui_collapsed_width if bool(getattr(self, "ui_collapsed", False)) else self.ui_expanded_width)
        except Exception:
            return 190 if bool(getattr(self, "ui_collapsed", False)) else 820

    def apply_ui_layout(self, save=True):
        """v68: one-line RTC status bar with _/ㅁ collapse toggle.

        The state is stored in the same local JSON config file as x/y position:
        host/yjm_save_dialog_auto_enter_config.json. No registry usage.
        """
        try:
            for w in (self.origin_entry, self.status, self.last_label, self.toggle_btn, self.setting_btn, self.fold_btn, self.close_btn):
                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))
            width = self.current_ui_width()
            height = int(getattr(self, "ui_bar_height", 34) or 34)
            self.root.geometry(f"{width}x{height}+{x}+{y}")
            self.origin_entry.grid(row=0, column=0, sticky="we", padx=(18, 6), pady=5)
            if self.ui_collapsed:
                self.fold_btn.config(text="ㅁ", width=3, bg="#374151", activebackground="#1f2937")
                self.fold_btn.grid(row=0, column=5, sticky="e", padx=(0, 2), pady=5)
                self.close_btn.grid(row=0, column=6, sticky="e", padx=(0, 6), pady=5)
            else:
                self.fold_btn.config(text="_", width=3, bg="#374151", activebackground="#1f2937")
                self.status.grid(row=0, column=1, sticky="w", padx=(4, 10), pady=5)
                self.last_label.grid(row=0, column=2, sticky="we", padx=(0, 10), pady=5)
                self.toggle_btn.grid(row=0, column=3, sticky="e", padx=(0, 4), pady=5)
                self.setting_btn.grid(row=0, column=4, sticky="e", padx=(0, 4), pady=5)
                self.fold_btn.grid(row=0, column=5, sticky="e", padx=(0, 2), pady=5)
                self.close_btn.grid(row=0, column=6, sticky="e", padx=(0, 6), pady=5)
            try:
                self.cfg["ui_collapsed"] = bool(self.ui_collapsed)
                self.cfg["ui_expanded_width"] = int(self.ui_expanded_width)
                self.cfg["ui_collapsed_width"] = int(self.ui_collapsed_width)
                self.cfg["ui_bar_height"] = int(self.ui_bar_height)
                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
            self.refresh_origin_text()
            self.root.update_idletasks()
        except Exception:
            pass

    def toggle_ui_collapsed(self):
        try:
            self.ui_collapsed = not bool(getattr(self, "ui_collapsed", False))
            self.apply_ui_layout(save=True)
        except Exception:
            pass

    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)
            try:
                path = os.path.join(igblue_log_dir(), "tkinter_callback_exception_last.txt")
                with open(path, "w", encoding="utf-8") as f:
                    f.write(text)
            except Exception:
                pass
            self.ui_note("Tkinter callback 예외 로그 저장: logs/tkinter_callback_exception_last.txt")
            vlog("Tkinter callback exception " + str(val))
        except Exception:
            try:
                traceback.print_exception(exc, val, tb)
            except Exception:
                pass

    def current_marker_origin(self):
        """Return the screen absolute coordinate of the RTC marker/window top-left.

        v63 rule: this floating window's left-top yellow triangle vertex is the
        manual Chrome DOM 0,0 marker. Zoom is not shown here because the RTC
        floating window is not the browser. Browser zoom is supplied by the
        Extension only in payload/logs.
        """
        try:
            self.root.update_idletasks()
        except Exception:
            pass
        try:
            return {
                "x": int(self.root.winfo_rootx()),
                "y": int(self.root.winfo_rooty()),
                "source": "rtc_floating_marker_top_left",
                "meaning": "screen_abs_dom_0_0_manual_marker"
            }
        except Exception:
            return {"x": int(self.cfg.get("x", 0)), "y": int(self.cfg.get("y", 0)), "source": "cfg_fallback"}

    def refresh_origin_text(self):
        try:
            o = self.current_marker_origin()
            txt = f"x: {o.get('x', '-')}   y: {o.get('y', '-')}"
            if hasattr(self, "origin_text_var"):
                self.origin_text_var.set(txt)
            return o
        except Exception:
            return {}

    def refresh_origin_text_loop(self):
        try:
            self.refresh_origin_text()
        finally:
            try:
                self.root.after(300, self.refresh_origin_text_loop)
            except Exception:
                pass

    def write_marker_origin_current(self):
        try:
            o = self.refresh_origin_text()
            doc = {"ts": now_text(), "schema": "ig_rtc_marker_origin_v63", "origin": o, "collector_id": self.collector_id}
            path = os.path.join(igblue_log_dir(), "ig_rtc_marker_origin_current.json")
            tmp = path + ".tmp"
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(doc, f, ensure_ascii=False, sort_keys=True, indent=2)
            os.replace(tmp, path)
            igblue_append_jsonl("ig_rtc_marker_origin.jsonl", doc)
            return doc
        except Exception:
            return None

    def ui_origin_note(self, origin=None, zoom=None):
        # v63: do not replace the visible basis text with browser-sent zoom.
        # The visible basis is this RTC floating window's top-left marker position.
        self.refresh_origin_text()

    def update_status(self):
        enabled = bool(self.cfg.get("enabled", False))
        if enabled:
            self.status.config(text="체크 사용중", fg="#86efac")
            self.toggle_btn.config(text="끄기", bg="#166534", activebackground="#14532d")
        else:
            self.status.config(text="체크 미사용중", fg="#fca5a5")
            self.toggle_btn.config(text="켜기", bg="#6b7280", activebackground="#4b5563")

    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):
        reply_q = queue.Queue(maxsize=1)
        self.control_queue.put((req, reply_q))
        try:
            return reply_q.get(timeout=10)
        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 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.cfg["enabled"] = bool(req.get("enabled"))
            save_config(self.cfg)
            self.update_status()
            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", "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
            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_auto_start"):
            return self.handle_domain_file_request(act, req)
        if act == "ig_rtc_hover_status":
            return self.handle_ig_rtc_hover_status(req)
        if act == "ig_rtc_browser_origin":
            return self.handle_ig_rtc_browser_origin(req)
        if act == "ig_media_exists":
            return self.handle_ig_media_exists(req)
        if act == "ig_media_downloaded":
            return self.handle_ig_media_downloaded(req)
        if act == "scan_once":
            self.scan_once()
            return self.control_status()
        if act in ("auto_enter_once", "accept_once"):
            return self.accept_once(req)
        if act in ("mouse_move", "mouse_click", "mouse_move_click", "yello_mouse_click"):
            return self.handle_mouse_request(act, req)
        if act in ("ig_rtc_auto_click_once", "ig_auto_download_click"):
            return self.handle_ig_rtc_auto_click_once(req, act)
        return {"ok": False, "error": "unknown_act", "act": act}


    def handle_ig_rtc_hover_status(self, req):
        try:
            data = self._req_data(req)
            doc = igblue_write_hover_status(data)
            if doc.get("click_allowed"):
                vlog(f"IG_RTC_HOVER recv OK rank={doc.get('rank')} media={doc.get('media_type')} x={doc.get('client_x')} y={doc.get('client_y')} code={str(doc.get('code') or '')[:16]}")
            elif doc.get("zone") in ("download_button", "thumbnail"):
                vlog(f"IG_RTC_HOVER recv zone={doc.get('zone')} rank={doc.get('rank')} allowed={doc.get('click_allowed')}")
            return {"ok": True, "act": "ig_rtc_hover_status", "stored": True, "click_allowed": bool(doc.get("click_allowed")), "rank": doc.get("rank"), "media_type": doc.get("media_type"), "zone": doc.get("zone")}
        except Exception as e:
            err = {"ts": now_text(), "ok": False, "act": "ig_rtc_hover_status", "error": str(e)}
            igblue_append_jsonl("ig_rtc_hover_status_error.jsonl", err)
            return err

    def handle_ig_rtc_browser_origin(self, req):
        try:
            data = self._req_data(req)
            doc = igblue_write_browser_origin(data)
            origin = doc.get("manual_origin") or doc.get("browser_dom_origin") or {}
            if isinstance(origin, dict):
                zoom = doc.get("zoom") or doc.get("devicePixelRatio")
                vlog(f"IG_RTC_ORIGIN recv x={origin.get('x')} y={origin.get('y')} zoom={zoom}")
                self.ui_origin_note(origin, zoom)
                self.ui_note(f"origin recv x={origin.get('x')} y={origin.get('y')} zoom={zoom}")
            return {"ok": True, "act": "ig_rtc_browser_origin", "stored": True, "origin": origin, "zoom": doc.get("zoom") or doc.get("devicePixelRatio")}
        except Exception as e:
            err = {"ts": now_text(), "ok": False, "act": "ig_rtc_browser_origin", "error": str(e)}
            igblue_append_jsonl("ig_rtc_browser_origin_error.jsonl", err)
            return err

    def handle_ig_rtc_auto_click_once(self, req, act="ig_rtc_auto_click_once"):
        """RTC-owned Auto PY click entry.

        Extension sends No001 ACTIVE button coordinates. ws8771 only validates/bridges;
        yjm_win2rtc/host/ig_auto_click_download.py owns mouse movement and logging.
        """
        try:
            data = self._req_data(req)
            data["bridge_act"] = act
            data.setdefault("use_rtc", True)
            module = igblue_load_rtc_click_module()
            def _rtc_ui_logger(message):
                vlog(message)
                self.ui_note(message)
            result = module.execute_click_once(data, igblue_log_dir(), logger=_rtc_ui_logger)
            if isinstance(result, dict):
                result.setdefault("bridge_act", act)
                result.setdefault("act", "ig_rtc_auto_click_once")
                return result
            return {"ok": False, "act": "ig_rtc_auto_click_once", "error": "bad_rtc_result"}
        except Exception as e:
            err = {"ts": now_text(), "ok": False, "act": "ig_rtc_auto_click_once", "bridge_act": act, "error": str(e)}
            igblue_append_jsonl("ig_rtc_auto_click_once_error.jsonl", err)
            vlog(f"IG_RTC_AUTO bridge error {e}")
            return err

    def handle_ig_auto_download_click(self, req):
        """Extension Auto PY v55: click the currently ACTIVE Instagram download button.

        First stage is intentionally first-target only.  The extension sends the
        DOM-calculated download button rectangle; this handler logs the payload,
        moves to the left side of the button, wanders briefly, moves to the
        center, clicks, and returns the final cursor position.
        """
        import random
        started = time.time()
        try:
            data = self._req_data(req)
            target = data.get("target") if isinstance(data.get("target"), dict) else data
            button_rect = target.get("button_rect") if isinstance(target.get("button_rect"), dict) else {}
            if not button_rect:
                return {"ok": False, "error": "missing_button_rect", "act": "ig_auto_download_click"}
            cx, cy = igblue_center_from_rect(button_rect)
            if not cx or not cy:
                return {"ok": False, "error": "bad_button_center", "button_rect": button_rect}

            left_min = int(_ig_num(data.get("left_offset_min"), 36))
            left_max = int(_ig_num(data.get("left_offset_max"), 84))
            if left_max < left_min:
                left_min, left_max = left_max, left_min
            y_jitter = max(0, min(60, int(_ig_num(data.get("y_jitter"), 18))))
            start_x = cx - random.randint(max(0, left_min), max(left_min, left_max))
            start_y = cy + random.randint(-y_jitter, y_jitter)
            move_sec = max(0.05, min(3.0, _ig_num(data.get("move_sec"), 0.45)))
            pre_wander_sec = max(0.0, min(6.0, _ig_num(data.get("pre_wander_sec"), data.get("hold_sec", 2.0))))
            after_hold = max(0.0, min(8.0, _ig_num(data.get("after_click_hold_sec"), 2.4)))
            click = data.get("click") is not False

            event = {
                "ts": now_text(),
                "act": "ig_auto_download_click",
                "rank": int(_ig_num(target.get("rank"), 0)),
                "code": str(target.get("code") or ""),
                "filename_prefix": str(target.get("filename_prefix") or ""),
                "download_root": str(target.get("download_root") or ""),
                "download_account": str(target.get("download_account") or ""),
                "button_center": {"x": cx, "y": cy},
                "start_left": {"x": start_x, "y": start_y},
                "button_rect": button_rect,
                "active_style": target.get("active_style") if isinstance(target.get("active_style"), dict) else {},
                "href": str(data.get("href") or ""),
            }
            log_path = igblue_append_jsonl("ig_auto_download_click.jsonl", event)
            vlog(f"IG_AUTO target rank={event['rank']} center=({cx},{cy}) start=({start_x},{start_y}) file={event['filename_prefix']}")
            try:
                self.last_label.config(text=f"IG Auto No{event['rank']:03d} x={cx} y={cy}")
            except Exception:
                pass

            mouse_move_smooth(start_x, start_y, move_sec, 24)
            if pre_wander_sec > 0:
                end = time.time() + pre_wander_sec
                while time.time() < end:
                    mouse_wander_around(start_x, start_y, radius=10, count=1, step_duration=0.16)
            mouse_move_smooth(cx, cy, max(0.12, move_sec * 0.72), 18)
            if click:
                mouse_left_click()
            if after_hold > 0:
                time.sleep(after_hold)
            pos = get_cursor_pos()
            result = {
                "ts": now_text(),
                "ok": True,
                "act": "ig_auto_download_click",
                "clicked": bool(click),
                "rank": event["rank"],
                "code": event["code"],
                "click_x": cx,
                "click_y": cy,
                "start_x": start_x,
                "start_y": start_y,
                "pos": pos,
                "elapsed_sec": round(time.time() - started, 3),
                "log_path": log_path,
                "mode": str(data.get("mode") or "first_only"),
                "rtc_module": "host/ig_auto_download.py",
            }
            igblue_append_jsonl("ig_auto_download_click_result.jsonl", result)
            return result
        except Exception as e:
            err = {"ts": now_text(), "ok": False, "act": "ig_auto_download_click", "error": str(e)}
            igblue_append_jsonl("ig_auto_download_click_error.jsonl", err)
            return err

    def handle_mouse_request(self, act, req):
        try:
            x = req.get("x")
            y = req.get("y")
            target = req.get("target") if isinstance(req.get("target"), dict) else {}
            if x is None:
                x = target.get("x")
            if y is None:
                y = target.get("y")
            if x is None or y is None:
                return {"ok": False, "error": "missing_x_y", "act": act}
            duration = float(req.get("duration_sec", req.get("move_sec", 0.35)) or 0.35)
            hold = float(req.get("hold_sec", req.get("delay_sec", 0.0)) or 0.0)
            wander = bool(req.get("wander", False))
            radius = int(req.get("wander_radius", 12) or 12)
            count = int(req.get("wander_count", 4) or 4)
            click = act in ("mouse_click", "mouse_move_click", "yello_mouse_click") or bool(req.get("click", False))
            mouse_move_smooth(x, y, duration, int(req.get("steps", 24) or 24))
            if hold > 0:
                time.sleep(max(0.0, min(10.0, hold)))
            if wander:
                mouse_wander_around(x, y, radius=radius, count=count, step_duration=float(req.get("wander_step_sec", 0.18) or 0.18))
                mouse_move_smooth(x, y, 0.12, 6)
            if click:
                mouse_left_click()
            pos = get_cursor_pos()
            return {"ok": True, "act": act, "clicked": click, "pos": pos}
        except Exception as e:
            return {"ok": False, "error": str(e), "act": act}

    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', '')} path={path} 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', '')} path={path} 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_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_media_exists(self, req):
        data = self._req_data(req)
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        vlog(f"ig_media_exists user={data.get('collector_id')} ig={data.get('ig_account_id', '')} shortcode={meta.get('shortcode', '')}")
        local = local_folder_json_check(data, meta)
        if local.get("exists"):
            self.last_label.config(text="중복확인: 로컬 JSON 있음")
            return {
                "ok": True,
                "exists": 1,
                "match_by": local.get("match_by", "local_json"),
                "local": local,
            }
        fields = meta_to_yellow_fields(meta, collector_id=data.get("collector_id"))
        res = yellow_api_call(self.cfg, "media_exists", fields)
        exists = bool(res.get("exists")) if isinstance(res, dict) else False
        self.last_label.config(text=("중복확인: DB 있음" if exists else "중복확인: 신규"))
        return {
            "ok": True if isinstance(res, dict) else False,
            "exists": 1 if exists else 0,
            "match_by": res.get("match_by", "") if isinstance(res, dict) else "",
            "local": local,
            "yellow": res,
        }

    def handle_ig_media_downloaded(self, req):
        data = self._req_data(req)
        meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
        vlog(f"ig_media_downloaded user={data.get('collector_id')} ig={data.get('ig_account_id', '')} shortcode={meta.get('shortcode', '')}")
        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}
        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)
        folder_name = item.get("folder_name") or os.path.basename(os.path.dirname(file_path))
        file_name = os.path.basename(file_path)
        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 = yellow_api_call(self.cfg, "media_insert", fields)
        self.last_label.config(text=f"JSON/DB 저장: {asset_kind or 'file'} {file_name}")
        return {
            "ok": True,
            "file_path": file_path,
            "json_path": json_path,
            "sidecar_path": sidecar_path,
            "md5": md5,
            "sha256": sha256,
            "size": size,
            "asset_kind": asset_kind,
            "yellow": yellow_res,
            "item": item,
        }

    def control_status(self):
        ws_cfg = self.cfg.get("control_ws") or {}
        return {
            "ok": True,
            "app": APP_NAME,
            "collector_id": self.collector_id,
            "user_id": self.collector_id,
            "enabled": bool(self.cfg.get("enabled", False)),
            "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 "",
            "origin_text": self.origin_text_var.get() if hasattr(self, "origin_text_var") else "",
            "ui_collapsed": bool(getattr(self, "ui_collapsed", False)),
        }

    def accept_once(self, req=None):
        target_titles = self.cfg.get("target_titles", [])
        match_contains = bool(self.cfg.get("match_contains", False))
        hwnds = enum_matching_dialogs(target_titles, match_contains)
        if not hwnds:
            return {"ok": True, "acted": False, "reason": "no_matching_dialog", "count": 0}
        hwnd = hwnds[0]
        title = get_window_text(hwnd)
        ok, method = auto_accept_save_dialog(hwnd, self.cfg)
        self.acted[hwnd] = time.time()
        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.cfg["enabled"] = not bool(self.cfg.get("enabled", False))
        save_config(self.cfg)
        self.update_status()

    def start_move(self, event):
        self.drag_start_x = event.x
        self.drag_start_y = event.y

    def do_move(self, event):
        x = self.root.winfo_pointerx() - self.drag_start_x
        y = self.root.winfo_pointery() - self.drag_start_y
        self.root.geometry(f"+{x}+{y}")
        self.refresh_origin_text()

    def end_move(self, event):
        try:
            self.cfg["x"] = self.root.winfo_x()
            self.cfg["y"] = self.root.winfo_y()
            self.cfg["ui_collapsed"] = bool(getattr(self, "ui_collapsed", False))
            self.cfg["ui_expanded_width"] = int(getattr(self, "ui_expanded_width", 820))
            self.cfg["ui_collapsed_width"] = int(getattr(self, "ui_collapsed_width", 190))
            self.cfg["ui_bar_height"] = int(getattr(self, "ui_bar_height", 34))
            save_config(self.cfg)
            self.write_marker_origin_current()
        except Exception:
            pass

    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)

        if not self.cfg.get("enabled", False):
            return

        hwnds = enum_matching_dialogs(
            self.cfg.get("target_titles", []),
            bool(self.cfg.get("match_contains", False))
        )
        if not hwnds:
            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)
            ok, method = auto_accept_save_dialog(hwnd, self.cfg)
            self.acted[hwnd] = now

            if ok:
                self.last_label.config(text=f"처리: {title} / {method}")
            else:
                self.last_label.config(text=f"실패: {title} / {method}")

    def open_settings(self):
        win = tk.Toplevel(self.root)
        win.title("설정")
        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="대상 창 제목", bg="#f3f4f6").grid(row=2, 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=2, 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=3, column=1, sticky="w", padx=10, pady=4)

        tk.Label(win, text="버튼 글자", bg="#f3f4f6").grid(row=4, 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=4, 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)))

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

        hint = (
            "F8: 켜기/끄기, ESC: 종료\n"
            "주의: 대상 제목이 맞으면 자동 저장됩니다. 필요한 창 제목만 넣으세요."
        )
        tk.Label(win, text=hint, bg="#f3f4f6", fg="#374151", justify="left").grid(
            row=7, column=0, columnspan=2, sticky="w", padx=10, pady=(8, 4)
        )

        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
                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())
                save_config(self.cfg)
                self.root.attributes("-alpha", alpha)
                win.destroy()
            except Exception as e:
                messagebox.showerror("오류", str(e))

        bottom = tk.Frame(win, bg="#f3f4f6")
        bottom.grid(row=8, 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):
        save_config(self.cfg)
        try:
            if self.control_server:
                self.control_server.stop()
        except Exception:
            pass
        self.root.destroy()

    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")
    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()
