# pip install Pillow tkinterdnd2
"""
Windows image-to-GIF GUI

- Drag and drop up to 3 image files.
- Set each frame duration independently.
- Set infinite or finite playback count.
- Add a pause before the animation repeats.
- Preview the animation before saving.

Run:
    python yjm_img2gif.py
"""

from __future__ import annotations

import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

import tkinter as tk
from tkinter import filedialog, messagebox, ttk

try:
    from PIL import Image, ImageOps, ImageTk
except ImportError as exc:
    raise SystemExit(
        "Pillow가 설치되어 있지 않습니다.\n"
        "명령 프롬프트에서 다음을 실행하세요:\n"
        "pip install Pillow tkinterdnd2"
    ) from exc

try:
    from tkinterdnd2 import DND_FILES, TkinterDnD

    DND_AVAILABLE = True
except ImportError:
    DND_FILES = None
    TkinterDnD = None
    DND_AVAILABLE = False


APP_TITLE = "YJM 이미지 3장 → 움직이는 GIF"
SUPPORTED_EXTENSIONS = {
    ".png",
    ".jpg",
    ".jpeg",
    ".webp",
    ".bmp",
    ".tif",
    ".tiff",
}
MAX_IMAGES = 3
PREVIEW_BOX = (500, 500)


@dataclass
class FrameItem:
    path: Path
    duration_ms: int = 500


class ImgToGifApp:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.items: list[FrameItem] = []
        self.preview_frames: list[ImageTk.PhotoImage] = []
        self.preview_durations: list[int] = []
        self.preview_index = 0
        self.preview_job: Optional[str] = None
        self.last_output: Optional[Path] = None

        self.default_seconds = tk.DoubleVar(value=0.5)
        self.repeat_count = tk.IntVar(value=0)
        self.repeat_pause = tk.DoubleVar(value=0.0)
        self.size_mode = tk.StringVar(value="가장 큰 이미지 기준")
        self.custom_width = tk.IntVar(value=512)
        self.custom_height = tk.IntVar(value=512)
        self.background = tk.StringVar(value="투명")
        self.status_text = tk.StringVar(value="이미지 3개를 끌어놓으세요.")

        self._configure_window()
        self._configure_style()
        self._build_ui()
        self._configure_drop_targets()
        self._update_controls()

    def _configure_window(self) -> None:
        self.root.title(APP_TITLE)
        self.root.geometry("1060x720")
        self.root.minsize(920, 650)
        self.root.configure(bg="#17191d")
        self.root.protocol("WM_DELETE_WINDOW", self._on_close)

    def _configure_style(self) -> None:
        style = ttk.Style(self.root)
        try:
            style.theme_use("clam")
        except tk.TclError:
            pass

        style.configure(".", font=("맑은 고딕", 10))
        style.configure("TFrame", background="#17191d")
        style.configure("Card.TFrame", background="#23262c")
        style.configure(
            "TLabel", background="#17191d", foreground="#e8eaf0"
        )
        style.configure(
            "Card.TLabel", background="#23262c", foreground="#e8eaf0"
        )
        style.configure(
            "Title.TLabel",
            background="#17191d",
            foreground="#ffffff",
            font=("맑은 고딕", 17, "bold"),
        )
        style.configure(
            "Hint.TLabel", background="#23262c", foreground="#aeb4bf"
        )
        style.configure(
            "Accent.TButton",
            background="#5b7cfa",
            foreground="#ffffff",
            font=("맑은 고딕", 10, "bold"),
            padding=(14, 8),
        )
        style.map(
            "Accent.TButton",
            background=[("active", "#6f8cff"), ("disabled", "#3a4050")],
        )
        style.configure("TButton", padding=(10, 6))
        style.configure(
            "Treeview",
            background="#1c1f24",
            fieldbackground="#1c1f24",
            foreground="#f1f2f5",
            rowheight=31,
            borderwidth=0,
        )
        style.configure(
            "Treeview.Heading",
            background="#30343c",
            foreground="#ffffff",
            relief="flat",
            font=("맑은 고딕", 10, "bold"),
        )
        style.map(
            "Treeview",
            background=[("selected", "#455fae")],
            foreground=[("selected", "#ffffff")],
        )
        style.configure(
            "TCombobox",
            fieldbackground="#f6f7fb",
            foreground="#111111",
        )

    def _build_ui(self) -> None:
        outer = ttk.Frame(self.root, padding=18)
        outer.pack(fill="both", expand=True)

        ttk.Label(outer, text=APP_TITLE, style="Title.TLabel").pack(anchor="w")
        subtitle = (
            "PNG/JPG/WEBP/BMP/TIFF 지원 · 목록 순서대로 재생 · 0회는 무한 반복"
        )
        ttk.Label(outer, text=subtitle).pack(anchor="w", pady=(3, 14))

        body = ttk.Frame(outer)
        body.pack(fill="both", expand=True)
        body.columnconfigure(0, weight=6)
        body.columnconfigure(1, weight=5)
        body.rowconfigure(0, weight=1)

        left = ttk.Frame(body, style="Card.TFrame", padding=14)
        left.grid(row=0, column=0, sticky="nsew", padx=(0, 9))
        left.columnconfigure(0, weight=1)
        left.rowconfigure(2, weight=1)

        right = ttk.Frame(body, style="Card.TFrame", padding=14)
        right.grid(row=0, column=1, sticky="nsew", padx=(9, 0))
        right.columnconfigure(0, weight=1)
        right.rowconfigure(1, weight=1)

        self._build_file_panel(left)
        self._build_preview_panel(right)

        footer = ttk.Frame(outer)
        footer.pack(fill="x", pady=(14, 0))
        ttk.Label(footer, textvariable=self.status_text).pack(
            side="left", fill="x", expand=True
        )

        self.open_button = ttk.Button(
            footer, text="결과 폴더 열기", command=self._open_result_folder
        )
        self.open_button.pack(side="right", padx=(8, 0))
        self.create_button = ttk.Button(
            footer,
            text="GIF 생성 및 저장",
            style="Accent.TButton",
            command=self._create_gif,
        )
        self.create_button.pack(side="right")

    def _build_file_panel(self, parent: ttk.Frame) -> None:
        header = ttk.Frame(parent, style="Card.TFrame")
        header.grid(row=0, column=0, sticky="ew")
        ttk.Label(header, text="1. 이미지 3개", style="Card.TLabel").pack(
            side="left"
        )
        self.count_label = ttk.Label(
            header, text="0 / 3", style="Hint.TLabel"
        )
        self.count_label.pack(side="right")

        drop_text = (
            "여기로 이미지 3개를 드래그 앤 드롭"
            if DND_AVAILABLE
            else "tkinterdnd2 미설치: 아래 '파일 추가' 버튼을 사용하세요"
        )
        self.drop_label = tk.Label(
            parent,
            text=drop_text,
            bg="#30343c",
            fg="#dce2f2",
            activebackground="#3d4350",
            font=("맑은 고딕", 11, "bold"),
            relief="flat",
            height=3,
            cursor="hand2",
        )
        self.drop_label.grid(row=1, column=0, sticky="ew", pady=(10, 10))
        self.drop_label.bind("<Button-1>", lambda _event: self._browse_images())

        tree_wrap = ttk.Frame(parent, style="Card.TFrame")
        tree_wrap.grid(row=2, column=0, sticky="nsew")
        tree_wrap.columnconfigure(0, weight=1)
        tree_wrap.rowconfigure(0, weight=1)

        columns = ("order", "filename", "dimensions", "seconds")
        self.tree = ttk.Treeview(
            tree_wrap,
            columns=columns,
            show="headings",
            selectmode="browse",
        )
        self.tree.heading("order", text="순서")
        self.tree.heading("filename", text="파일명")
        self.tree.heading("dimensions", text="크기")
        self.tree.heading("seconds", text="표시 시간")
        self.tree.column("order", width=52, minwidth=45, anchor="center")
        self.tree.column("filename", width=230, minwidth=130)
        self.tree.column("dimensions", width=90, minwidth=75, anchor="center")
        self.tree.column("seconds", width=88, minwidth=76, anchor="center")
        self.tree.grid(row=0, column=0, sticky="nsew")
        self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
        self.tree.bind("<Double-1>", self._on_tree_double_click)

        scroll = ttk.Scrollbar(tree_wrap, orient="vertical", command=self.tree.yview)
        scroll.grid(row=0, column=1, sticky="ns")
        self.tree.configure(yscrollcommand=scroll.set)

        file_buttons = ttk.Frame(parent, style="Card.TFrame")
        file_buttons.grid(row=3, column=0, sticky="ew", pady=(10, 4))
        ttk.Button(file_buttons, text="파일 추가", command=self._browse_images).pack(
            side="left"
        )
        ttk.Button(file_buttons, text="삭제", command=self._remove_selected).pack(
            side="left", padx=(6, 0)
        )
        ttk.Button(file_buttons, text="전체 삭제", command=self._clear_items).pack(
            side="left", padx=(6, 0)
        )
        ttk.Button(file_buttons, text="아래로", command=lambda: self._move_item(1)).pack(
            side="right"
        )
        ttk.Button(file_buttons, text="위로", command=lambda: self._move_item(-1)).pack(
            side="right", padx=(0, 6)
        )

        options = ttk.LabelFrame(parent, text=" 2. 시간과 반복 설정 ", padding=10)
        options.grid(row=4, column=0, sticky="ew", pady=(10, 0))
        for column in range(6):
            options.columnconfigure(column, weight=1 if column in (1, 4) else 0)

        ttk.Label(options, text="선택 장면(초)").grid(
            row=0, column=0, sticky="w", padx=(0, 6)
        )
        self.seconds_spin = ttk.Spinbox(
            options,
            from_=0.05,
            to=60.0,
            increment=0.05,
            textvariable=self.default_seconds,
            width=8,
            format="%.2f",
        )
        self.seconds_spin.grid(row=0, column=1, sticky="ew")
        ttk.Button(options, text="선택 적용", command=self._apply_duration_selected).grid(
            row=0, column=2, padx=(6, 0)
        )
        ttk.Button(options, text="전체 적용", command=self._apply_duration_all).grid(
            row=0, column=3, padx=(6, 14)
        )

        ttk.Label(options, text="재생 횟수").grid(
            row=0, column=4, sticky="e", padx=(0, 6)
        )
        ttk.Spinbox(
            options,
            from_=0,
            to=999,
            increment=1,
            textvariable=self.repeat_count,
            width=7,
        ).grid(row=0, column=5, sticky="ew")

        ttk.Label(options, text="반복 전 대기(초)").grid(
            row=1, column=0, sticky="w", pady=(9, 0), padx=(0, 6)
        )
        ttk.Spinbox(
            options,
            from_=0.0,
            to=60.0,
            increment=0.1,
            textvariable=self.repeat_pause,
            width=8,
            format="%.1f",
        ).grid(row=1, column=1, sticky="ew", pady=(9, 0))
        ttk.Label(
            options,
            text="0=무한 · 대기 시간은 마지막 장면에 추가됩니다.",
        ).grid(row=1, column=2, columnspan=4, sticky="w", padx=(8, 0), pady=(9, 0))

        size_box = ttk.LabelFrame(parent, text=" 3. 출력 크기 ", padding=10)
        size_box.grid(row=5, column=0, sticky="ew", pady=(10, 0))
        size_box.columnconfigure(1, weight=1)
        size_box.columnconfigure(5, weight=1)

        ttk.Label(size_box, text="기준").grid(row=0, column=0, padx=(0, 6))
        self.size_combo = ttk.Combobox(
            size_box,
            textvariable=self.size_mode,
            values=("가장 큰 이미지 기준", "첫 이미지 기준", "직접 입력"),
            state="readonly",
            width=18,
        )
        self.size_combo.grid(row=0, column=1, sticky="ew")
        self.size_combo.bind("<<ComboboxSelected>>", lambda _event: self._size_changed())

        ttk.Label(size_box, text="너비").grid(row=0, column=2, padx=(10, 5))
        self.width_spin = ttk.Spinbox(
            size_box,
            from_=1,
            to=10000,
            textvariable=self.custom_width,
            width=7,
        )
        self.width_spin.grid(row=0, column=3)
        ttk.Label(size_box, text="높이").grid(row=0, column=4, padx=(10, 5))
        self.height_spin = ttk.Spinbox(
            size_box,
            from_=1,
            to=10000,
            textvariable=self.custom_height,
            width=7,
        )
        self.height_spin.grid(row=0, column=5, sticky="ew")

        ttk.Label(size_box, text="여백 배경").grid(
            row=1, column=0, padx=(0, 6), pady=(9, 0)
        )
        ttk.Combobox(
            size_box,
            textvariable=self.background,
            values=("투명", "흰색", "검정"),
            state="readonly",
            width=18,
        ).grid(row=1, column=1, sticky="ew", pady=(9, 0))
        ttk.Label(
            size_box, text="비율은 유지하고 남는 영역만 선택한 배경으로 채웁니다."
        ).grid(row=1, column=2, columnspan=4, sticky="w", padx=(10, 0), pady=(9, 0))

    def _build_preview_panel(self, parent: ttk.Frame) -> None:
        header = ttk.Frame(parent, style="Card.TFrame")
        header.grid(row=0, column=0, sticky="ew")
        ttk.Label(header, text="4. 미리보기", style="Card.TLabel").pack(side="left")
        self.preview_info = ttk.Label(
            header, text="아직 이미지 없음", style="Hint.TLabel"
        )
        self.preview_info.pack(side="right")

        self.preview_label = tk.Label(
            parent,
            text="이미지를 추가한 뒤\n미리보기 버튼을 누르세요.",
            bg="#111317",
            fg="#8f97a5",
            font=("맑은 고딕", 12),
            relief="flat",
        )
        self.preview_label.grid(row=1, column=0, sticky="nsew", pady=(10, 10))

        buttons = ttk.Frame(parent, style="Card.TFrame")
        buttons.grid(row=2, column=0, sticky="ew")
        self.preview_button = ttk.Button(
            buttons, text="▶ 미리보기", command=self._start_preview
        )
        self.preview_button.pack(side="left")
        ttk.Button(buttons, text="■ 정지", command=self._stop_preview).pack(
            side="left", padx=(7, 0)
        )
        ttk.Button(buttons, text="첫 장면", command=self._show_first_frame).pack(
            side="left", padx=(7, 0)
        )

        ttk.Label(
            parent,
            text=(
                "GIF는 최대 256색을 사용합니다. 사진은 색이 다소 줄어들 수 있고, "
                "투명 가장자리는 GIF 규격상 반투명 대신 투명/불투명으로 처리됩니다."
            ),
            style="Hint.TLabel",
            wraplength=430,
            justify="left",
        ).grid(row=3, column=0, sticky="ew", pady=(12, 0))

    def _configure_drop_targets(self) -> None:
        if not DND_AVAILABLE:
            return
        for widget in (self.root, self.drop_label, self.tree, self.preview_label):
            widget.drop_target_register(DND_FILES)
            widget.dnd_bind("<<Drop>>", self._on_drop)

    def _on_drop(self, event: tk.Event) -> None:
        try:
            raw_paths = list(self.root.tk.splitlist(event.data))
        except (tk.TclError, AttributeError):
            raw_paths = [str(event.data)]
        self._add_paths(raw_paths)

    def _browse_images(self) -> None:
        paths = filedialog.askopenfilenames(
            title="GIF로 만들 이미지 선택",
            filetypes=[
                ("이미지 파일", "*.png *.jpg *.jpeg *.webp *.bmp *.tif *.tiff"),
                ("모든 파일", "*.*"),
            ],
        )
        if paths:
            self._add_paths(list(paths))

    def _add_paths(self, raw_paths: list[str]) -> None:
        existing = {item.path.resolve() for item in self.items}
        added = 0
        rejected: list[str] = []

        for raw in raw_paths:
            path = Path(raw.strip().strip("{}"))
            if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
                rejected.append(path.name or str(path))
                continue
            try:
                resolved = path.resolve()
            except OSError:
                resolved = path
            if resolved in existing:
                continue
            if len(self.items) >= MAX_IMAGES:
                break
            try:
                with Image.open(path) as image:
                    image.verify()
            except Exception:
                rejected.append(path.name)
                continue

            seconds = self._safe_float(self.default_seconds.get(), 0.5, 0.05, 60.0)
            self.items.append(
                FrameItem(path=path, duration_ms=max(50, round(seconds * 1000)))
            )
            existing.add(resolved)
            added += 1

        self._refresh_tree()
        self._invalidate_preview()
        if added:
            self.status_text.set(f"이미지 {added}개 추가됨 · 현재 {len(self.items)}/3")
        elif len(self.items) >= MAX_IMAGES:
            self.status_text.set("이미지는 최대 3개까지 추가할 수 있습니다.")

        if rejected:
            messagebox.showwarning(
                "추가하지 못한 파일",
                "지원하지 않거나 읽을 수 없는 파일입니다:\n" + "\n".join(rejected[:8]),
            )

    def _refresh_tree(self, selected_index: Optional[int] = None) -> None:
        self.tree.delete(*self.tree.get_children())
        for index, item in enumerate(self.items):
            try:
                with Image.open(item.path) as image:
                    dimensions = f"{image.width}×{image.height}"
            except Exception:
                dimensions = "읽기 오류"
            self.tree.insert(
                "",
                "end",
                iid=str(index),
                values=(
                    index + 1,
                    item.path.name,
                    dimensions,
                    f"{item.duration_ms / 1000:.2f}초",
                ),
            )
        if selected_index is not None and self.items:
            selected_index = max(0, min(selected_index, len(self.items) - 1))
            iid = str(selected_index)
            self.tree.selection_set(iid)
            self.tree.focus(iid)
            self.tree.see(iid)
        self._update_controls()

    def _selected_index(self) -> Optional[int]:
        selection = self.tree.selection()
        if not selection:
            return None
        try:
            return int(selection[0])
        except (TypeError, ValueError):
            return None

    def _on_tree_select(self, _event: tk.Event) -> None:
        index = self._selected_index()
        if index is not None and 0 <= index < len(self.items):
            self.default_seconds.set(self.items[index].duration_ms / 1000)

    def _on_tree_double_click(self, event: tk.Event) -> None:
        row = self.tree.identify_row(event.y)
        if row:
            self.tree.selection_set(row)
            self.seconds_spin.focus_set()
            self.seconds_spin.selection_range(0, "end")

    def _remove_selected(self) -> None:
        index = self._selected_index()
        if index is None:
            return
        del self.items[index]
        self._refresh_tree(index)
        self._invalidate_preview()
        self.status_text.set("선택한 이미지를 삭제했습니다.")

    def _clear_items(self) -> None:
        if not self.items:
            return
        self.items.clear()
        self._refresh_tree()
        self._invalidate_preview(clear_canvas=True)
        self.status_text.set("이미지 목록을 비웠습니다.")

    def _move_item(self, direction: int) -> None:
        index = self._selected_index()
        if index is None:
            return
        target = index + direction
        if target < 0 or target >= len(self.items):
            return
        self.items[index], self.items[target] = self.items[target], self.items[index]
        self._refresh_tree(target)
        self._invalidate_preview()

    def _apply_duration_selected(self) -> None:
        index = self._selected_index()
        if index is None:
            messagebox.showinfo("장면 선택", "시간을 바꿀 장면을 먼저 선택하세요.")
            return
        seconds = self._validated_seconds()
        if seconds is None:
            return
        self.items[index].duration_ms = round(seconds * 1000)
        self._refresh_tree(index)
        self._invalidate_preview()
        self.status_text.set(f"{index + 1}번 장면을 {seconds:.2f}초로 설정했습니다.")

    def _apply_duration_all(self) -> None:
        if not self.items:
            return
        seconds = self._validated_seconds()
        if seconds is None:
            return
        duration_ms = round(seconds * 1000)
        for item in self.items:
            item.duration_ms = duration_ms
        self._refresh_tree(self._selected_index())
        self._invalidate_preview()
        self.status_text.set(f"모든 장면을 {seconds:.2f}초로 설정했습니다.")

    def _validated_seconds(self) -> Optional[float]:
        try:
            seconds = float(self.default_seconds.get())
        except (tk.TclError, TypeError, ValueError):
            messagebox.showerror("시간 오류", "표시 시간은 숫자로 입력하세요.")
            return None
        if not 0.05 <= seconds <= 60:
            messagebox.showerror("시간 오류", "표시 시간은 0.05~60초로 입력하세요.")
            return None
        return seconds

    def _size_changed(self) -> None:
        self._update_controls()
        self._invalidate_preview()

    def _update_controls(self) -> None:
        count = len(self.items)
        self.count_label.configure(text=f"{count} / 3")
        complete = count == MAX_IMAGES
        self.create_button.configure(state="normal" if complete else "disabled")
        self.preview_button.configure(state="normal" if count else "disabled")
        self.open_button.configure(state="normal" if self.last_output else "disabled")

        custom_state = "normal" if self.size_mode.get() == "직접 입력" else "disabled"
        self.width_spin.configure(state=custom_state)
        self.height_spin.configure(state=custom_state)

        if complete:
            self.drop_label.configure(text="3개 준비 완료 · 클릭하면 다시 선택")
        elif DND_AVAILABLE:
            self.drop_label.configure(
                text=f"여기로 이미지 드래그 앤 드롭 · {MAX_IMAGES - count}개 더 필요"
            )

    def _canvas_size(self) -> tuple[int, int]:
        if not self.items:
            raise ValueError("이미지가 없습니다.")

        mode = self.size_mode.get()
        if mode == "직접 입력":
            try:
                width = int(self.custom_width.get())
                height = int(self.custom_height.get())
            except (tk.TclError, TypeError, ValueError) as exc:
                raise ValueError("너비와 높이는 정수로 입력하세요.") from exc
            if not (1 <= width <= 10000 and 1 <= height <= 10000):
                raise ValueError("너비와 높이는 1~10000 픽셀로 입력하세요.")
            if width * height > 40_000_000:
                raise ValueError("출력 크기가 너무 큽니다. 총 4천만 픽셀 이하로 설정하세요.")
            return width, height

        sizes: list[tuple[int, int]] = []
        for item in self.items:
            with Image.open(item.path) as image:
                sizes.append(image.size)
        if mode == "첫 이미지 기준":
            return sizes[0]
        return max(width for width, _height in sizes), max(
            height for _width, height in sizes
        )

    def _background_rgba(self) -> tuple[int, int, int, int]:
        return {
            "투명": (0, 0, 0, 0),
            "흰색": (255, 255, 255, 255),
            "검정": (0, 0, 0, 255),
        }.get(self.background.get(), (0, 0, 0, 0))

    def _render_rgba_frames(self) -> tuple[list[Image.Image], list[int]]:
        size = self._canvas_size()
        frames: list[Image.Image] = []
        durations = [item.duration_ms for item in self.items]
        pause_ms = round(
            self._safe_float(self.repeat_pause.get(), 0.0, 0.0, 60.0) * 1000
        )
        if durations:
            durations[-1] += pause_ms

        for item in self.items:
            with Image.open(item.path) as source:
                frame = ImageOps.exif_transpose(source).convert("RGBA")
                frame.thumbnail(size, Image.Resampling.LANCZOS)
                canvas = Image.new("RGBA", size, self._background_rgba())
                x = (size[0] - frame.width) // 2
                y = (size[1] - frame.height) // 2
                canvas.alpha_composite(frame, (x, y))
                frames.append(canvas)
        return frames, durations

    @staticmethod
    def _to_gif_palette(frame: Image.Image) -> Image.Image:
        """Reserve palette index 255 for GIF's one-bit transparency."""
        alpha = frame.getchannel("A")
        rgb = Image.new("RGB", frame.size, (0, 0, 0))
        rgb.paste(frame.convert("RGB"), mask=alpha)
        paletted = rgb.quantize(colors=255, method=Image.Quantize.MEDIANCUT)
        transparent_mask = alpha.point(lambda value: 255 if value < 128 else 0)
        paletted.paste(255, mask=transparent_mask)

        palette = paletted.getpalette() or []
        palette = (palette + [0] * 768)[:768]
        palette[255 * 3 : 255 * 3 + 3] = [0, 0, 0]
        paletted.putpalette(palette)
        paletted.info["transparency"] = 255
        paletted.info["disposal"] = 2
        return paletted

    def _start_preview(self) -> None:
        if not self.items:
            return
        try:
            rgba_frames, durations = self._render_rgba_frames()
        except Exception as exc:
            messagebox.showerror("미리보기 오류", str(exc))
            return

        self._stop_preview()
        self.preview_frames.clear()
        self.preview_durations = durations

        for frame in rgba_frames:
            shown = frame.copy()
            shown.thumbnail(PREVIEW_BOX, Image.Resampling.LANCZOS)
            checker = self._checkerboard(shown.size)
            checker.alpha_composite(shown)
            self.preview_frames.append(ImageTk.PhotoImage(checker.convert("RGB")))

        self.preview_index = 0
        self._show_preview_frame()
        width, height = rgba_frames[0].size
        cycle_seconds = sum(durations) / 1000
        self.preview_info.configure(
            text=f"{width}×{height} · 1주기 {cycle_seconds:.2f}초"
        )
        self.status_text.set("미리보기 재생 중")

    @staticmethod
    def _checkerboard(size: tuple[int, int]) -> Image.Image:
        width, height = size
        tile = 14
        board = Image.new("RGBA", size, (225, 225, 225, 255))
        pixels = board.load()
        for y in range(height):
            for x in range(width):
                if ((x // tile) + (y // tile)) % 2:
                    pixels[x, y] = (190, 190, 190, 255)
        return board

    def _show_preview_frame(self) -> None:
        if not self.preview_frames:
            return
        frame = self.preview_frames[self.preview_index]
        self.preview_label.configure(image=frame, text="")
        self.preview_label.image = frame
        delay = max(50, self.preview_durations[self.preview_index])
        self.preview_index = (self.preview_index + 1) % len(self.preview_frames)
        self.preview_job = self.root.after(delay, self._show_preview_frame)

    def _stop_preview(self) -> None:
        if self.preview_job is not None:
            try:
                self.root.after_cancel(self.preview_job)
            except tk.TclError:
                pass
            self.preview_job = None
        if self.preview_frames:
            self.status_text.set("미리보기 정지")

    def _show_first_frame(self) -> None:
        self._stop_preview()
        if not self.items:
            return
        try:
            frames, durations = self._render_rgba_frames()
            shown = frames[0].copy()
            shown.thumbnail(PREVIEW_BOX, Image.Resampling.LANCZOS)
            checker = self._checkerboard(shown.size)
            checker.alpha_composite(shown)
            photo = ImageTk.PhotoImage(checker.convert("RGB"))
            self.preview_frames = [photo]
            self.preview_durations = durations[:1]
            self.preview_label.configure(image=photo, text="")
            self.preview_label.image = photo
            self.preview_info.configure(text=f"{frames[0].width}×{frames[0].height}")
        except Exception as exc:
            messagebox.showerror("미리보기 오류", str(exc))

    def _invalidate_preview(self, clear_canvas: bool = False) -> None:
        self._stop_preview()
        self.preview_frames.clear()
        self.preview_durations.clear()
        self.preview_info.configure(text="설정 변경됨")
        if clear_canvas:
            self.preview_label.configure(
                image="", text="이미지를 추가한 뒤\n미리보기 버튼을 누르세요."
            )
            self.preview_label.image = None

    def _create_gif(self) -> None:
        if len(self.items) != MAX_IMAGES:
            messagebox.showwarning("이미지 수 확인", "이미지 3개를 모두 추가하세요.")
            return

        try:
            repeat_count = int(self.repeat_count.get())
        except (tk.TclError, TypeError, ValueError):
            messagebox.showerror("반복 횟수 오류", "반복 횟수는 0~999 정수로 입력하세요.")
            return
        if not 0 <= repeat_count <= 999:
            messagebox.showerror("반복 횟수 오류", "반복 횟수는 0~999로 입력하세요.")
            return

        initial_dir = str(self.items[0].path.parent)
        default_name = f"{self.items[0].path.stem}_animated.gif"
        output = filedialog.asksaveasfilename(
            title="움직이는 GIF 저장",
            initialdir=initial_dir,
            initialfile=default_name,
            defaultextension=".gif",
            filetypes=[("GIF 애니메이션", "*.gif")],
        )
        if not output:
            return

        self._stop_preview()
        self.status_text.set("GIF 생성 중...")
        self.root.update_idletasks()

        try:
            rgba_frames, durations = self._render_rgba_frames()
            frames = [self._to_gif_palette(frame) for frame in rgba_frames]
            save_options = {
                "format": "GIF",
                "save_all": True,
                "append_images": frames[1:],
                "duration": durations,
                "disposal": 2,
                "optimize": False,
                "transparency": 255,
            }
            if repeat_count == 0:
                save_options["loop"] = 0
            elif repeat_count > 1:
                # Pillow's loop value is the number of extra plays after the first.
                save_options["loop"] = repeat_count - 1
            # repeat_count == 1 intentionally omits loop: play once.
            frames[0].save(output, **save_options)
        except Exception as exc:
            self.status_text.set("GIF 생성 실패")
            messagebox.showerror("GIF 생성 실패", f"파일을 만들지 못했습니다.\n\n{exc}")
            return

        self.last_output = Path(output)
        self._update_controls()
        size_kb = self.last_output.stat().st_size / 1024
        self.status_text.set(
            f"완료: {self.last_output.name} · {size_kb:,.1f}KB"
        )
        answer = messagebox.askyesno(
            "GIF 생성 완료",
            f"저장했습니다.\n{self.last_output}\n\n지금 GIF 파일을 열까요?",
        )
        if answer:
            self._open_path(self.last_output)

    def _open_result_folder(self) -> None:
        if self.last_output:
            self._open_path(self.last_output.parent)

    @staticmethod
    def _open_path(path: Path) -> None:
        try:
            if sys.platform.startswith("win"):
                os.startfile(str(path))  # type: ignore[attr-defined]
            elif sys.platform == "darwin":
                import subprocess

                subprocess.Popen(["open", str(path)])
            else:
                import subprocess

                subprocess.Popen(["xdg-open", str(path)])
        except Exception as exc:
            messagebox.showerror("열기 실패", f"직접 열어주세요:\n{path}\n\n{exc}")

    @staticmethod
    def _safe_float(value: object, default: float, minimum: float, maximum: float) -> float:
        try:
            number = float(value)
        except (TypeError, ValueError, tk.TclError):
            return default
        return max(minimum, min(maximum, number))

    def _on_close(self) -> None:
        self._stop_preview()
        self.root.destroy()


def main() -> None:
    if DND_AVAILABLE:
        root = TkinterDnD.Tk()
    else:
        root = tk.Tk()
        root.after(
            300,
            lambda: messagebox.showwarning(
                "드래그 앤 드롭 비활성",
                "tkinterdnd2가 설치되어 있지 않아 드래그 앤 드롭은 사용할 수 없습니다.\n\n"
                "명령 프롬프트에서 아래 명령을 실행한 뒤 다시 시작하세요.\n"
                "pip install Pillow tkinterdnd2\n\n"
                "현재도 '파일 추가' 버튼으로 이미지를 선택할 수 있습니다.",
            ),
        )
    ImgToGifApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
