"""Double-click GUI for non-destructive GLB optimization.

On first run this downloads a portable Node.js LTS runtime to the current user's
local app-data cache.  It then runs the maintained glTF Transform CLI, which
includes the official Draco encoder.  Nothing is installed into this project.
"""

from __future__ import annotations

import json
import os
import queue
import random
import shutil
import subprocess
import sys
import threading
import urllib.request
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Optional
import tkinter as tk
from tkinter import Tk, StringVar, BooleanVar, Text, END, DISABLED, NORMAL
from tkinter import filedialog, messagebox, ttk


APP_NAME = "GLB Draco Optimizer"
CACHE = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "NeoSakuraTools" / "GLB-Draco-Optimizer"
NODE_DIR = CACHE / "node"
NPM_CACHE = CACHE / "npm-cache"


class App(Tk):
    def __init__(self) -> None:
        super().__init__()
        self.title(APP_NAME)
        self.configure(background="#05060d")
        self.minsize(760, 620)
        self.geometry("900x710")
        self.target = StringVar()
        self.output = StringVar()
        self.mode = StringVar(value="building-8k")
        self.recursive = BooleanVar(value=False)
        self.busy = False
        self.events: queue.Queue[tuple[str, str]] = queue.Queue()
        self.log_path: Optional[Path] = None
        self._configure_style()
        self._build()
        self.after(100, self._drain_events)

    def _configure_style(self) -> None:
        style = ttk.Style(self)
        style.theme_use("clam")
        style.configure("App.TFrame", background="#080913")
        style.configure("Panel.TLabelframe", background="#0b0c18", foreground="#d8d4ff", bordercolor="#5d3ca8", lightcolor="#2a1b55", darkcolor="#100d23")
        style.configure("Panel.TLabelframe.Label", background="#0b0c18", foreground="#bca9ff", font=("Segoe UI", 10, "bold"))
        style.configure("App.TLabel", background="#080913", foreground="#d7d8ec", font=("Segoe UI", 10))
        style.configure("Hint.TLabel", background="#080913", foreground="#9fbfae", font=("Segoe UI", 9))
        style.configure("TEntry", fieldbackground="#111426", foreground="#e7e5ff", insertcolor="#cdb8ff", bordercolor="#56418b", lightcolor="#6c4da7", darkcolor="#17132a")
        style.configure("TCheckbutton", background="#080913", foreground="#d7d8ec", font=("Segoe UI", 10))
        style.map("TCheckbutton", background=[("active", "#080913")], foreground=[("active", "#bdaeff")])
        style.configure("TRadiobutton", background="#0b0c18", foreground="#d7d8ec", font=("Segoe UI", 10), indicatorcolor="#7f54d9")
        style.map("TRadiobutton", background=[("active", "#0b0c18")], foreground=[("active", "#cdb8ff")])
        style.configure("TButton", background="#17152a", foreground="#e9e5ff", bordercolor="#6950a7", lightcolor="#3b2b65", darkcolor="#090a13", padding=(11, 6), font=("Segoe UI", 9, "bold"))
        style.map("TButton", background=[("active", "#36225d"), ("disabled", "#171727")], foreground=[("disabled", "#74748b")])
        style.configure("Accent.TButton", background="#512786", foreground="#ffffff", bordercolor="#a98bff", lightcolor="#7ccf9d", darkcolor="#24113e", padding=(17, 8), font=("Segoe UI", 10, "bold"))
        style.map("Accent.TButton", background=[("active", "#3e8b63"), ("disabled", "#27243c")], foreground=[("disabled", "#8a86a2")])

    def _build(self) -> None:
        root = ttk.Frame(self, padding=(18, 12, 18, 16), style="App.TFrame")
        root.grid(sticky="nsew")
        self.columnconfigure(0, weight=1)
        self.rowconfigure(0, weight=1)
        root.columnconfigure(1, weight=1)
        root.rowconfigure(8, weight=1)

        header = tk.Canvas(root, height=126, highlightthickness=0, background="#080913")
        header.grid(row=0, column=0, columnspan=3, sticky="ew", pady=(0, 14))
        header.bind("<Configure>", self._draw_header)

        ttk.Label(root, text="Input", style="App.TLabel").grid(row=1, column=0, sticky="w")
        ttk.Entry(root, textvariable=self.target).grid(row=1, column=1, sticky="ew", padx=8)
        buttons = ttk.Frame(root)
        buttons.grid(row=1, column=2, sticky="e")
        ttk.Button(buttons, text="Choose GLB…", command=self.choose_file).grid(row=0, column=0, padx=(0, 6))
        ttk.Button(buttons, text="Choose Folder…", command=self.choose_folder).grid(row=0, column=1)

        ttk.Label(root, text="Output folder", style="App.TLabel").grid(row=2, column=0, sticky="w", pady=(10, 0))
        ttk.Entry(root, textvariable=self.output).grid(row=2, column=1, sticky="ew", padx=8, pady=(10, 0))
        ttk.Button(root, text="Choose Output…", command=self.choose_output).grid(row=2, column=2, sticky="e", pady=(10, 0))

        options = ttk.LabelFrame(root, text="COMPRESSION PRESET", padding=12, style="Panel.TLabelframe")
        options.grid(row=3, column=0, columnspan=3, sticky="ew", pady=(18, 10))
        ttk.Radiobutton(options, variable=self.mode, value="draco", text="Draco geometry only — preserves all texture pixels; use when texture detail must remain untouched.").grid(row=0, column=0, sticky="w")
        ttk.Radiobutton(options, variable=self.mode, value="building-8k", text="Static building, ultra high quality — Draco + WebP textures capped at 8192 px; no triangle reduction.").grid(row=1, column=0, sticky="w", pady=(6, 0))
        ttk.Radiobutton(options, variable=self.mode, value="building-4k", text="Static building, high quality — Draco + WebP textures capped at 4096 px; no triangle reduction.").grid(row=2, column=0, sticky="w", pady=(6, 0))
        ttk.Radiobutton(options, variable=self.mode, value="building-2k", text="Static building, large savings — Draco + WebP textures capped at 2048 px; no triangle reduction.").grid(row=3, column=0, sticky="w", pady=(6, 0))
        ttk.Label(options, text="WebP presets require EXT_texture_webp support in the target loader. Review the output before replacing originals.", style="Hint.TLabel").grid(row=4, column=0, sticky="w", pady=(8, 0))

        ttk.Checkbutton(root, text="When a folder is chosen, include subfolders", variable=self.recursive).grid(row=4, column=0, columnspan=3, sticky="w", pady=(4, 14))

        self.run_button = ttk.Button(root, text="✦  Compress GLB files", command=self.start, style="Accent.TButton")
        self.run_button.grid(row=5, column=0, sticky="w", pady=(0, 12))
        ttk.Label(root, text="Originals stay untouched. File copies get a Compressed_ prefix.", style="Hint.TLabel").grid(row=5, column=1, columnspan=2, sticky="w", padx=12, pady=(0, 12))

        ttk.Label(root, text="OPTIMIZER SIGNAL", style="Hint.TLabel").grid(row=6, column=0, columnspan=3, sticky="w", pady=(0, 5))
        self.log = Text(root, height=18, wrap="word", state=DISABLED, font=("Cascadia Mono", 9), background="#070812", foreground="#b9d8e9", insertbackground="#bdaeff", relief="flat", borderwidth=1, highlightthickness=1, highlightbackground="#4b3477", highlightcolor="#9667da", padx=11, pady=10)
        self.log.grid(row=8, column=0, columnspan=3, sticky="nsew")

    def _draw_header(self, event: tk.Event) -> None:
        canvas = event.widget
        width, height = event.width, event.height
        canvas.delete("all")
        for x in range(width):
            ratio = x / max(1, width - 1)
            # A black aurora: violet on the left drifting into northern green.
            red = int(28 - 21 * ratio)
            green = int(8 + 43 * ratio)
            blue = int(47 - 19 * ratio)
            canvas.create_line(x, 0, x, height, fill=f"#{red:02x}{green:02x}{blue:02x}")
        rng = random.Random(79)
        for _ in range(118):
            x, y = rng.randrange(width), rng.randrange(height)
            size = rng.choice((1, 1, 1, 2))
            color = rng.choice(("#76d99b", "#9380de", "#dec8ff", "#3e8061"))
            canvas.create_oval(x, y, x + size, y + size, fill=color, outline="")
        for y in range(0, height, 4):
            canvas.create_line(0, y, width, y, fill="#000000", stipple="gray75")
        canvas.create_text(28, 43, anchor="w", text="GLB OPTIMIZER", fill="#d8c6ff", font=("Segoe UI", 23, "bold"))
        canvas.create_text(30, 77, anchor="w", text="DRACO  /  STATIC MESH PIPELINE", fill="#81e7a6", font=("Segoe UI", 9, "bold"))
        canvas.create_text(30, 103, anchor="w", text="NON-DESTRUCTIVE COMPRESSION  •  LOCAL CONTROL", fill="#9fa9c7", font=("Segoe UI", 8))

    def choose_file(self) -> None:
        value = filedialog.askopenfilename(title="Choose a GLB file", filetypes=[("glTF Binary", "*.glb")])
        if value:
            self.set_target(Path(value))

    def choose_folder(self) -> None:
        value = filedialog.askdirectory(title="Choose a folder containing GLB files")
        if value:
            self.set_target(Path(value))

    def choose_output(self) -> None:
        initial = self.output.get().strip()
        value = filedialog.askdirectory(title="Choose output folder", initialdir=initial if Path(initial).is_dir() else None)
        if value:
            self.output.set(value)

    def set_target(self, target: Path) -> None:
        self.target.set(str(target))
        # Defaults are deliberately close to the source, but are always editable.
        self.output.set(str(target.parent if target.is_file() else target / "GLB_Compressed"))

    def start(self) -> None:
        target = Path(self.target.get().strip().strip('"'))
        output = Path(self.output.get().strip().strip('"'))
        if not target.exists() or (target.is_file() and target.suffix.lower() != ".glb"):
            messagebox.showerror(APP_NAME, "Choose an existing .glb file or a folder that contains .glb files.")
            return
        if not self.output.get().strip():
            messagebox.showerror(APP_NAME, "Choose an output folder.")
            return
        if output.exists() and not output.is_dir():
            messagebox.showerror(APP_NAME, "The output location must be a folder.")
            return
        self.busy = True
        self.run_button.configure(state=DISABLED)
        self.write("Preparing job…\n")
        threading.Thread(target=self.worker, args=(target, output), daemon=True).start()

    def worker(self, target: Path, output_root: Path) -> None:
        try:
            output_root.mkdir(parents=True, exist_ok=True)
            self.log_path = output_root / f"GLB-Optimizer-{datetime.now():%Y%m%d-%H%M%S}.log"
            self.emit(f"Log file: {self.log_path}\n")
            npx = self.find_or_install_npx()
            if target.is_file():
                jobs = [(target, output_root / f"Compressed_{target.stem}.glb")]
            else:
                if target.resolve() == output_root.resolve():
                    raise RuntimeError("For a folder input, choose an output folder inside or outside it — not the input folder itself.")
                pattern = "**/*.glb" if self.recursive.get() else "*.glb"
                files = [p for p in target.glob(pattern) if not self.is_within(p, output_root)]
                jobs = [(p, output_root / p.relative_to(target)) for p in files]
            if not jobs:
                raise RuntimeError("No .glb files were found.")
            self.emit(f"Found {len(jobs)} GLB file(s).\n")
            before = after = 0
            for index, (source, output) in enumerate(jobs, 1):
                output.parent.mkdir(parents=True, exist_ok=True)
                before += source.stat().st_size
                self.emit(f"[{index}/{len(jobs)}] {source.name}\n")
                self.run_optimizer(npx, source, output)
                after += output.stat().st_size
            saved = before - after
            self.emit(f"\nComplete. {self.format_size(before)} → {self.format_size(after)}; saved {self.format_size(saved)} ({(saved / before * 100 if before else 0):.1f}%).\n")
            self.events.put(("done", "Compression finished."))
        except Exception as exc:
            self.events.put(("error", str(exc)))

    def find_or_install_npx(self) -> Path:
        for name in ("npx.cmd", "npx"):
            found = shutil.which(name)
            if found:
                return Path(found)
        candidates = list(NODE_DIR.glob("node-*/npx.cmd"))
        if candidates:
            return candidates[0]
        self.emit("Node.js is not installed. Downloading portable Node.js LTS once…\n")
        CACHE.mkdir(parents=True, exist_ok=True)
        with urllib.request.urlopen("https://nodejs.org/dist/index.json", timeout=30) as response:
            releases = json.load(response)
        release = next((item for item in releases if item.get("lts")), None)
        if not release:
            raise RuntimeError("Could not find a Node.js LTS release.")
        version = release["version"]
        archive = CACHE / f"node-{version}-win-x64.zip"
        url = f"https://nodejs.org/dist/{version}/node-{version}-win-x64.zip"
        self.emit(f"Downloading {url}\n")
        with urllib.request.urlopen(url, timeout=120) as response, archive.open("wb") as stream:
            shutil.copyfileobj(response, stream)
        with zipfile.ZipFile(archive) as zf:
            zf.extractall(NODE_DIR)
        archive.unlink(missing_ok=True)
        npx = NODE_DIR / f"node-{version}-win-x64" / "npx.cmd"
        if not npx.exists():
            raise RuntimeError("Portable Node.js download did not contain npx.cmd.")
        return npx

    def run_optimizer(self, npx: Path, source: Path, output: Path) -> None:
        if self.mode.get() == "draco":
            command = [
                str(npx), "--yes", "--package", "gltf-pipeline", "gltf-pipeline",
                "-i", str(source), "-o", str(output), "-d", "--draco.compressionLevel=10",
                "--draco.quantizePositionBits=16", "--draco.quantizeNormalBits=12",
                "--draco.quantizeTexcoordBits=14", "--draco.quantizeColorBits=10",
            ]
        else:
            size = {"building-8k": "8192", "building-4k": "4096", "building-2k": "2048"}[self.mode.get()]
            command = [
                str(npx), "--yes", "--package", "@gltf-transform/cli", "gltf-transform", "optimize",
                str(source), str(output), "--compress", "draco", "--texture-compress", "webp",
                "--texture-size", size, "--simplify", "false", "--flatten", "false",
                "--join", "false", "--instance", "false", "--verbose",
            ]
        env = os.environ.copy()
        env["npm_config_cache"] = str(NPM_CACHE)
        env["PATH"] = str(npx.parent) + os.pathsep + env.get("PATH", "")
        result = subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
        if result.stdout:
            self.emit(result.stdout.rstrip() + "\n")
        if result.returncode != 0:
            output.unlink(missing_ok=True)
            detail = result.stdout.strip() if result.stdout else "No output was returned by the optimizer."
            raise RuntimeError(f"Optimizer failed for {source.name} (exit code {result.returncode}).\n\n{detail[-3000:]}")

    def emit(self, text: str) -> None:
        if self.log_path:
            try:
                with self.log_path.open("a", encoding="utf-8") as log:
                    log.write(text)
            except OSError:
                pass
        self.events.put(("log", text))

    def _drain_events(self) -> None:
        while True:
            try:
                kind, value = self.events.get_nowait()
            except queue.Empty:
                break
            if kind == "log":
                self.write(value)
            elif kind == "done":
                self.busy = False
                self.run_button.configure(state=NORMAL)
                messagebox.showinfo(APP_NAME, value)
            elif kind == "error":
                self.busy = False
                self.run_button.configure(state=NORMAL)
                self.write(f"\nERROR: {value}\n")
                messagebox.showerror(APP_NAME, value)
        self.after(100, self._drain_events)

    def write(self, text: str) -> None:
        self.log.configure(state=NORMAL)
        self.log.insert(END, text)
        self.log.see(END)
        self.log.configure(state=DISABLED)

    @staticmethod
    def format_size(size: int) -> str:
        for unit in ("B", "KB", "MB", "GB"):
            if size < 1024 or unit == "GB":
                return f"{size:.1f} {unit}"
            size /= 1024
        return f"{size:.1f} GB"

    @staticmethod
    def is_within(path: Path, folder: Path) -> bool:
        try:
            path.resolve().relative_to(folder.resolve())
            return True
        except ValueError:
            return False


if __name__ == "__main__":
    App().mainloop()
