======= D2RMT(Diablo 2 Resurrected Mod Toolkit) =======

[구글 드라이브 다운로드 링크]



오랜만에 시간이 나서 구상만 하고 있던 프로그램 만들어 공유합니다.

모드 백업 및 롤백, 모드 추가 등을 할 수 있는 툴킷입니다.

모드를 하나하나 백업해서 관리하고 롤백하는 게 꽤 번거로워 만든 프로그램입니다.

p.s 좀 써봤는데 '(원본 백업 후) 바닐라화 - 모드 설치 - 테스트 - 원본 롤백' 루틴으로 테스트
     하니 편하네요



[주요 기능]

1. 현재 모드 백업 / 롤백 / 바닐라 기능

   현재 모드를 연월일시간을 초단위까지 기억해 백업합니다. 이렇게 백업된 모드는 내용을 제목으로 지정해
   두고 언제든지 롤백시킬 수 있습니다(하단 스크린샷 참조). 추가로 바닐라 버튼을 조작해 바닐라(순정) 
   상태로 모드를 되돌리는 기능도 있습니다(모드 테스트용).

   
   


2. 모드 불러오기 / 설치 기능

   인벤 애드온·모드 게시판에서 다운 받은 모더분들의 모드를 불러와 현재 모드에 설치 가능합니다.
   백업 / 롤백 기능과 연계하면 언제든지 원하는 스킨이나 기능이 포함된 모드를 불러올 수 있습니다.
   ※ 불러온 모드는 별도의 mod 폴더에 정리되어 다운로드 받은 모드를 지우더라도 언제든지 앱에서 
      설치/삭제가 가능합니다.


▲ 드래그로 위치 변경 가능


▲ 어떤 모드인지 직접 제목 설정하면 됩니다.


3. 모드 변경내역(로그) 기능

   모드별로 변경 내역을 저장해서 앱 최하단의 로그 보기를 눌러 언제든 이력 확인이 가능합니다. 
   롤백으로 모드를 불러올 때에도 각 모드별로 로그를 불러옵니다.







[사용법]

1. D2RMT_v0.1.exe를 원하는 경로에 폴더를 만들어 넣고 바로가기 생성

   
    ▲ 처음 다운로드 받으면 해당 파일 하나지만, 앱 실행 후 설정을 진행하면 설정파일과 모드폴더가 같은 
        경로에 생성 됩니다. 따라서 따로 폴더에 넣어두시고 바로가기를 만들어 사용하시면 깔끔합니다.


2. 현재 모드 폴더 경로지정

   디아블로2 레저렉션이 설치된 폴더/mods/모드폴더를 선택 - '폴더 선택' 클릭
   (예시 : 저의 경우 C:/Program files (x86)/Diablo II Resurrected/Mods/drag 폴더)




3. 현재 모드 백업

   1.에 따라 경로지정을 하셨다면 '백업' 버튼을 눌러 제목을 '원본'으로 백업해줍니다.



▲ 이제 언제든지 '롤백' 버튼을 이용해 방금 백업한 원본 상태로 되돌릴 수 있습니다.


4. 모드 불러오기 및 설치

   (1) 다운 받은 모드의 압축을 풉니다.

   (2) 앱에서 ③추가모드 항목의 '+모드 불러오기' 버튼 클릭

   (3) 압축풀어 나온 global, hd, local 폴더를 선택합니다

       ※ data폴더에 바로 들어가는 global, hd, local 폴더만 해당합니다. 이외에는 에러 출력하도록 했습니다.
          만약 받으신 폴더가 이보다 하위 폴더일 경우 직접 상위폴더를 생성해줘야 합니다. 
          (예시 : 스킨을 받았는데 items 폴더만 있을 경우 hd 폴더 생성해서 안에 items 폴더 넣고 불러오기)

       ※ 다중 선택 기능은 안정성 이슈로 제외했고, 한 폴더를 선택하면 반복 팝업 되고 취소 누를 경우 제목 
          설정으로 넘어갑니다. (가령 적용하려는 모드가 hd와 global 폴더만 있다면 'hd폴더 선택 - 확인' 후 
          다시 뜨는 창에서 'global 폴더 선택 - 확인' 그리고 다시 뜨는 창에서 '취소'를 누르시면 됩니다.)





▲ 불러오기 및 제목 설정을 마친 다음, 설치 버튼을 누르면 현재 모드에 설치됩니다. 테스트 후 마음에 안들 
    경우 롤백하시면 되겠죠?

※ 모드들을 설치하신 후 주기적으로 백업 - 제목설정에 모드내용을 적어 두시면 언제든지 확인해 모드 전환 
   가능합니다!


▲ 스킨 불러오기를 할 시 exe 파일이 있는 경로에 Mod 폴더가 생성되어 위와 같이 모드들이 정리됩니다. 




★★ 보안 및 안전 안내 ★★
  • 100% 안전한 툴: 오직 모드 폴더의 백업/롤백과 외부모드 설치의 편의성만을 위해 제작된 도우미 프로그램입니다.
  • 소스코드 투명 공개: 혹시 모를 불안감이 있으신 분들을 위해 사용된 파이썬(Python) 소스코드를 하단에 그대로 첨부합니다. 의심스러우시다면 코드를 직접 검증하시거나 파이썬으로 직접 실행하셔도 좋습니다!
  • 미인증 개인 제작 파일 특성상 백신 프로그램이 오진할 수 있으나, 어떠한 악성 기능도 없는 청정 프로그램임을 보증합니다

▼ 클릭 시 펼쳐집니다.

Python Code 보기
"""
D2R Mod Manager - Diablo II Resurrected 모드 관리 툴
"""

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog
import os
import json
import shutil
import re
from datetime import datetime
from pathlib import Path

import sys

# PyInstaller로 빌드된 exe는 __file__이 임시 폴더를 가리키므로
# sys.executable 기준(exe 실제 위치)으로 저장 경로를 잡음
if getattr(sys, "frozen", False):
    _BASE_DIR = Path(sys.executable).parent
else:
    _BASE_DIR = Path(__file__).parent

SAVE_FILE = _BASE_DIR / "d2r_mod_manager_state.json"

APP_ICON = "app_icon.ico"

def _get_ico_path() -> Path | None:
    """번들/스크립트 환경 모두에서 app_icon.ico 경로를 반환한다."""
    if getattr(sys, "frozen", False):
        bundle_dir = Path(sys._MEIPASS)
    else:
        bundle_dir = Path(__file__).parent
    p = bundle_dir / APP_ICON
    return p if p.exists() else None


def _set_icon(win):
    """타이틀바·Alt+Tab 아이콘을 설정한다. (작업표시줄은 _set_taskbar_icon 담당)"""
    ico_path = _get_ico_path()
    if ico_path:
        try:
            win.iconbitmap(str(ico_path))
        except Exception:
            pass


def _set_taskbar_icon(win):
    """ctypes로 HICON을 직접 윈도우 핸들에 전송해 작업표시줄 아이콘을 교체한다.
    메인 Tk 창에만 호출하면 된다 (mainloop 진입 직후 after로 호출).
    wm_frame()으로 실제 최상위 데코레이션 윈도우 핸들을 획득하고,
    WS_EX_APPWINDOW를 명시적으로 설정해 작업표시줄 표시를 강제한다.
    """
    if sys.platform != "win32":
        return
    ico_path = _get_ico_path()
    if not ico_path:
        return
    try:
        import ctypes

        user32 = ctypes.windll.user32

        GWL_EXSTYLE      = -20
        WS_EX_APPWINDOW  = 0x00040000
        WS_EX_TOOLWINDOW = 0x00000080
        LR_LOADFROMFILE  = 0x10
        IMAGE_ICON       = 1
        WM_SETICON       = 0x0080
        ICON_SMALL       = 0
        ICON_BIG         = 1

        # winfo_id()는 내부 child 핸들을 반환할 수 있으므로
        # wm_frame()으로 실제 최상위(데코레이션 포함) 윈도우 핸들을 획득
        try:
            hwnd = int(win.wm_frame(), 16)
        except Exception:
            hwnd = win.winfo_id()

        # 큰 아이콘 (작업표시줄용)
        big_size = user32.GetSystemMetrics(11)   # SM_CXICON
        hicon_big = user32.LoadImageW(
            None, str(ico_path), IMAGE_ICON,
            big_size, big_size, LR_LOADFROMFILE
        )
        # 작은 아이콘 (타이틀바용)
        sm_size = user32.GetSystemMetrics(49)    # SM_CXSMICON
        hicon_sm = user32.LoadImageW(
            None, str(ico_path), IMAGE_ICON,
            sm_size, sm_size, LR_LOADFROMFILE
        )

        if hicon_big:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG,   hicon_big)
        if hicon_sm:
            user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon_sm)

        # WS_EX_APPWINDOW 명시적 설정 → 작업표시줄에 항상 표시
        ex_style = user32.GetWindowLongW(hwnd, GWL_EXSTYLE)
        ex_style = (ex_style | WS_EX_APPWINDOW) & ~WS_EX_TOOLWINDOW
        user32.SetWindowLongW(hwnd, GWL_EXSTYLE, ex_style)

    except Exception:
        pass


def _set_appid():
    """Windows 작업표시줄 아이콘을 exe 아이콘으로 고정시키기 위해
    AppUserModelID를 등록한다. 메인 창 생성 전에 한 번만 호출하면 된다.
    """
    if sys.platform != "win32":
        return
    try:
        import ctypes
        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
            "D2RModManager.App.1"
        )
    except Exception:
        pass


# ──────────────────────────────────────────────
#  커스텀 텍스트 입력 다이얼로그
#  (simpledialog.askstring 대체 → 아이콘 적용 가능)
# ──────────────────────────────────────────────
class AskStringDialog(tk.Toplevel):
    """단일 텍스트 입력을 받는 커스텀 다이얼로그.
    result: 확인 시 입력 문자열, 취소/닫기 시 None.
    """
    def __init__(self, parent, title: str, prompt: str, initial: str = ""):
        super().__init__(parent)
        self.title(title)
        self.configure(bg=BG_DARK)
        self.resizable(False, False)
        self.result = None
        _set_icon(self)

        pw, ph = 440, 200
        self.update_idletasks()
        sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
        self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
        self.grab_set()

        tk.Label(self, text=prompt, bg=BG_DARK, fg=TEXT_MAIN,
                 font=FONT_SMALL, wraplength=340, justify="left",
                 padx=20, pady=14).pack(fill="x")

        self._var = tk.StringVar(value=initial)
        entry = tk.Entry(self, textvariable=self._var,
                         bg=BG_CARD, fg=TEXT_MAIN, insertbackground=TEXT_MAIN,
                         font=FONT_BODY, relief="flat",
                         highlightthickness=1, highlightcolor=BORDER_GOLD,
                         highlightbackground=BORDER)
        entry.pack(fill="x", padx=20)
        entry.icursor("end")
        entry.focus_set()

        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=14)
        styled_button(btn_frame, "확인", self._ok,
                      BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "취소", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")

        self.bind("<Return>", lambda e: self._ok())
        self.bind("<Escape>", lambda e: self.destroy())
        self.protocol("WM_DELETE_WINDOW", self.destroy)

    def _ok(self):
        self.result = self._var.get()
        self.destroy()

    @classmethod
    def ask(cls, parent, title: str, prompt: str, initial: str = ""):
        """블로킹 호출. 확인 → 입력값(str), 취소 → None 반환."""
        dlg = cls(parent, title, prompt, initial)
        parent.wait_window(dlg)
        return dlg.result

# 추가 모드를 영구 보관하는 폴더 (exe 옆 mod/ 디렉터리)
MOD_STORE_DIR = _BASE_DIR / "mod"


def _read_backup_labels(backup_root: Path) -> dict:
    """backup_root/backup_labels.json → {폴더명: 메모} dict"""
    label_file = backup_root / "backup_labels.json"
    if label_file.exists():
        try:
            return json.loads(label_file.read_text(encoding="utf-8"))
        except Exception:
            pass
    return {}


def _write_backup_labels(backup_root: Path, labels: dict):
    """labels dict를 backup_root/backup_labels.json 에 저장"""
    label_file = backup_root / "backup_labels.json"
    try:
        label_file.write_text(json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


# ──────────────────────────────────────────────
#  로그 파일 경로 helper
# ──────────────────────────────────────────────
def _log_path_for(mod_folder: Path) -> Path:
    """mod_folder 옆 BackUp/ 안의 <모드명>_activity_log.json 경로 (모드별 분리)"""
    mod_name = mod_folder.name
    return mod_folder.parent / "BackUp" / f"{mod_name}_activity_log.json"


def _read_log(mod_folder: Path) -> list:
    p = _log_path_for(mod_folder)
    if p.exists():
        try:
            return json.loads(p.read_text(encoding="utf-8"))
        except Exception:
            pass
    return []


def _write_log(mod_folder: Path, entries: list):
    backup_root = mod_folder.parent / "BackUp"
    backup_root.mkdir(exist_ok=True)
    p = _log_path_for(mod_folder)
    try:
        p.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
    except Exception:
        pass


def _ts_now() -> str:
    """현재 시각을 'YY:MM:DD:HH:MM:SS' 표시용 문자열로 반환"""
    return datetime.now().strftime("%y:%m:%d %H:%M:%S")


# ──────────────────────────────────────────────
#  색상 & 폰트 테마 (Windows 11 네이티브 스타일)
# ──────────────────────────────────────────────
BG_DARK      = "#f3f3f3"   # 창 배경
BG_PANEL     = "#e8e8e8"   # 상태바 배경
BG_CARD      = "#ffffff"   # 카드 배경
BG_CARD_HOV  = "#f5f5f5"   # 카드 호버
BORDER       = "#d0d0d0"   # 일반 테두리
BORDER_GOLD  = "#0078d4"   # 강조 테두리 (Windows 블루)
ACCENT_GOLD  = "#0078d4"   # 강조색 (Windows 블루)
ACCENT_RED   = "#c42b1c"   # 위험 강조색
TEXT_MAIN    = "#1a1a1a"   # 기본 텍스트
TEXT_SUB     = "#5a5a5a"   # 보조 텍스트
TEXT_DIM     = "#999999"   # 흐린 텍스트
BTN_INSTALL  = "#0078d4"   # 설치 버튼 (Windows 블루)
BTN_INSTALL_H= "#006cbe"
BTN_REMOVE   = "#c42b1c"   # 제거 버튼 (빨간색)
BTN_REMOVE_H = "#ae2518"
BTN_BACKUP   = "#107c10"   # 백업 버튼 (초록색)
BTN_BACKUP_H = "#0e6b0e"
BTN_ROLL     = "#7a5af8"   # 롤백 버튼 (보라색)
BTN_ROLL_H   = "#6b4fe0"

FONT_TITLE   = ("Segoe UI", 18, "bold")
FONT_HEAD    = ("Segoe UI", 12, "bold")
FONT_BODY    = ("Segoe UI", 12)
FONT_SMALL   = ("Segoe UI", 11)
FONT_MONO    = ("Consolas", 11)

VALID_FOLDERS = {"global", "hd", "local"}


def styled_button(parent, text, command, bg, hover_bg,
                  fg="#ffffff", padx=10, pady=4, font=FONT_SMALL, **kw):
    btn = tk.Label(parent, text=text, bg=bg, fg=fg,
                   font=font, padx=padx, pady=pady,
                   cursor="hand2", relief="flat", **kw)
    btn.bind("<Button-1>", lambda e: command())
    btn.bind("<Enter>",  lambda e: btn.config(bg=hover_bg))
    btn.bind("<Leave>",  lambda e: btn.config(bg=bg))
    return btn


# ──────────────────────────────────────────────
#  활동 로그 다이얼로그
# ──────────────────────────────────────────────
class LogDialog(tk.Toplevel):
    def __init__(self, parent, entries: list, mod_name: str):
        super().__init__(parent)
        self.title("활동 로그")
        self.configure(bg=BG_DARK)
        self.resizable(True, True)
        _set_icon(self)

        pw, ph = 660, 520
        self.update_idletasks()
        sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
        self.geometry(f"{pw}x{ph}+{max(0,(sw-pw)//2)}+{max(0,(sh-ph)//2)}")
        self.grab_set()

        tk.Label(self, text=f"활동 로그 — {mod_name}",
                 bg=BG_DARK, fg=ACCENT_GOLD, font=FONT_HEAD, pady=12).pack(fill="x")
        tk.Frame(self, bg=BORDER_GOLD, height=1).pack(fill="x", padx=20)

        frame = tk.Frame(self, bg=BG_DARK)
        frame.pack(fill="both", expand=True, padx=20, pady=10)

        sb = tk.Scrollbar(frame)
        sb.pack(side="right", fill="y")

        lb = tk.Listbox(
            frame,
            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
            selectbackground=BORDER_GOLD, selectforeground="#ffffff",
            relief="flat", bd=0,
            highlightthickness=1, highlightcolor=BORDER_GOLD,
            highlightbackground=BORDER_GOLD,
            yscrollcommand=sb.set, activestyle="none"
        )
        lb.pack(side="left", fill="both", expand=True)
        sb.config(command=lb.yview)

        if entries:
            for e in reversed(entries):   # 최신 항목이 위로
                lb.insert("end", f"  {e['ts']}   {e['msg']}")
        else:
            lb.insert("end", "  (기록 없음)")

        self.after(50, lb.focus_set)

        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=(0, 14))
        styled_button(btn_frame, "닫기", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")


# ──────────────────────────────────────────────
#  롤백 선택 다이얼로그
# ──────────────────────────────────────────────
class RollbackDialog(tk.Toplevel):
    def __init__(self, parent, backup_folder: Path, current_mod_name: str):
        super().__init__(parent)
        self.title("롤백 선택")
        self.configure(bg=BG_DARK)
        self.resizable(False, False)
        self.selected = None
        _set_icon(self)
        self.current_mod_name = current_mod_name
        self._backup_folder = backup_folder
        self._labels = _read_backup_labels(backup_folder) if backup_folder.exists() else {}

        # 화면 중앙 배치
        pw, ph = 560, 460
        self.update_idletasks()
        sw = self.winfo_screenwidth()
        sh = self.winfo_screenheight()
        x = max(0, (sw - pw) // 2)
        y = max(0, (sh - ph) // 2)
        self.geometry(f"{pw}x{ph}+{x}+{y}")
        self.grab_set()

        # 제목
        tk.Label(self, text="⟲  롤백 포인트 선택",
                 bg=BG_DARK, fg=ACCENT_GOLD,
                 font=FONT_HEAD, pady=14).pack(fill="x")

        sep = tk.Frame(self, bg=BORDER_GOLD, height=1)
        sep.pack(fill="x", padx=20)

        tk.Label(self, text="복원할 백업을 선택하세요.",
                 bg=BG_DARK, fg=TEXT_SUB, font=FONT_SMALL, pady=8).pack()

        # 리스트
        frame = tk.Frame(self, bg=BG_DARK)
        frame.pack(fill="both", expand=True, padx=20, pady=(0, 10))

        sb = tk.Scrollbar(frame)
        sb.pack(side="right", fill="y")

        self.listbox = tk.Listbox(
            frame,
            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
            selectbackground=BORDER_GOLD, selectforeground="#ffffff",
            relief="flat", bd=0,
            highlightthickness=1, highlightcolor=BORDER_GOLD,
            highlightbackground=BORDER_GOLD,
            yscrollcommand=sb.set,
            activestyle="none"
        )
        self.listbox.pack(side="left", fill="both", expand=True)
        sb.config(command=self.listbox.yview)

        # BackUp 폴더 내 .mpq 폴더 목록 수집
        self.entries = []
        if backup_folder.exists():
            pattern = re.compile(
                rf"^{re.escape(current_mod_name)}(d{{10,12}}).mpq$", re.IGNORECASE
            )
            for item in sorted(backup_folder.iterdir(), reverse=True):
                if item.is_dir() and item.name.lower().endswith(".mpq"):
                    m = pattern.match(item.name)
                    if m:
                        ts = m.group(1)          # 예: 2606112100
                        display = self._format_ts(ts, item.name)
                        self.entries.append((item.name, display))
                        self.listbox.insert("end", f"  {display}")

        if not self.entries:
            self.listbox.insert("end", "  (백업 없음)")

        self.after(50, self.listbox.focus_set)

        # 버튼
        btn_frame = tk.Frame(self, bg=BG_DARK)
        btn_frame.pack(fill="x", padx=20, pady=(0, 16))

        styled_button(btn_frame, "✔  복원", self._confirm,
                      BTN_INSTALL, BTN_INSTALL_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "🗑  제거", self._delete_selected,
                      BTN_REMOVE, BTN_REMOVE_H, padx=22, pady=6).pack(side="left")
        tk.Frame(btn_frame, bg=BG_DARK, width=10).pack(side="left")
        styled_button(btn_frame, "✕  취소", self.destroy,
                      "#e0e0e0", "#d0d0d0", fg=TEXT_MAIN, padx=22, pady=6).pack(side="left")

    def _format_ts(self, ts: str, raw_name: str) -> str:
        """260612091036 → 2026-06-12 09:10:36  |  파싱 실패 시 raw_name"""
        try:
            year   = int("20" + ts[0:2])
            month  = int(ts[2:4])
            day    = int(ts[4:6])
            hour   = int(ts[6:8])
            minute = int(ts[8:10])
            second = int(ts[10:12]) if len(ts) >= 12 else 0
            date_str = f"{year}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
            memo = self._labels.get(raw_name, "")
            if memo:
                return f"[{memo}]  {date_str}"
            else:
                base = raw_name.replace(".mpq", "")
                mod_only = re.sub(r"d{10,12}$", "", base)
                return f"{mod_only}  ({date_str})"
        except Exception:
            return raw_name.replace(".mpq", "")

    def _confirm(self):
        sel = self.listbox.curselection()
        if not sel or not self.entries:
            messagebox.showwarning("선택 없음", "복원할 백업을 선택하세요.", parent=self)
            return
        self.selected = self.entries[sel[0]][0]
        self.destroy()

    def _delete_selected(self):
        sel = self.listbox.curselection()
        if not sel or not self.entries:
            messagebox.showwarning("선택 없음", "제거할 백업을 선택하세요.", parent=self)
            return
        folder_name, display = self.entries[sel[0]]
        ok = messagebox.askyesno(
            "백업 제거",
            f"선택한 백업을 영구 삭제할까요?nn{display}nn이 작업은 되돌릴 수 없습니다.",
            parent=self
        )
        if not ok:
            return
        target = self._backup_folder / folder_name
        try:
            shutil.rmtree(str(target))
        except Exception as e:
            messagebox.showerror("삭제 실패", str(e), parent=self)
            return
        # labels.json 에서도 제거
        labels = _read_backup_labels(self._backup_folder)
        if folder_name in labels:
            del labels[folder_name]
            _write_backup_labels(self._backup_folder, labels)
            self._labels = labels
        # 목록 갱신
        idx = sel[0]
        self.entries.pop(idx)
        self.listbox.delete(idx)
        if not self.entries:
            self.listbox.insert("end", "  (백업 없음)")


# ──────────────────────────────────────────────
#  메인 애플리케이션
# ──────────────────────────────────────────────
class D2RModManager(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("D2RMT v0.1")
        self.configure(bg=BG_DARK)
        self.minsize(820, 620)
        self.geometry("940x720")
        _set_icon(self)

        self.mod_folder: Path | None = None
        self.mpq_folder: Path | None = None
        self.mod_name: str = ""
        self.addon_mods: list[dict] = []
        self._activity_log: list[dict] = []   # 현재 mod_folder 세션 로그

        self._build_ui()
        self._load_state()

        # 창이 완전히 그려진 뒤 작업표시줄 아이콘 교체
        self.after(200, lambda: _set_taskbar_icon(self))

    # ── 상태 저장 / 불러오기 ─────────────────────────────────────────────
    # 추가 모드는 exe 옆 mod/<모드명>/ 폴더에 실제 파일을 복사해 두고,
    # JSON 에는 mod_folder 경로만 저장한다.
    # 재실행 시 mod/ 폴더를 스캔해 목록을 복원하므로 경로 소실 문제가 없다.

    def _save_state(self):
        """mod_folder 경로만 JSON 에 저장 (addon_mods 는 mod/ 폴더가 정보 원본)."""
        try:
            data = {
                "mod_folder": str(self.mod_folder) if self.mod_folder else None,
            }
            save_path = SAVE_FILE.resolve()
            save_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception as e:
            import traceback; traceback.print_exc()
            messagebox.showerror("저장 실패", f"상태 저장 중 오류가 발생했습니다:n{e}")

    def _load_state(self):
        """JSON 에서 mod_folder 복원, mod/ 폴더 스캔으로 addon_mods 복원."""
        # ── mod_folder 복원
        save_path = SAVE_FILE.resolve()
        if save_path.exists():
            try:
                data = json.loads(save_path.read_text(encoding="utf-8"))
                if data.get("mod_folder"):
                    p = Path(data["mod_folder"])
                    if p.exists():
                        mod_name = p.name
                        mpq_candidate = p / f"{mod_name}.mpq"
                        if not mpq_candidate.exists():
                            candidates = [x for x in p.iterdir()
                                          if x.name.lower().endswith(".mpq")]
                            mpq_candidate = candidates[0] if candidates else None
                        if mpq_candidate:
                            self.mod_folder = p
                            self.mpq_folder = mpq_candidate
                            self.mod_name = mpq_candidate.name.replace(".mpq", "")
                            self.path_var.set(str(p))
                            self._render_current_mod()
                            # 저장된 로그 복원
                            self._activity_log = _read_log(p)
            except Exception:
                import traceback; traceback.print_exc()

        # ── addon_mods 복원: mod/ 폴더 스캔
        try:
            if MOD_STORE_DIR.exists():
                for mod_dir in sorted(MOD_STORE_DIR.iterdir()):
                    if not mod_dir.is_dir():
                        continue
                    mod_name = mod_dir.name
                    # 하위 폴더(global / hd / local) 중 존재하는 것만 수집
                    folders = [mod_dir / fn for fn in ("global", "hd", "local")
                               if (mod_dir / fn).is_dir()]
                    if folders:
                        self.addon_mods.append({"name": mod_name, "folders": folders})
        except Exception:
            import traceback; traceback.print_exc()

        self._render_addon_list()

        # 재실행 후 상태바: mod_folder가 복원된 경우 로그 다시 읽어 최신 항목 표시
        # (mod_folder 복원 블록 안에서 _activity_log를 set했더라도 여기서 확정)
        if self.mod_folder:
            self._activity_log = _read_log(self.mod_folder)
        fa = [e for e in self._activity_log if e.get("fa")]
        if fa:
            latest = fa[-1]
            self.status_var.set(f"  {latest['ts']}  {latest['msg']}   ▲ 로그 보기")

    # ── UI 구성 ──────────────────────────────
    def _build_ui(self):
        # ── 하단 상태바를 먼저 pack(side=bottom) 해야
        #    outer(expand=True)가 남은 공간을 정확히 채움 → 스크롤바 상하 공백 없음
        self.status_var = tk.StringVar(value="  경로를 설정하여 시작하세요.")
        status_bar = tk.Frame(self, bg=BG_PANEL, pady=5)
        status_bar.pack(fill="x", side="bottom")

        self._status_lbl = tk.Label(
            status_bar, textvariable=self.status_var,
            bg=BG_PANEL, fg=TEXT_SUB, font=FONT_SMALL, padx=12,
            cursor="hand2", anchor="w", justify="left"
        )
        self._status_lbl.pack(side="left", fill="both", expand=True)
        self._status_lbl.bind("<Button-1>", lambda e: self._open_log_dialog())
        status_bar.bind("<Button-1>", lambda e: self._open_log_dialog())

        # ── 우측 상단 고정, 스크롤 영역 밖
        credit_bar = tk.Frame(self, bg=BG_DARK)
        credit_bar.pack(fill="x", side="top")
        tk.Label(
            credit_bar,
            text="디아블로2 인벤 애드온·모드 자료실  (닉네임 : 풍선 제작)",
            bg=BG_DARK, fg=TEXT_DIM, font=("Segoe UI", 9),
            anchor="e", padx=16, pady=2
        ).pack(side="right")

        # ── 스크롤 가능 메인 영역 (상태바 다음에 pack → 공백 없이 꽉 참)
        outer = tk.Frame(self, bg=BG_DARK, bd=0)
        outer.pack(fill="both", expand=True)

        canvas = tk.Canvas(outer, bg=BG_DARK, bd=0, highlightthickness=0)
        vscroll = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
        canvas.configure(yscrollcommand=vscroll.set)

        vscroll.pack(side="right", fill="y")
        canvas.pack(side="left", fill="both", expand=True)

        # scroll_frame 자체에 좌우 padding 24 적용 → 스크롤바는 창 끝에 붙음
        self.scroll_frame = tk.Frame(canvas, bg=BG_DARK, padx=24, pady=14)
        self.scroll_window = canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")

        def _update_scrollregion(e=None):
            canvas.update_idletasks()
            bbox = canvas.bbox("all")
            if bbox:
                # scrollregion 상단을 항상 0으로 고정 → 위로 스크롤 불가
                canvas.configure(scrollregion=(0, 0, bbox[2], bbox[3]))

        self.scroll_frame.bind("<Configure>", _update_scrollregion)

        # canvas 너비 변경 시 scroll_frame 너비 동기화
        def _on_canvas_configure(e):
            canvas.itemconfig(self.scroll_window, width=e.width)

        canvas.bind("<Configure>", _on_canvas_configure)

        self._main_canvas = canvas
        self._update_scrollregion = _update_scrollregion

        # ── 마우스 휠 스크롤: canvas 위에 포커스가 있을 때만 동작
        #    (RollbackDialog 등 별도 창 제외)
        def _on_mousewheel(e):
            # 이벤트 위젯이 이 창(self) 소속인지 확인
            try:
                widget = e.widget
                # toplevel 찾기
                top = widget.winfo_toplevel()
                if top is not self:
                    return  # 다른 창(RollbackDialog 등)이면 무시
            except Exception:
                return
            # scrollregion 크기가 canvas 높이보다 클 때만 스크롤
            scroll_region = canvas.cget("scrollregion")
            if not scroll_region:
                return
            try:
                _, _, _, content_h = map(float, str(scroll_region).split())
            except Exception:
                return
            if content_h <= canvas.winfo_height():
                return  # 내용이 창보다 작으면 스크롤 불필요
            canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")

        self.bind_all("<MouseWheel>", _on_mousewheel)

        self._build_path_section()
        self._build_current_mod_section()
        self._build_addon_section()

    def _section_label(self, parent, text):
        f = tk.Frame(parent, bg=BG_DARK)
        f.pack(fill="x", pady=(14, 4))
        tk.Label(f, text=text, bg=BG_DARK, fg=ACCENT_GOLD,
                 font=FONT_HEAD).pack(side="left")
        tk.Frame(f, bg=BORDER, height=1).pack(side="left", fill="x", expand=True, padx=(10, 0))

    def _build_path_section(self):
        self._section_label(self.scroll_frame, "① 모드 폴더 경로 설정")

        card = tk.Frame(self.scroll_frame, bg=BG_CARD,
                        highlightbackground=BORDER, highlightthickness=1)
        card.pack(fill="x", pady=(0, 4))

        inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
        inner.pack(fill="x")

        tk.Label(inner, text="경로", bg=BG_CARD, fg=TEXT_SUB,
                 font=FONT_SMALL, width=6, anchor="w").pack(side="left")

        self.path_var = tk.StringVar(value="선택된 폴더 없음")
        path_lbl = tk.Label(inner, textvariable=self.path_var,
                            bg=BG_CARD, fg=TEXT_MAIN, font=FONT_MONO,
                            anchor="w")
        path_lbl.pack(side="left", fill="x", expand=True, padx=(0, 10))

        styled_button(inner, "📁  폴더 선택", self._select_mod_folder,
                      BORDER_GOLD, ACCENT_GOLD, fg="#ffffff",
                      font=FONT_SMALL, padx=14, pady=6).pack(side="right")

    def _build_current_mod_section(self):
        self._section_label(self.scroll_frame, "② 현재 모드")

        self.current_mod_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        self.current_mod_frame.pack(fill="x")

        self._render_current_mod()

    def _render_current_mod(self):
        for w in self.current_mod_frame.winfo_children():
            w.destroy()

        if not self.mpq_folder:
            card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
                            highlightbackground=BORDER, highlightthickness=1)
            card.pack(fill="x")
            tk.Label(card, text="모드 폴더를 먼저 선택하세요.",
                     bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL,
                     padx=14, pady=12).pack(anchor="w")
            return

        card = tk.Frame(self.current_mod_frame, bg=BG_CARD,
                        highlightbackground=BORDER_GOLD, highlightthickness=1)
        card.pack(fill="x")

        inner = tk.Frame(card, bg=BG_CARD, padx=14, pady=10)
        inner.pack(fill="x")

        # 모드 아이콘 + 이름
        left = tk.Frame(inner, bg=BG_CARD)
        left.pack(side="left", fill="x", expand=True)

        tk.Label(left, text="🗂", bg=BG_CARD, fg=ACCENT_GOLD,
                 font=("Segoe UI Emoji", 18)).pack(side="left")
        tk.Label(left, text=self.mod_name,
                 bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
                 padx=8).pack(side="left")
        tk.Label(left, text=".mpq",
                 bg=BG_CARD, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")

        # 버튼 우측 배치
        right = tk.Frame(inner, bg=BG_CARD)
        right.pack(side="right")

        styled_button(right, "💾  백업", self._backup_mod,
                      BTN_BACKUP, BTN_BACKUP_H, padx=14, pady=6).pack(side="left", padx=4)
        styled_button(right, "⟲  롤백", self._rollback_mod,
                      BTN_ROLL, BTN_ROLL_H, padx=14, pady=6).pack(side="left", padx=4)
        styled_button(right, "🛡  바닐라", self._restore_vanilla,
                      "#6b6b6b", "#555555", padx=14, pady=6).pack(side="left", padx=4)

        # 경로 표시
        path_frame = tk.Frame(card, bg=BG_CARD, padx=14, pady=(0, 8))
        path_frame.pack(fill="x")
        tk.Label(path_frame, text=str(self.mpq_folder),
                 bg=BG_CARD, fg=TEXT_DIM, font=FONT_MONO).pack(anchor="w")

    def _build_addon_section(self):
        self._section_label(self.scroll_frame, "③ 추가 모드 (스킨 / UI / 패치)")

        # 추가 버튼
        add_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        add_frame.pack(fill="x", pady=(0, 8))

        styled_button(add_frame, "+  모드 불러오기",
                      self._load_addon_mod,
                      BTN_INSTALL, BTN_INSTALL_H,
                      padx=18, pady=7, font=FONT_BODY).pack(side="left")

        tk.Label(add_frame,
                 text="  ※ global / hd / local 폴더만 허용",
                 bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL).pack(side="left")

        self.addon_list_frame = tk.Frame(self.scroll_frame, bg=BG_DARK)
        self.addon_list_frame.pack(fill="x")

        self._render_addon_list()

    def _render_addon_list(self):
        for w in self.addon_list_frame.winfo_children():
            w.destroy()

        if not self.addon_mods:
            tk.Label(self.addon_list_frame,
                     text="불러온 추가 모드가 없습니다.",
                     bg=BG_DARK, fg=TEXT_DIM, font=FONT_SMALL,
                     pady=8).pack(anchor="w")
            return

        for idx, mod in enumerate(self.addon_mods):
            self._render_addon_row(idx, mod)

    def _render_addon_row(self, idx: int, mod: dict):
        card = tk.Frame(self.addon_list_frame, bg=BG_CARD,
                        highlightbackground=BORDER, highlightthickness=1)
        card.pack(fill="x", pady=2)

        inner = tk.Frame(card, bg=BG_CARD, padx=8, pady=8)
        inner.pack(fill="x")

        # ── 드래그 핸들 (≡ 아이콘)
        handle = tk.Label(inner, text="≡", bg=BG_CARD, fg=TEXT_DIM,
                          font=("Segoe UI", 16), cursor="fleur", padx=6)
        handle.pack(side="left")

        # ── 모드명
        left = tk.Frame(inner, bg=BG_CARD)
        left.pack(side="left", fill="x", expand=True)

        tk.Label(left, text=mod["name"],
                 bg=BG_CARD, fg=TEXT_MAIN, font=FONT_HEAD,
                 padx=6).pack(side="left")

        # 포함 폴더 태그
        for folder_path in mod["folders"]:
            tag_color = {"global": "#d4edda",
                         "hd":     "#d0e8f8",
                         "local":  "#fce8d4"}.get(folder_path.name.lower(), BORDER)
            tk.Label(left, text=folder_path.name,
                     bg=tag_color, fg=TEXT_MAIN,
                     font=FONT_SMALL, padx=6, pady=2,
                     relief="flat").pack(side="left", padx=2)

        # ── 버튼 영역
        right = tk.Frame(inner, bg=BG_CARD)
        right.pack(side="right")

        styled_button(right, "설치",
                      lambda i=idx: self._install_addon(i),
                      BTN_BACKUP, BTN_BACKUP_H,
                      padx=14, pady=5).pack(side="left", padx=3)
        styled_button(right, "이름변경",
                      lambda i=idx: self._rename_addon(i),
                      "#5a6472", "#4a5462", fg="#ffffff",
                      padx=12, pady=5).pack(side="left", padx=3)
        styled_button(right, "제거",
                      lambda i=idx: self._remove_addon(i),
                      BTN_REMOVE, BTN_REMOVE_H,
                      padx=14, pady=5).pack(side="left", padx=3)

        # ── 드래그 & 드롭 순서 변경
        self._bind_drag(handle, card, idx)
        self._bind_drag(inner,  card, idx)

    # ── 동작 ─────────────────────────────────

    def _select_mod_folder(self):
        path = filedialog.askdirectory(title="mods 안의 모드 폴더 선택")
        if not path:
            return

        mod_folder = Path(path)
        # .mpq 폴더/파일 탐색 (폴더와 모드명이 일치해야 함)
        mod_name = mod_folder.name
        mpq_candidate = mod_folder / f"{mod_name}.mpq"

        if not mpq_candidate.exists():
            # .mpq 로 끝나는 것 아무거나 찾기
            candidates = [p for p in mod_folder.iterdir()
                          if p.name.lower().endswith(".mpq")]
            if not candidates:
                messagebox.showerror(
                    "오류",
                    f"'{mod_folder}' 안에 .mpq 폴더/파일이 없습니다.n"
                    "올바른 모드 폴더를 선택하세요."
                )
                return
            mpq_candidate = candidates[0]

        self.mod_folder  = mod_folder
        self.mpq_folder  = mpq_candidate
        self.mod_name    = mpq_candidate.name.replace(".mpq", "")

        # 새 모드 로드 → 로그 초기화 후 첫 항목 기록
        self._activity_log = _read_log(mod_folder)
        self.path_var.set(str(mod_folder))
        self._render_current_mod()
        self._add_log(f"모드 로드: {self.mod_name}", file_affecting=True)
        self._save_state()

    def _backup_mod(self):
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "유효한 .mpq 폴더가 없습니다.")
            return

        # 백업 메모 입력 (빈 값 허용 → 메모 없음)
        memo = AskStringDialog.ask(
            self,
            "백업 메모",
            "이 백업 상태를 설명하는 메모를 입력하세요.n(비워두면 날짜/시간만 표시됩니다)"
        )
        if memo is None:          # 취소 버튼
            return
        memo = memo.strip()

        mods_folder = self.mod_folder.parent
        backup_root = mods_folder / "BackUp"
        backup_root.mkdir(exist_ok=True)

        now = datetime.now()
        ts  = now.strftime("%y%m%d%H%M%S")        # 예: 260612091036
        dst_name = f"{self.mod_name}{ts}.mpq"
        dst_path = backup_root / dst_name

        if dst_path.exists():
            messagebox.showwarning("중복", f"같은 이름의 백업이 이미 존재합니다:n{dst_name}")
            return

        try:
            shutil.copytree(str(self.mpq_folder), str(dst_path))
        except Exception as e:
            messagebox.showerror("백업 실패", str(e))
            return

        # 메모가 있으면 backup_labels.json 에 저장
        if memo:
            labels = _read_backup_labels(backup_root)
            labels[dst_name] = memo
            _write_backup_labels(backup_root, labels)

        # 백업 시점 로그 항목 추가 후 스냅샷을 백업 이름으로 함께 저장
        label_str = f" [{memo}]" if memo else ""
        self._add_log(f"백업{label_str} → {dst_name}", file_affecting=True)
        # 현재 로그를 이 백업 폴더에도 복사 (롤백 때 복원용)
        try:
            snap_path = dst_path / "_activity_log_snapshot.json"
            snap_path.write_text(
                json.dumps(self._activity_log, ensure_ascii=False, indent=2),
                encoding="utf-8"
            )
        except Exception:
            pass

        messagebox.showinfo("백업 완료", f"백업이 저장되었습니다:n{dst_path}")

    def _rollback_mod(self):
        if not self.mod_folder:
            messagebox.showerror("오류", "모드 폴더가 설정되지 않았습니다.")
            return

        mods_folder  = self.mod_folder.parent
        backup_root  = mods_folder / "BackUp"

        if not backup_root.exists() or not any(backup_root.iterdir()):
            messagebox.showinfo("백업 없음", "BackUp 폴더에 저장된 백업이 없습니다.")
            return

        dlg = RollbackDialog(self, backup_root, self.mod_name)
        self.wait_window(dlg)

        if not dlg.selected:
            return

        selected_path = backup_root / dlg.selected

        confirm = messagebox.askyesno(
            "롤백 확인",
            f"현재 모드 '{self.mod_name}.mpq'를 삭제하고n"
            f"'{dlg.selected}'으로 복원하시겠습니까?nn"
            "이 작업은 되돌릴 수 없습니다."
        )
        if not confirm:
            return

        try:
            # 현재 모드 삭제
            if self.mpq_folder.exists():
                shutil.rmtree(str(self.mpq_folder))

            # 백업 복사 (원래 이름으로)
            target = self.mod_folder / f"{self.mod_name}.mpq"
            shutil.copytree(str(selected_path), str(target))

            self.mpq_folder = target
        except Exception as e:
            messagebox.showerror("롤백 실패", str(e))
            return

        # 백업에 저장된 로그 스냅샷 복원
        snap = target / "_activity_log_snapshot.json"
        if snap.exists():
            try:
                self._activity_log = json.loads(snap.read_text(encoding="utf-8"))
                _write_log(self.mod_folder, self._activity_log)
            except Exception:
                pass
        self._add_log(f"롤백 완료 → {dlg.selected}", file_affecting=True)

        messagebox.showinfo("롤백 완료",
                            f"'{self.mod_name}.mpq'이 복원되었습니다.")
        self._render_current_mod()

    def _restore_vanilla(self):
        """현재 모드의 data 폴더 안 global / hd / local 폴더를 삭제해 바닐라 상태로 복원한다."""
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
            return

        data_folder = self.mpq_folder / "data"
        targets = [data_folder / name for name in ("global", "hd", "local")
                   if (data_folder / name).exists()]

        if not targets:
            messagebox.showinfo(
                "이미 바닐라 상태",
                f"'{data_folder}' 안에 global / hd / local 폴더가 없습니다.n"
                "이미 바닐라(순정) 상태입니다."
            )
            return

        folder_list = "n".join(f"  • {t.name}" for t in targets)
        confirmed = messagebox.askyesno(
            "⚠️  바닐라 복원 확인",
            f"아래 폴더를 영구적으로 삭제합니다.nn"
            f"{folder_list}nn"
            f"경로: {data_folder}nn"
            "삭제된 파일은 복구할 수 없습니다.n"
            "계속하시겠습니까?",
            icon="warning"
        )
        if not confirmed:
            return

        errors = []
        deleted = []
        for t in targets:
            try:
                shutil.rmtree(str(t))
                deleted.append(t.name)
            except Exception as e:
                errors.append(f"{t.name}: {e}")

        if errors:
            messagebox.showerror("삭제 오류", "n".join(errors))
        else:
            deleted_str = ", ".join(deleted)
            messagebox.showinfo(
                "바닐라 복원 완료",
                f"다음 폴더가 삭제되었습니다: {deleted_str}nn"
                "클라이언트가 순정(바닐라) 상태로 실행됩니다."
            )
            self._add_log(f"바닐라 복원 — 삭제: {deleted_str}", file_affecting=True)

    def _load_addon_mod(self):
        """폴더 다중 선택 → 유효성 검사 → 모드명 입력 → 등록"""
        folders: list[Path] = []
        while True:
            path = filedialog.askdirectory(
                title=f"모드 폴더 선택 (global/hd/local)  ―  현재 {len(folders)}개 선택됨 | 취소 시 완료"
            )
            if not path:
                break
            p = Path(path)
            if p.name.lower() not in VALID_FOLDERS:
                messagebox.showerror(
                    "잘못된 폴더",
                    f"'{p.name}' 폴더는 허용되지 않습니다.n"
                    "global, hd, local 폴더만 선택할 수 있습니다."
                )
                continue
            if p.name.lower() in ("global", "local") and p.parent.name.lower() == "hd":
                messagebox.showerror(
                    "경로 오류",
                    f"선택한 '{p.name}' 폴더의 상위가 'hd' 입니다.nn"
                    f"현재 경로: {p}nn"
                    "이 경로는 data/hd/global (또는 local) 로 설치됩니다.n"
                    "일반 모드에 필요한 경로는 data/global (또는 data/local) 이므로,n"
                    "hd 폴더 바깥의 올바른 global / local 폴더를 선택하세요."
                )
                continue
            if p not in folders:
                folders.append(p)

        if not folders:
            return

        mod_name = AskStringDialog.ask(
            self,
            "모드명 입력",
            "이 모드의 이름을 입력하세요:"
        )
        if not mod_name or not mod_name.strip():
            return

        name = mod_name.strip()

        # ── mod/<모드명>/ 폴더에 파일 복사 (영구 보관)
        store_dir = MOD_STORE_DIR / name
        if store_dir.exists():
            overwrite = messagebox.askyesno(
                "이미 존재",
                f"'{name}' 모드가 이미 저장되어 있습니다.n덮어쓸까요?"
            )
            if not overwrite:
                return
            shutil.rmtree(str(store_dir))

        try:
            store_dir.mkdir(parents=True, exist_ok=True)
            for src in folders:
                dst = store_dir / src.name
                shutil.copytree(str(src), str(dst))
        except Exception as e:
            messagebox.showerror("저장 실패", f"mod 폴더에 복사 중 오류:n{e}")
            return

        # ── addon_mods 에 등록 (folders는 mod/ 내부 경로)
        stored_folders = [store_dir / src.name for src in folders]
        self.addon_mods.append({"name": name, "folders": stored_folders})
        self._render_addon_list()
        self._add_log(f"추가 모드 등록: {name}")
        self._save_state()

    def _install_addon(self, idx: int):
        if not self.mpq_folder or not self.mpq_folder.exists():
            messagebox.showerror("오류", "현재 모드(.mpq)가 설정되지 않았습니다.")
            return

        mod  = self.addon_mods[idx]
        data_folder = self.mpq_folder / "data"

        if not data_folder.exists():
            confirm = messagebox.askyesno(
                "data 폴더 없음",
                f"'{data_folder}' 폴더가 없습니다. 생성하고 계속할까요?"
            )
            if not confirm:
                return
            data_folder.mkdir(parents=True)

        errors = []
        for src in mod["folders"]:
            dst = data_folder / src.name
            try:
                dst.mkdir(parents=True, exist_ok=True)
                shutil.copytree(str(src), str(dst), dirs_exist_ok=True)
            except Exception as e:
                errors.append(f"{src.name}: {e}")

        if errors:
            messagebox.showerror("설치 오류", "n".join(errors))
        else:
            messagebox.showinfo("설치 완료",
                                f"'{mod['name']}' 모드가 설치되었습니다.n"
                                f"→ {data_folder}")
            self._add_log(f"설치 완료: {mod['name']}", file_affecting=True)

    def _remove_addon(self, idx: int):
        mod = self.addon_mods[idx]
        confirm = messagebox.askyesno(
            "제거 확인",
            f"'{mod['name']}' 모드를 목록에서 제거할까요?nn"
            "저장된 mod 폴더의 파일도 함께 삭제됩니다."
        )
        if confirm:
            # mod/<모드명>/ 폴더 삭제
            store_dir = MOD_STORE_DIR / mod["name"]
            if store_dir.exists():
                try:
                    shutil.rmtree(str(store_dir))
                except Exception as e:
                    messagebox.showerror("삭제 실패", f"mod 폴더 삭제 중 오류:n{e}")
            self.addon_mods.pop(idx)
            self._render_addon_list()
            self._add_log(f"모드 제거: {mod['name']}")
            self._save_state()

    def _rename_addon(self, idx: int):
        mod = self.addon_mods[idx]
        new_name = AskStringDialog.ask(
            self,
            "이름 변경",
            f"'{mod['name']}' 의 새 이름을 입력하세요:",
            initial=mod["name"]
        )
        if not new_name or not new_name.strip():
            return
        new_name = new_name.strip()
        if new_name == mod["name"]:
            return

        # mod/ 폴더 이름 변경
        old_dir = MOD_STORE_DIR / mod["name"]
        new_dir = MOD_STORE_DIR / new_name
        if new_dir.exists():
            messagebox.showerror("이름 충돌", f"'{new_name}' 이름의 모드가 이미 존재합니다.")
            return
        try:
            if old_dir.exists():
                old_dir.rename(new_dir)
        except Exception as e:
            messagebox.showerror("이름 변경 실패", str(e))
            return

        # addon_mods 내부 경로도 새 이름으로 업데이트
        new_folders = [new_dir / f.name for f in mod["folders"]]
        self.addon_mods[idx] = {"name": new_name, "folders": new_folders}
        self._render_addon_list()
        self._add_log(f"이름 변경: {mod['name']} → {new_name}")

    # ── 드래그 & 드롭 순서 변경 ─────────────────────────────────────────
    def _bind_drag(self, widget: tk.Widget, card: tk.Frame, idx: int):
        """widget 에 드래그 이벤트를 바인딩해 addon_mods 순서를 변경한다."""
        state = {}

        def on_press(e, i=idx):
            state["start_y"] = e.y_root
            state["idx"]     = i
            # 드래그 중인 카드 강조
            card.configure(highlightbackground=ACCENT_GOLD)

        def on_drag(e):
            pass  # 시각 피드백은 release 시 처리

        def on_release(e, i=idx):
            card.configure(highlightbackground=BORDER)
            if "start_y" not in state:
                return
            dy = e.y_root - state["start_y"]
            # 카드 높이를 동적으로 구함 (기본 ~42px)
            try:
                card_h = max(card.winfo_height(), 42)
            except Exception:
                card_h = 42
            steps = round(dy / card_h)
            if steps == 0:
                return
            src = i
            dst = max(0, min(len(self.addon_mods) - 1, src + steps))
            if src == dst:
                return
            # 순서 변경
            item = self.addon_mods.pop(src)
            self.addon_mods.insert(dst, item)
            self._render_addon_list()
            self._add_log(f"순서 변경: '{item['name']}' {src+1}번 → {dst+1}번")

        widget.bind("<ButtonPress-1>",   on_press)
        widget.bind("<B1-Motion>",       on_drag)
        widget.bind("<ButtonRelease-1>", on_release)

    # ── 로그 & 상태바 ────────────────────────────────────────────────────

    def _add_log(self, msg: str, file_affecting: bool = False):
        """로그 항목 추가, 상태바 갱신, 파일 저장.
        file_affecting=True 인 항목만 로그 다이얼로그에 표시된다.
        """
        entry = {"ts": _ts_now(), "msg": msg, "fa": file_affecting}
        self._activity_log.append(entry)
        # 상태바에는 file_affecting 항목만 표시 (없으면 마지막 항목)
        fa_entries = [e for e in self._activity_log if e.get("fa")]
        latest = fa_entries[-1] if fa_entries else entry
        self.status_var.set(f"  {latest['ts']}  {latest['msg']}   ▲ 로그 보기")
        # mod_folder 가 설정돼 있으면 즉시 파일에 저장
        if self.mod_folder:
            _write_log(self.mod_folder, self._activity_log)

    def _open_log_dialog(self):
        fa_entries = [e for e in self._activity_log if e.get("fa")]
        if not fa_entries:
            messagebox.showinfo("로그 없음",
                                "아직 기록된 파일 변경 이력이 없습니다.n"
                                "모드를 설치하거나 롤백해보세요.")
            return
        mod_name = self.mod_name or "—"
        LogDialog(self, fa_entries, mod_name)


# ──────────────────────────────────────────────
#  진입점
# ──────────────────────────────────────────────
if __name__ == "__main__":
    _set_appid()
    app = D2RModManager()
    app.mainloop()