import io
import os
import queue
import threading
import tkinter as tk
import zipfile
from tkinter import filedialog, messagebox, ttk

from PIL import Image, ImageOps
from pillow_heif import register_heif_opener

# Enable HEIC/HEIF parsing inside Pillow
register_heif_opener()

SIZE_CHOICES = {
    "Original size": None,
    "1200 px (longest side)": 1200,
    "1600 px (longest side)": 1600,
    "2048 px (longest side)": 2048,
}


# ---------------------------------------------------------------------------
# Conversion helpers (no tkinter in here, so they are safe to run in a thread)
# ---------------------------------------------------------------------------
def convert_image(path, fmt, quality, max_side):
    """Open a HEIC file and return the converted image as bytes."""
    with Image.open(path) as img:
        img = ImageOps.exif_transpose(img)  # fix phone rotation
        if max_side:
            img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS)

        buf = io.BytesIO()
        if fmt == "JPEG":
            # JPEG has no alpha channel / odd modes, so force RGB
            img.convert("RGB").save(buf, "JPEG", quality=quality, optimize=True)
        else:
            img.save(buf, "PNG", optimize=True)
        return buf.getvalue()


def unique_name(stem, ext, used):
    """Return 'stem.ext', or 'stem_1.ext', 'stem_2.ext'... if already used.
    `used` is a set of lowercase filenames and gets updated."""
    name = f"{stem}{ext}"
    n = 1
    while name.lower() in used:
        name = f"{stem}_{n}{ext}"
        n += 1
    used.add(name.lower())
    return name


# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------
class HEICConverter:
    def __init__(self, root):
        self.root = root
        self.root.title("HEIC Converter")
        self.root.geometry("640x620")
        self.root.minsize(540, 480)

        self.files = {}  # path -> tk.BooleanVar (insertion-ordered)
        self.msg_queue = queue.Queue()
        self.busy = False

        self._build_header()
        self._build_options()
        self._build_list()
        self._build_actions()

    # --- UI construction ---------------------------------------------------
    def _build_header(self):
        frame = tk.Frame(self.root, bg="#f0f4f8", pady=10)
        frame.pack(fill="x")
        self.btn_select = tk.Button(
            frame, text="Add HEIC Images", command=self.load_images,
            font=("Arial", 10, "bold"), bg="#28a745", fg="white",
            relief="flat", padx=15, pady=5,
        )
        self.btn_select.pack()

    def _build_options(self):
        frame = tk.LabelFrame(self.root, text="Output settings", padx=10, pady=8)
        frame.pack(fill="x", padx=15, pady=(8, 0))

        tk.Label(frame, text="Format:").grid(row=0, column=0, sticky="w")
        self.fmt_var = tk.StringVar(value="JPEG")
        fmt_box = ttk.Combobox(frame, textvariable=self.fmt_var, width=8,
                               values=["JPEG", "PNG"], state="readonly")
        fmt_box.grid(row=0, column=1, padx=(5, 20))
        fmt_box.bind("<<ComboboxSelected>>", self._on_format_change)

        tk.Label(frame, text="Quality:").grid(row=0, column=2, sticky="w")
        self.quality_var = tk.IntVar(value=90)
        self.quality_spin = ttk.Spinbox(frame, from_=50, to=100, width=5,
                                        textvariable=self.quality_var)
        self.quality_spin.grid(row=0, column=3, padx=(5, 20))

        tk.Label(frame, text="Max size:").grid(row=0, column=4, sticky="w")
        self.size_var = tk.StringVar(value="1600 px (longest side)")
        ttk.Combobox(frame, textvariable=self.size_var, width=22,
                     values=list(SIZE_CHOICES), state="readonly"
                     ).grid(row=0, column=5, padx=5)

    def _build_list(self):
        tk.Label(self.root, text="Files to convert:", font=("Arial", 10, "bold"),
                 anchor="w").pack(fill="x", padx=15, pady=(10, 0))

        outer = tk.Frame(self.root, bd=1, relief="sunken")
        outer.pack(fill="both", expand=True, padx=15, pady=5)

        self.canvas = tk.Canvas(outer, highlightthickness=0, bg="white")
        scrollbar = ttk.Scrollbar(outer, orient="vertical", command=self.canvas.yview)
        self.list_frame = tk.Frame(self.canvas, bg="white")

        self.list_frame.bind(
            "<Configure>",
            lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")),
        )
        self.list_window = self.canvas.create_window((0, 0), window=self.list_frame, anchor="nw")
        # keep the inner frame as wide as the canvas
        self.canvas.bind(
            "<Configure>",
            lambda e: self.canvas.itemconfigure(self.list_window, width=e.width),
        )
        self.canvas.configure(yscrollcommand=scrollbar.set)
        self.canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        # Mouse-wheel scrolling only while the pointer is over the list
        self.canvas.bind("<Enter>", self._bind_wheel)
        self.canvas.bind("<Leave>", self._unbind_wheel)

        ctrl = tk.Frame(self.root)
        ctrl.pack(fill="x", padx=15)
        tk.Button(ctrl, text="Check All", command=lambda: self.toggle_all(True)).pack(side="left", padx=2)
        tk.Button(ctrl, text="Uncheck All", command=lambda: self.toggle_all(False)).pack(side="left", padx=2)
        tk.Button(ctrl, text="Remove Unchecked", command=self.remove_unchecked).pack(side="left", padx=2)
        tk.Button(ctrl, text="Clear List", command=self.clear_list).pack(side="left", padx=2)

    def _build_actions(self):
        self.status = tk.Label(self.root, text="No images loaded.", font=("Arial", 9),
                               fg="#666", pady=2)
        self.status.pack(side="bottom")

        self.progress = ttk.Progressbar(self.root, mode="determinate")
        self.progress.pack(side="bottom", fill="x", padx=15, pady=(0, 2))

        frame = tk.Frame(self.root, bg="#f8f9fa", pady=12, bd=1, relief="groove")
        frame.pack(fill="x", side="bottom")

        self.btn_folder = tk.Button(
            frame, text="Save Checked to Folder", command=self.start_folder,
            font=("Arial", 10, "bold"), bg="#0076ff", fg="white",
            relief="flat", padx=10, pady=8,
        )
        self.btn_folder.pack(side="left", padx=20)

        self.btn_zip = tk.Button(
            frame, text="Save as Zip", command=self.start_zip,
            font=("Arial", 10, "bold"), bg="#6f42c1", fg="white",
            relief="flat", padx=10, pady=8,
        )
        self.btn_zip.pack(side="right", padx=20)

    # --- Scrolling ---------------------------------------------------------
    def _bind_wheel(self, _event):
        self.canvas.bind_all("<MouseWheel>", self._on_wheel)       # Windows / macOS
        self.canvas.bind_all("<Button-4>", self._on_wheel)         # Linux up
        self.canvas.bind_all("<Button-5>", self._on_wheel)         # Linux down

    def _unbind_wheel(self, _event):
        self.canvas.unbind_all("<MouseWheel>")
        self.canvas.unbind_all("<Button-4>")
        self.canvas.unbind_all("<Button-5>")

    def _on_wheel(self, event):
        if event.num == 4:
            step = -1
        elif event.num == 5:
            step = 1
        elif abs(event.delta) >= 120:      # Windows
            step = -int(event.delta / 120)
        else:                              # macOS
            step = -event.delta
        self.canvas.yview_scroll(step, "units")

    # --- File list management ---------------------------------------------
    def load_images(self):
        paths = filedialog.askopenfilenames(
            title="Open HEIC Images",
            filetypes=[("HEIC/HEIF Files", "*.heic *.HEIC *.heif *.HEIF"),
                       ("All Files", "*.*")],
        )
        added = 0
        for path in paths:
            if path not in self.files:
                self.files[path] = tk.BooleanVar(value=True)
                added += 1
        if paths:
            self._refresh_list()
            self.set_status(f"Added {added} file(s). {len(self.files)} in list.", "green")

    def _refresh_list(self):
        for widget in self.list_frame.winfo_children():
            widget.destroy()
        for path, var in self.files.items():
            tk.Checkbutton(
                self.list_frame, text=os.path.basename(path), variable=var,
                bg="white", anchor="w", font=("Arial", 10),
            ).pack(fill="x", anchor="w", padx=5)

    def toggle_all(self, value):
        for var in self.files.values():
            var.set(value)

    def remove_unchecked(self):
        self.files = {p: v for p, v in self.files.items() if v.get()}
        self._refresh_list()
        self.set_status(f"{len(self.files)} file(s) in list.", "#666")

    def clear_list(self):
        self.files = {}
        self._refresh_list()
        self.set_status("No images loaded.", "#666")

    def selected_paths(self):
        return [p for p, v in self.files.items() if v.get()]

    # --- Settings / state --------------------------------------------------
    def _on_format_change(self, _event=None):
        state = "normal" if self.fmt_var.get() == "JPEG" else "disabled"
        self.quality_spin.config(state=state)

    def get_settings(self):
        try:
            quality = max(1, min(100, int(self.quality_var.get())))
        except (tk.TclError, ValueError):
            quality = 90
        return {
            "fmt": self.fmt_var.get(),
            "quality": quality,
            "max_side": SIZE_CHOICES[self.size_var.get()],
        }

    def set_status(self, text, color="#666"):
        self.status.config(text=text, fg=color)

    def set_busy(self, busy):
        self.busy = busy
        state = "disabled" if busy else "normal"
        for btn in (self.btn_select, self.btn_folder, self.btn_zip):
            btn.config(state=state)

    # --- Starting jobs -----------------------------------------------------
    def _ext(self, settings):
        return ".jpg" if settings["fmt"] == "JPEG" else ".png"

    def start_folder(self):
        selected = self.selected_paths()
        if not selected:
            messagebox.showwarning("No Files", "Please check at least one file.")
            return
        out_dir = filedialog.askdirectory(title="Select Destination Folder")
        if not out_dir:
            return
        self._start_job("folder", selected, out_dir)

    def start_zip(self):
        selected = self.selected_paths()
        if not selected:
            messagebox.showwarning("No Files", "Please check at least one file.")
            return
        zip_path = filedialog.asksaveasfilename(
            title="Create Archive File", defaultextension=".zip",
            filetypes=[("Zip Archive", "*.zip")],
        )
        if not zip_path:
            return
        self._start_job("zip", selected, zip_path)

    def _start_job(self, mode, selected, target):
        settings = self.get_settings()
        self.set_busy(True)
        self.progress.config(maximum=len(selected), value=0)
        threading.Thread(
            target=self._worker, args=(mode, selected, target, settings), daemon=True
        ).start()
        self.root.after(100, self._poll_queue)

    # --- Worker thread (never touches tkinter; talks via the queue) --------
    def _worker(self, mode, selected, target, settings):
        total = len(selected)
        ext = self._ext(settings)
        failed = []
        count = 0

        try:
            if mode == "zip":
                used = set()
                archive = zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED)
            else:
                # Don't overwrite files already in the destination folder
                used = {f.lower() for f in os.listdir(target)}
                archive = None

            try:
                for i, path in enumerate(selected, start=1):
                    self.msg_queue.put(("progress", i, total))
                    try:
                        data = convert_image(path, settings["fmt"],
                                             settings["quality"], settings["max_side"])
                        stem = os.path.splitext(os.path.basename(path))[0]
                        name = unique_name(stem, ext, used)
                        if archive:
                            archive.writestr(name, data)
                        else:
                            with open(os.path.join(target, name), "wb") as f:
                                f.write(data)
                        count += 1
                    except Exception as e:
                        failed.append((os.path.basename(path), str(e)))
            finally:
                if archive:
                    archive.close()
        except Exception as e:
            self.msg_queue.put(("fatal", str(e)))
            return

        self.msg_queue.put(("done", mode, count, total, failed, target))

    # --- Main-thread queue polling -----------------------------------------
    def _poll_queue(self):
        try:
            while True:
                msg = self.msg_queue.get_nowait()
                kind = msg[0]

                if kind == "progress":
                    _, i, total = msg
                    self.progress.config(value=i - 1)
                    self.set_status(f"Converting image {i} of {total}...", "#0076ff")

                elif kind == "fatal":
                    self.set_busy(False)
                    self.set_status("Failed.", "red")
                    messagebox.showerror("Error", f"Could not complete the job:\n{msg[1]}")
                    return

                elif kind == "done":
                    _, mode, count, total, failed, target = msg
                    self.progress.config(value=total)
                    self.set_busy(False)
                    self._show_result(mode, count, total, failed, target)
                    return
        except queue.Empty:
            pass

        if self.busy:
            self.root.after(100, self._poll_queue)

    def _show_result(self, mode, count, total, failed, target):
        where = "ZIP archive" if mode == "zip" else "folder"
        text = f"Converted {count} of {total} image(s) to the {where}:\n{target}"
        if failed:
            lines = [f"  - {name}: {err}" for name, err in failed[:10]]
            if len(failed) > 10:
                lines.append(f"  ...and {len(failed) - 10} more")
            text += f"\n\n{len(failed)} file(s) failed:\n" + "\n".join(lines)
            self.set_status(f"Done with {len(failed)} error(s).", "orange")
            messagebox.showwarning("Finished with errors", text)
        else:
            self.set_status(f"Finished! Saved {count} file(s).", "green")
            messagebox.showinfo("Success", text)


if __name__ == "__main__":
    main_window = tk.Tk()
    HEICConverter(main_window)
    main_window.mainloop()
