"""
MicroscopeXport  v2.3
Owner: TMG

Yenilik v2.3:
  - Batch mode: birden fazla ND2/CZI dosyası seç
  - Tek dosyada LUT ayarla + önizle, sonra tüm batch'e uygula
  - Her dosya ayrı klasöre kaydedilir
  - Klasör adında hangi channel renkleri varsa onlar yazar
  - Canlı preview (LUT kaydırınca anlık güncelleme)
  - Merged PNG + TIFF

TRIAL: 10 dosya, XY pozisyon kilitli
FULL ($9.99): Sınırsız, XY pozisyon açık

Kurulum: pip install nd2 czifile numpy tifffile Pillow
EXE: pyinstaller --onefile --windowed --hidden-import=nd2 --hidden-import=czifile --hidden-import=tifffile --hidden-import=PIL biosplit_app.py
"""

import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading, os, json, hashlib, sys

try:
    import nd2, numpy as np, tifffile
    from PIL import Image, ImageTk
    DEPS_OK = True
except ImportError as _e:
    DEPS_OK = False; _ERR = str(_e)

try:
    import czifile; DEPS_CZI = True
except ImportError:
    DEPS_CZI = False

APP   = "MicroscopeXport"
OWNER = "TMG"
VER   = "2.3"
LIMIT = 10
SALT  = "biosplit_tugba_secret_2024"
PRICE = "$9.99"
PREVIEW_SIZE = 120

BG    = "#1a1a2e"; SURF  = "#12122a"; CARD  = "#16162a"
ACC   = "#6c63ff"; ACC2  = "#00e5cc"; TEXT  = "#ddddf5"
MUTED = "#7070a0"; RED   = "#ff6060"; GREEN = "#22dd88"

COLORS = {
    # ── En yaygın 3 kanal ──────────────────────────────────────
    "DAPI (405nm)":    (0,   0,   255),   # mavi
    "GFP (488nm)":     (0,   255, 0),     # yeşil
    "AF594 (594nm)":   (255, 60,  0),     # kırmızı-turuncu
    # ── Yaygın ────────────────────────────────────────────────
    "mCherry (561nm)": (255, 30,  30),    # parlak kırmızı
    "CY3 (550nm)":     (255, 165, 0),     # turuncu
    "CY5 (647nm)":     (255, 0,   200),   # mor-kırmızı
    "TRITC (555nm)":   (255, 80,  0),     # kırmızı-turuncu
    "FITC (490nm)":    (0,   230, 50),    # yeşil
    "CFP (440nm)":     (0,   255, 255),   # cyan
    "YFP (514nm)":     (255, 255, 0),     # sarı
    "RFP (555nm)":     (255, 0,   0),     # kırmızı
    # ── Alexa Fluor serisi ─────────────────────────────────────
    "AF488 (488nm)":   (0,   220, 80),    # yeşil
    "AF555 (555nm)":   (255, 140, 0),     # turuncu
    "AF647 (647nm)":   (220, 0,   180),   # mor
    "AF750 (750nm)":   (160, 0,   255),   # koyu mor
    # ── Az kullanılan ─────────────────────────────────────────
    "CY2 (492nm)":     (0,   255, 100),   # yeşilimsi
    "CY3.5 (581nm)":   (255, 100, 0),     # koyu turuncu
    "CY5.5 (675nm)":   (200, 0,   255),   # mor
    "CY7 (743nm)":     (140, 0,   255),   # koyu mor
    "DAPI2 (405nm)":   (100, 100, 255),   # ikinci mavi
    "Custom":          (255, 255, 255),   # beyaz
}

# ── Lisans ────────────────────────────────────────────────────────────────────
def _spath():
    d = os.path.join(os.environ.get("LOCALAPPDATA") or os.path.expanduser("~"), "MicroscopeXport")
    os.makedirs(d, exist_ok=True); return os.path.join(d, "state.json")

def _load_state():
    try:
        with open(_spath()) as f: return json.load(f)
    except: return {"count": 0, "licensed": False}

def _save_state(s):
    with open(_spath(), "w") as f: json.dump(s, f)

def make_key(email):
    h = hashlib.sha256(f"{email.lower().strip()}||{SALT}".encode()).hexdigest().upper()
    return "-".join(h[i:i+4] for i in range(0, 16, 4))

def verify_key(email, k):
    return k.strip().upper().replace(" ", "") == make_key(email).replace(" ", "")

# ── Görüntü işleme ─────────────────────────────────────────────────────────────
def apply_lut(arr, lo, hi, gm):
    a = arr.astype(np.float32); hi = max(hi, lo + 1)
    a = np.clip((a - lo) / (hi - lo), 0, 1)
    if gm != 1.0: a = np.power(a, 1.0 / gm)
    return (a * 255).astype(np.uint8)

def colorize(g, rgb):
    h, w = g.shape; o = np.zeros((h, w, 3), dtype=np.uint8); f = g.astype(np.float32)
    for i, c in enumerate(rgb): o[:, :, i] = (f * c / 255).astype(np.uint8)
    return o

def colorize_16bit(arr16, lo, hi, gm, rgb):
    """16-bit array'i LUT uygulayarak 16-bit renkli TIFF'e çevirir."""
    a = arr16.astype(np.float32)
    hi = max(hi, lo + 1)
    a = np.clip((a - lo) / (hi - lo), 0, 1)
    if gm != 1.0: a = np.power(a, 1.0 / gm)
    a = (a * 65535).astype(np.uint16)
    h, w = a.shape
    o = np.zeros((h, w, 3), dtype=np.uint16)
    for i, c in enumerate(rgb):
        o[:, :, i] = (a.astype(np.float32) * c / 255).astype(np.uint16)
    return o

def arr16_normalize(arr16, lo, hi, gm):
    """16-bit array'i LUT ile normalize eder, 16-bit grayscale döner."""
    a = arr16.astype(np.float32)
    hi = max(hi, lo + 1)
    a = np.clip((a - lo) / (hi - lo), 0, 1)
    if gm != 1.0: a = np.power(a, 1.0 / gm)
    return (a * 65535).astype(np.uint16)

def to_2d(a):
    while a.ndim > 2: a = a.max(axis=0)
    return a

def make_thumb(arr2d, lo, hi, gm, rgb, size=PREVIEW_SIZE):
    g = apply_lut(arr2d, lo, hi, gm)
    img = Image.fromarray(colorize(g, rgb))
    img.thumbnail((size, size), Image.LANCZOS)
    bg = Image.new("RGB", (size, size), (8, 8, 18))
    bg.paste(img, ((size - img.width)//2, (size - img.height)//2))
    return bg

def make_merged_thumb(ch_arrays, luts, size=PREVIEW_SIZE):
    merged = None
    for i, arr in enumerate(ch_arrays):
        lut = luts[i] if i < len(luts) else None
        if lut and not lut["enabled"].get(): continue
        lo = lut["min"].get() if lut else 0
        hi = lut["max"].get() if lut else 65535
        gm = lut["gamma"].get() if lut else 1.0
        cn = lut["color"].get() if lut else list(COLORS.keys())[i % len(COLORS)]
        g  = apply_lut(arr, lo, hi, gm)
        c  = colorize(g, COLORS.get(cn, (255,255,255)))
        merged = c.astype(np.float32) if merged is None else np.clip(merged + c.astype(np.float32), 0, 255)
    if merged is None: return Image.new("RGB", (size, size), (8, 8, 18))
    img = Image.fromarray(merged.astype(np.uint8))
    img.thumbnail((size, size), Image.LANCZOS)
    bg = Image.new("RGB", (size, size), (8, 8, 18))
    bg.paste(img, ((size - img.width)//2, (size - img.height)//2))
    return bg

def read_czi(path):
    if not DEPS_CZI: raise ImportError("pip install czifile")
    with czifile.CziFile(path) as czi:
        data = czi.asarray().squeeze()
        axes = [a for a in czi.axes if a not in ('B','0')]
        sh   = data.shape
        sizes = {a: s for a, s in zip(axes, sh) if len(axes) == len(sh)}
        if not sizes:
            for a, s in zip(czi.axes, czi.shape):
                if a not in ('B','0'): sizes[a] = s
        ch = []
        try:
            import xml.etree.ElementTree as ET
            root = ET.fromstring(czi.metadata())
            for c in root.iter("Channel"):
                n = c.get("Name") or c.findtext("Name") or ""
                if n: ch.append(n)
        except: pass
    return data, sizes, ch

def auto_detect_color(ch_name: str) -> str:
    """
    Channel isminden veya dalga boyundan otomatik renk profili seç.
    Önce emission dalga boyu sayısını arar, sonra isim eşleşmesi dener.
    """
    import re
    name_upper = ch_name.upper().replace(" ", "").replace("-", "").replace("_", "")

    # Dalga boyu bazlı eşleştirme — sayı varsa önce bunu dene
    wavelength_map = [
        ((340, 425), "DAPI (405nm)"),
        ((426, 465), "CFP (440nm)"),
        ((466, 515), "GFP (488nm)"),
        ((516, 535), "YFP (514nm)"),
        ((536, 575), "CY3 (550nm)"),
        ((576, 610), "AF594 (594nm)"),
        ((611, 645), "mCherry (561nm)"),
        ((646, 680), "CY5 (647nm)"),
        ((681, 725), "CY5.5 (675nm)"),
        ((726, 800), "CY7 (743nm)"),
    ]

    nums = re.findall(r'\d+', ch_name)
    for num_str in nums:
        num = int(num_str)
        if 340 <= num <= 800:
            for (lo, hi), color in wavelength_map:
                if lo <= num <= hi:
                    return color

    # İsim bazlı eşleştirme
    name_map = [
        (["DAPI", "HOECHST", "DRAQ5"],              "DAPI (405nm)"),
        (["GFP", "EGFP", "FITC", "AF488"],          "GFP (488nm)"),
        (["YFP", "EYFP", "VENUS"],                  "YFP (514nm)"),
        (["CFP", "ECFP", "CERULEAN"],               "CFP (440nm)"),
        (["MCHERRY", "CHERRY", "DSRED", "MRFP"],    "mCherry (561nm)"),
        (["RFP", "RED"],                             "RFP (555nm)"),
        (["CY3", "TRITC", "AF555"],                 "CY3 (550nm)"),
        (["AF594", "TEXASRED"],                      "AF594 (594nm)"),
        (["CY5", "AF647"],                           "CY5 (647nm)"),
        (["CY55", "CY5.5", "AF680"],                 "CY5.5 (675nm)"),
        (["CY7", "AF750"],                           "CY7 (743nm)"),
        (["CY2"],                                    "CY2 (492nm)"),
        (["BF", "BRIGHTFIELD", "DIC", "TRANS", "PH", "PHASE"], "Custom"),
    ]

    for keywords, color in name_map:
        for kw in keywords:
            if kw in name_upper:
                return color

    return ""  # Tespit edilemedi — çağıran sıraya göre atar

def load_first_pos_channels(path):
    """Dosyadan ilk pozisyonun tüm channel 2D array'lerini döner."""
    ext = os.path.splitext(path)[1].lower()
    try:
        if ext == ".nd2":
            f    = nd2.ND2File(path); data = f.asarray(); sz = dict(f.sizes); f.close()
            print(f"[ND2] sizes={sz} data.shape={data.shape}")
            ao   = list(sz.keys()); pk = "P" if "P" in sz else "M"
            nch  = sz.get("C", 1)
            if pk in ao and sz.get(pk, 1) > 1:
                data = np.take(data, 0, axis=ao.index(pk)); ao = [k for k in ao if k != pk]
            if "T" in ao:
                data = data.max(axis=ao.index("T")); ao = [k for k in ao if k != "T"]
        else:
            data, sz, _ = read_czi(path)
            ao  = [k for k in ("T","S","M","Z","C","Y","X") if k in sz]
            pk  = "S" if "S" in sz else "M"; nch = sz.get("C", 1)
            if pk in ao and sz.get(pk, 1) > 1:
                data = np.take(data, 0, axis=ao.index(pk)); ao = [k for k in ao if k != pk]
            if "T" in ao:
                data = data.max(axis=ao.index("T")); ao = [k for k in ao if k != "T"]
        ci   = ao.index("C") if "C" in ao else None
        result = []
        for i in range(nch):
            ch = np.take(data, i, axis=ci) if ci is not None else data
            ch2d = to_2d(ch)
            print(f"[CH{i+1}] before to_2d={ch.shape} after={ch2d.shape}")
            result.append(ch2d)
        return result
    except Exception as e:
        print(f"[LOAD] {e}"); return []


# ── Ana Uygulama ───────────────────────────────────────────────────────────────
class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(f"{APP}  v{VER}"); self.geometry("1280x860")
        self.configure(bg=BG); self.resizable(True, True)

        self.state   = _load_state()
        self.odir    = tk.StringVar()
        self.do_pos  = tk.BooleanVar(value=False)
        self.do_ch   = tk.BooleanVar(value=True)
        self.do_mpng = tk.BooleanVar(value=False)
        self.do_mtif = tk.BooleanVar(value=True)
        self.do_sub  = tk.BooleanVar(value=True)
        self.do_tif  = tk.BooleanVar(value=True)   # TIFF — varsayılan açık
        self.do_png  = tk.BooleanVar(value=False)  # PNG — varsayılan kapalı
        self.do_jpg  = tk.BooleanVar(value=False)  # JPG — varsayılan kapalı
        self.do_16bit= tk.BooleanVar(value=False)  # 16-bit renkli TIFF
        self.upscale = tk.StringVar(value="1x")
        self.status  = tk.StringVar(value="Add files to batch list…")
        self.prog    = tk.DoubleVar(value=0)

        # Batch listesi
        self.batch_files = []   # [(path, label), ...]
        self.preview_idx = 0    # hangi dosya önizleniyor

        # LUT state
        self.luts  = []
        self.nch   = 0
        self._ch_2d = []

        self._build_ui()
        self._refresh_banner()

    # ── UI ────────────────────────────────────────────────────────────────────
    def _build_ui(self):
        s = ttk.Style(self); s.theme_use("clam")
        for n, bg, fg, fo in [
            ("TFrame",       BG,   TEXT,  ("Segoe UI",10)),
            ("TLabel",       BG,   TEXT,  ("Segoe UI",10)),
            ("M.TLabel",     BG,   MUTED, ("Segoe UI",9)),
            ("H.TLabel",     BG,   ACC,   ("Segoe UI",12,"bold")),
            ("TButton",      None, None,  ("Segoe UI",10)),
            ("TCheckbutton", BG,   TEXT,  ("Segoe UI",10)),
        ]:
            kw = {"font": fo}
            if bg: kw["background"] = bg
            if fg: kw["foreground"] = fg
            s.configure(n, **kw)
        s.configure("TButton", padding=5)
        s.configure("TCheckbutton", selectcolor=SURF)
        s.configure("TProgressbar", troughcolor=SURF, background=ACC, thickness=7)

        # Başlık
        hdr = ttk.Frame(self, padding=(16,10,16,6)); hdr.pack(fill="x")
        ttk.Label(hdr, text=APP, style="H.TLabel").pack(side="left")
        ttk.Label(hdr, text=f" v{VER}  •  Nikon ND2 + Zeiss CZI  •  Batch Analysis",
                  style="M.TLabel").pack(side="left", padx=8)
        ttk.Label(hdr, text=f"© {OWNER}", style="M.TLabel").pack(side="right")
        tk.Frame(self, bg="#2a2a4a", height=1).pack(fill="x")

        # Banner
        self.bframe = tk.Frame(self, pady=6); self.bframe.pack(fill="x")
        self.blabel = tk.Label(self.bframe, text="", font=("Segoe UI",9,"bold"), anchor="w", padx=14)
        self.blabel.pack(side="left")
        self.bbtn = tk.Button(self.bframe, text="Get License / Buy  →",
                              bg=ACC, fg="#fff", font=("Segoe UI",9,"bold"),
                              bd=0, padx=10, pady=2, cursor="hand2", command=self._lic_dlg)
        self.bbtn.pack(side="right", padx=14)

        # Output klasörü
        of = ttk.Frame(self, padding=(16,6)); of.pack(fill="x")
        ttk.Label(of, text="Output Folder:").grid(row=0, column=0, sticky="w", padx=(0,6))
        ttk.Entry(of, textvariable=self.odir, width=52).grid(row=0, column=1, sticky="ew")
        ttk.Button(of, text="Browse", command=self._bout).grid(row=0, column=2, padx=6)
        of.columnconfigure(1, weight=1)

        # Seçenekler
        opt = ttk.Frame(self, padding=(16,4)); opt.pack(fill="x")
        row = ttk.Frame(opt); row.pack(fill="x")
        self.pos_cb = ttk.Checkbutton(row, text="XY Position Split  🔒 (Full)",
                                      variable=self.do_pos, command=self._guard)
        self.pos_cb.pack(side="left", padx=(0,12))
        ttk.Checkbutton(row, text="Subfolders", variable=self.do_sub).pack(side="left", padx=(0,12))
        ttk.Checkbutton(row, text="Channel Files", variable=self.do_ch).pack(side="left", padx=(0,12))
        ttk.Checkbutton(row, text="Merged", variable=self.do_mtif).pack(side="left", padx=(0,12))

        # Format seçimi
        row2 = ttk.Frame(opt); row2.pack(fill="x", pady=(4,0))
        ttk.Label(row2, text="Output Format:", style="M.TLabel").pack(side="left", padx=(0,8))
        ttk.Checkbutton(row2, text="TIFF (8-bit)", variable=self.do_tif).pack(side="left", padx=(0,10))
        ttk.Checkbutton(row2, text="TIFF (16-bit color)", variable=self.do_16bit).pack(side="left", padx=(0,10))
        ttk.Checkbutton(row2, text="PNG",  variable=self.do_png).pack(side="left", padx=(0,10))
        ttk.Checkbutton(row2, text="JPG",  variable=self.do_jpg).pack(side="left", padx=(0,20))

        tk.Frame(self, bg="#2a2a4a", height=1).pack(fill="x", padx=16, pady=3)

        # 2 sütunlu ana alan: sol=batch, sağ=LUT+preview inline
        main = ttk.Frame(self); main.pack(fill="both", expand=True, padx=16, pady=4)
        main.columnconfigure(0, weight=1)  # batch listesi
        main.columnconfigure(1, weight=3)  # LUT + inline preview
        main.rowconfigure(0, weight=1)

        # SOL: Batch listesi
        bl = ttk.Frame(main); bl.grid(row=0, column=0, sticky="nsew", padx=(0,8))
        hf = ttk.Frame(bl); hf.pack(fill="x")
        ttk.Label(hf, text="Batch Files", style="H.TLabel").pack(side="left")
        ttk.Button(hf, text="+ Files", command=self._add_files).pack(side="right")
        ttk.Button(hf, text="+ Folder", command=self._add_folder).pack(side="right", padx=3)
        ttk.Button(hf, text="✕", command=self._remove_file).pack(side="right", padx=3)

        self.batch_lb = tk.Listbox(bl, bg=SURF, fg=TEXT,
                                   selectbackground=ACC, selectforeground="#fff",
                                   font=("Segoe UI",9), activestyle="none",
                                   relief="flat", borderwidth=0)
        self.batch_lb.pack(fill="both", expand=True, pady=(4,0))
        self.batch_lb.bind("<<ListboxSelect>>", self._on_batch_select)

        self.file_info = tk.Label(bl, text="", bg=SURF, fg=ACC2,
                                  font=("Segoe UI",8), anchor="w", padx=4, pady=3)
        self.file_info.pack(fill="x")

        # SAĞ: LUT paneli (preview her channel'ın yanında inline)
        lp = ttk.Frame(main); lp.grid(row=0, column=1, sticky="nsew")
        lh = ttk.Frame(lp); lh.pack(fill="x")
        ttk.Label(lh, text="Channel LUT Settings", style="H.TLabel").pack(side="left")
        ttk.Button(lh, text="↓ Apply to All Files",
                   command=self._apply_lut_to_all).pack(side="right")

        cv  = tk.Canvas(lp, bg=SURF, highlightthickness=0)
        sb  = ttk.Scrollbar(lp, orient="vertical", command=cv.yview)
        self.li = ttk.Frame(cv)
        self.li.bind("<Configure>", lambda e: cv.configure(scrollregion=cv.bbox("all")))
        cv.create_window((0,0), window=self.li, anchor="nw")
        cv.configure(yscrollcommand=sb.set)
        cv.pack(side="left", fill="both", expand=True); sb.pack(side="right", fill="y")

        # Preview frame (LUT panelinin içinde, self.li'ye eklenecek)
        # Her channel için _build_luts_and_preview'da preview label oluşturulacak

        # Alt bar
        bot = ttk.Frame(self, padding=(16,6)); bot.pack(fill="x", side="bottom")
        ttk.Button(bot, text="▶  Process All Files",
                   command=self._start).pack(side="left", ipadx=16, ipady=3)
        ttk.Button(bot, text="▶  Process Selected",
                   command=self._start_selected).pack(side="left", padx=8, ipadx=8, ipady=3)
        tk.Frame(self, bg="#2a2a4a", height=1).pack(fill="x", side="bottom")
        pb = ttk.Frame(self, padding=(16,4)); pb.pack(fill="x", side="bottom")
        ttk.Progressbar(pb, variable=self.prog, maximum=100,
                        style="TProgressbar", length=440).pack(side="left", padx=(0,10))
        ttk.Label(pb, textvariable=self.status, style="M.TLabel").pack(side="left")

    # ── Banner ────────────────────────────────────────────────────────────────
    def _refresh_banner(self):
        lic = self.state.get("licensed", False)
        if lic:
            em = self.state.get("email","")
            self.bframe.config(bg="#0a2a18")
            self.blabel.config(bg="#0a2a18", fg=GREEN,
                text=f"✓ Full Version — {em}  |  Unlimited + XY Position active")
            self.bbtn.pack_forget()
            self.pos_cb.config(text="XY Position Split")
        else:
            left = max(0, LIMIT - self.state.get("count",0))
            self.bframe.config(bg="#2a0a0a")
            self.blabel.config(bg="#2a0a0a", fg=RED,
                text=f"⚠ Trial — {left}/{LIMIT} files remaining  |  XY locked  |  Full: {PRICE}")
            self.bbtn.pack(side="right", padx=14)

    def _guard(self):
        if not self.state.get("licensed") and self.do_pos.get():
            self.do_pos.set(False)
            messagebox.showinfo("Full Version Required",
                f"XY Position splitting requires the full version.\n{PRICE}")

    # ── Lisans ────────────────────────────────────────────────────────────────
    def _lic_dlg(self):
        w = tk.Toplevel(self); w.title("License Activation")
        w.geometry("480x390"); w.configure(bg=BG); w.grab_set(); w.resizable(False,False)
        tk.Label(w, text=f"🔬 {APP} — License", bg=BG, fg=ACC,
                 font=("Segoe UI",13,"bold")).pack(pady=(22,4))
        tk.Label(w, text=f"Full version: {PRICE}  •  Unlimited  •  XY Position",
                 bg=BG, fg=TEXT, font=("Segoe UI",10)).pack()
        tk.Frame(w, bg="#2a2a4a", height=1).pack(fill="x", padx=22, pady=12)
        tk.Label(w, text="➜  microscopexport.gumroad.com/l/qghmob",
                 bg=BG, fg=ACC2, font=("Segoe UI",10,"bold")).pack(anchor="w", padx=26, pady=(0,12))
        tk.Label(w, text="Your purchase email:", bg=BG, fg=TEXT,
                 font=("Segoe UI",9)).pack(anchor="w", padx=26)
        ev = tk.StringVar()
        tk.Entry(w, textvariable=ev, bg=CARD, fg="#fff", insertbackground="white",
                 font=("Segoe UI",11), bd=0).pack(padx=26, pady=(4,10), ipady=5, fill="x")
        tk.Label(w, text="License Key:", bg=BG, fg=TEXT,
                 font=("Segoe UI",9)).pack(anchor="w", padx=26)
        kv = tk.StringVar()
        ke = tk.Entry(w, textvariable=kv, bg=CARD, fg="#fff", insertbackground="white",
                      font=("Courier New",12), bd=0, justify="center")
        ke.pack(padx=26, pady=(4,0), ipady=6, fill="x"); ke.focus()
        msg = tk.Label(w, text="", bg=BG, fg=RED, font=("Segoe UI",9)); msg.pack(pady=6)
        def act():
            em = ev.get().strip().lower(); k = kv.get().strip()
            if not em or "@" not in em:
                msg.config(fg=RED, text="✗ Enter a valid email."); return
            if verify_key(em, k):
                self.state.update({"licensed":True,"license_key":k,"email":em})
                _save_state(self.state); self._refresh_banner()
                msg.config(fg=GREEN, text="✓ Activated!")
                self.after(1800, w.destroy)
            else:
                msg.config(fg=RED, text="✗ Key does not match this email.")
        tk.Button(w, text="  Activate  ", bg=ACC, fg="#fff",
                  font=("Segoe UI",11,"bold"), bd=0, padx=20, pady=7,
                  cursor="hand2", command=act).pack(pady=(0,4))

    # ── Batch yönetimi ────────────────────────────────────────────────────────
    def _add_files(self):
        paths = filedialog.askopenfilenames(
            filetypes=[("Microscopy","*.nd2 *.czi"),("ND2","*.nd2"),("CZI","*.czi"),("All","*.*")])
        self._add_paths(paths)

    def _add_folder(self):
        folder = filedialog.askdirectory(title="Select folder with ND2/CZI files")
        if not folder: return
        found = []
        for root, dirs, files in os.walk(folder):
            for f in sorted(files):
                if f.lower().endswith((".nd2", ".czi")):
                    found.append(os.path.join(root, f))
        if not found:
            messagebox.showinfo("No Files", "No .nd2 or .czi files found in this folder.")
            return
        self._add_paths(found)
        self.status.set(f"{len(found)} file(s) found in folder.")

    def _add_paths(self, paths):
        existing = [f for f, _ in self.batch_files]
        added = 0
        for p in paths:
            if p not in existing:
                lbl = os.path.basename(p)
                self.batch_files.append((p, lbl))
                self.batch_lb.insert(tk.END, lbl)
                added += 1
        if added and not self.luts:
            self.batch_lb.selection_set(0)
            self._load_preview(0)

    def _remove_file(self):
        sel = self.batch_lb.curselection()
        if not sel: return
        idx = sel[0]
        self.batch_lb.delete(idx)
        self.batch_files.pop(idx)

    def _on_batch_select(self, event):
        sel = self.batch_lb.curselection()
        if not sel: return
        idx = sel[0]
        if idx != self.preview_idx or not self.luts:
            self.preview_idx = idx
            self._load_preview(idx)

    def _load_preview(self, idx):
        if idx >= len(self.batch_files): return
        path, _ = self.batch_files[idx]
        self.status.set(f"Loading preview: {os.path.basename(path)}…")
        threading.Thread(target=self._load_preview_thread, args=(path,), daemon=True).start()

    def _load_preview_thread(self, path):
        try:
            ext = os.path.splitext(path)[1].lower()
            if ext == ".nd2":
                f   = nd2.ND2File(path)
                sz  = dict(f.sizes)
                chs = f.metadata.channels or []
                f.close()
                nch = sz.get("C", 1)
                # Her channel için hem isim hem emission wavelength al
                ch_info = []
                for c in chs:
                    name = c.channel.name or ""
                    # Emission wavelength metadata'da varsa al
                    em_wave = ""
                    try:
                        em = c.channel.emissionLambdaNm
                        if em and em > 0:
                            em_wave = str(int(em))
                    except: pass
                    # excitation da dene
                    if not em_wave:
                        try:
                            ex = c.channel.excitationLambdaNm
                            if ex and ex > 0:
                                em_wave = str(int(ex))
                        except: pass
                    # İsim + dalga boyu birleştir — ikisi de yoksa sadece isim
                    combined = f"{name} {em_wave}".strip() if em_wave else name
                    ch_info.append(combined)
                ch_names = ch_info if ch_info else [f"CH{i+1}" for i in range(nch)]
            else:
                _, sz, ch_names = read_czi(path)
                nch = sz.get("C", 1)
            arrays = load_first_pos_channels(path)
            self.after(0, lambda: self._build_luts_and_preview(nch, ch_names, arrays, sz, path))
        except Exception as e:
            self.after(0, lambda: self.status.set(f"Error loading: {e}"))

    def _build_luts_and_preview(self, nch, ch_names, arrays, sz, path):
        self.nch    = nch
        self._ch_2d = arrays
        cn = list(COLORS.keys())

        # Eğer luts zaten var ve channel sayısı aynıysa değerleri koru
        if len(self.luts) != nch:
            self.luts = []
            default_colors = [
                "DAPI (405nm)",
                "GFP (488nm)",
                "AF594 (594nm)",
                "CY5 (647nm)",
                "mCherry (561nm)",
                "CY3 (550nm)",
                "Custom",
            ]
            for i in range(nch):
                nm = ch_names[i] if i < len(ch_names) else f"CH{i+1}"
                auto_color = auto_detect_color(nm)
                # Tespit edilemezse channel sırasına göre varsayılan ata
                if not auto_color:
                    auto_color = default_colors[i % len(default_colors)]
                self.luts.append({
                    "min":     tk.IntVar(value=0),
                    "max":     tk.IntVar(value=65535),
                    "gamma":   tk.DoubleVar(value=1.0),
                    "color":   tk.StringVar(value=auto_color),
                    "enabled": tk.BooleanVar(value=True),
                })

        # LUT widget'larını yeniden oluştur
        for w in self.li.winfo_children(): w.destroy()

        for i in range(nch):
            nm  = ch_names[i] if i < len(ch_names) else f"CH{i+1}"
            lut = self.luts[i]

            # Her channel için: LUT sol + preview sağ yan yana
            ch_row = tk.Frame(self.li, bg=SURF)
            ch_row.pack(fill="x", padx=4, pady=3)

            # Sol: LUT kontrolleri
            frm = tk.LabelFrame(ch_row, text=f" {nm} (CH{i+1}) ",
                                bg=SURF, fg=ACC, font=("Segoe UI",10,"bold"),
                                bd=1, relief="groove", padx=8, pady=5)
            frm.pack(side="left", fill="both", expand=True)

            tk.Checkbutton(frm, text="Active", variable=lut["enabled"],
                           bg=SURF, fg=TEXT, selectcolor=CARD, font=("Segoe UI",9),
                           command=lambda idx=i: self._upd_prev(idx)).grid(
                row=0, column=4, padx=(10,0))
            tk.Label(frm, text="Color:", bg=SURF, fg=MUTED).grid(row=0, column=0, sticky="w")
            cb = ttk.Combobox(frm, textvariable=lut["color"],
                              values=list(COLORS.keys()), width=10, state="readonly")
            cb.grid(row=0, column=1, padx=(4,10))
            cb.bind("<<ComboboxSelected>>", lambda e, idx=i: self._upd_prev(idx))
            for ri, (key, label, lo, hi, res) in enumerate([
                ("min","Min",0,65535,100),("max","Max",0,65535,100),("gamma","Gamma",0.1,4.0,0.05)],1):
                tk.Label(frm, text=f"{label}:", bg=SURF, fg=MUTED).grid(
                    row=ri, column=0, sticky="w", pady=(3,0))
                tk.Scale(frm, from_=lo, to=hi, resolution=res, variable=lut[key],
                         orient="horizontal", bg=SURF, fg=TEXT, troughcolor=CARD,
                         highlightthickness=0, length=200,
                         command=lambda v, idx=i: self._upd_prev(idx)).grid(
                    row=ri, column=1, columnspan=3, sticky="ew", pady=(3,0))
                tk.Entry(frm, textvariable=lut[key], width=6, bg=CARD, fg="#fff",
                         insertbackground="white").grid(row=ri, column=4, padx=(5,0), pady=(3,0))

            # Sağ: Küçük preview thumbnail — LUT frame ile aynı yükseklik
            pf = tk.Frame(ch_row, bg=SURF, padx=2)
            pf.pack(side="right", fill="y")
            lbl = tk.Label(pf, bg="#08080f")
            lbl.pack(expand=True, fill="both")
            lut["_plbl"] = lbl

        # Merged preview — en altta tam genişlik
        mf = tk.LabelFrame(self.li, text=" MERGED ", bg=SURF, fg=ACC2,
                            font=("Segoe UI",10,"bold"), bd=1, relief="groove")
        mf.pack(fill="x", padx=4, pady=(6,2))
        self._mlbl = tk.Label(mf, bg=SURF)
        self._mlbl.pack(pady=4)

        # Dosya bilgisi
        ext = os.path.splitext(path)[1].upper().lstrip(".")
        npos = sz.get("P", sz.get("S", sz.get("M", 1)))
        self.file_info.config(
            text=f"{ext} | CH:{nch} | Pos:{npos} | Z:{sz.get('Z',1)} | {sz.get('Y','?')}×{sz.get('X','?')}")
        self.status.set(f"Preview: {os.path.basename(path)}")
        self.after(100, self._upd_all_prev)

    def _upd_prev(self, idx):
        if idx >= len(self._ch_2d) or idx >= len(self.luts): return
        lut = self.luts[idx]; arr = self._ch_2d[idx]
        lo = lut["min"].get(); hi = lut["max"].get(); gm = lut["gamma"].get()
        cn = lut["color"].get()
        thumb = make_thumb(arr, lo, hi, gm, COLORS.get(cn,(255,255,255)))
        itk = ImageTk.PhotoImage(thumb)
        lut["_plbl"].config(image=itk); lut["_pimg"] = itk
        self._upd_merged()

    def _upd_merged(self):
        if not self._ch_2d: return
        thumb = make_merged_thumb(self._ch_2d, self.luts)
        itk = ImageTk.PhotoImage(thumb)
        self._mlbl.config(image=itk); self._mimg = itk

    def _upd_all_prev(self):
        for i in range(len(self.luts)): self._upd_prev(i)

    def _apply_lut_to_all(self):
        """Mevcut LUT değerlerini tüm batch dosyalarına uygula (state koru)."""
        messagebox.showinfo("LUT Applied",
            f"Current LUT settings will be applied to all {len(self.batch_files)} files when processing.")

    # ── Output ─────────────────────────────────────────────────────────────────
    def _bout(self):
        p = filedialog.askdirectory()
        if p: self.odir.set(p)

    # ── İşlem ─────────────────────────────────────────────────────────────────
    def _start(self):
        self._process_files(self.batch_files)

    def _start_selected(self):
        sel = self.batch_lb.curselection()
        if not sel:
            messagebox.showwarning("Warning","Select a file from the list first."); return
        # Seçili dosya preview'daki dosya mı?
        idx = sel[0]
        if idx == self.preview_idx and self._ch_2d:
            # Preview array'leri hazır — direkt kullan
            lut_snapshot = []
            for lut in self.luts:
                lut_snapshot.append({
                    "min":     lut["min"].get(),
                    "max":     lut["max"].get(),
                    "gamma":   lut["gamma"].get(),
                    "color":   lut["color"].get(),
                    "enabled": lut["enabled"].get(),
                })
            path, _ = self.batch_files[idx]
            threading.Thread(
                target=self._run_single_from_preview,
                args=(path, self._ch_2d.copy(), lut_snapshot),
                daemon=True).start()
        else:
            self._process_files([self.batch_files[idx]])

    def _process_files(self, files):
        if not DEPS_OK:
            messagebox.showerror("Missing","pip install nd2 numpy tifffile Pillow"); return
        if not files:
            messagebox.showwarning("Warning","Add files to the batch list first."); return
        if not self.odir.get():
            p = filedialog.askdirectory(title="Select Output Folder")
            if not p: return
            self.odir.set(p)
        if not self.luts:
            messagebox.showwarning("Warning","Select a file first to set LUT values."); return
        if not self.state.get("licensed"):
            used = self.state.get("count",0)
            remaining = LIMIT - used
            if len(files) > remaining:
                if messagebox.askyesno("Trial Limit",
                    f"Trial allows {remaining} more file(s), you selected {len(files)}.\n\n"
                    f"Full version: {PRICE}\nPurchase now?"):
                    self._lic_dlg()
                return
        # LUT değerlerini ANA THREAD'de oku — thread içinde tkinter var'ları güvenilmez
        lut_snapshot = []
        for lut in self.luts:
            lut_snapshot.append({
                "min":     lut["min"].get(),
                "max":     lut["max"].get(),
                "gamma":   lut["gamma"].get(),
                "color":   lut["color"].get(),
                "enabled": lut["enabled"].get(),
            })
        threading.Thread(target=self._run_batch, args=(files, lut_snapshot), daemon=True).start()

    def _run_single_from_preview(self, path, ch_arrays, lut_snapshot):
        """Preview'daki array'leri direkt export eder — dosyadan tekrar okumaz."""
        try:
            self.prog.set(5)
            base    = os.path.splitext(os.path.basename(path))[0]
            out_root = self.odir.get()

            active_colors = [lut_snapshot[i]["color"]
                             for i in range(min(len(lut_snapshot), len(ch_arrays)))
                             if lut_snapshot[i]["enabled"]]
            color_suffix = "_".join(c.split(" ")[0] for c in active_colors) if active_colors else "all"
            out_dir = os.path.join(out_root, f"{base}_{color_suffix}")
            os.makedirs(out_dir, exist_ok=True)

            mrgb = None
            for ci, c2 in enumerate(ch_arrays):
                lut = lut_snapshot[ci] if ci < len(lut_snapshot) else None
                if lut and not lut["enabled"]: continue

                cl = f"CH{ci+1:02d}"
                if lut:
                    lo = lut["min"]; hi = lut["max"]; gm = lut["gamma"]
                    cn = lut["color"]
                else:
                    lo, hi, gm = 0, 65535, 1.0
                    cn = list(COLORS.keys())[ci % len(COLORS)]

                print(f"[EXPORT-PREVIEW] CH{ci+1}: min={lo} max={hi} gamma={gm} color={cn}")
                print(f"[ARRAY] CH{ci+1}: shape={c2.shape} dtype={c2.dtype} min={c2.min()} max={c2.max()}")

                g8      = apply_lut(c2, lo, hi, gm)
                col_rgb = COLORS.get(cn, (255,255,255))
                colored = colorize(g8, col_rgb)
                cn_safe = cn.replace(" ","_").replace("(","").replace(")","").replace("/","")

                g8_save = g8
                col_save = colored

                if self.do_ch.get():
                    if self.do_tif.get():
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_{cl}.tif"), g8_save)
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_{cl}_{cn_safe}.tif"), col_save)
                    if self.do_16bit.get():
                        # 16-bit grayscale — ham array, LUT uygulanmış normalize
                        a16 = arr16_normalize(c2, lo, hi, gm)
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_{cl}_16bit.tif"), a16)
                        # 16-bit renkli
                        col16 = colorize_16bit(c2, lo, hi, gm, col_rgb)
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_{cl}_{cn_safe}_16bit.tif"), col16)
                    if self.do_png.get():
                        Image.fromarray(g8_save).save(os.path.join(out_dir, f"{base}_{cl}.png"))
                        Image.fromarray(col_save).save(os.path.join(out_dir, f"{base}_{cl}_{cn_safe}.png"))
                    if self.do_jpg.get():
                        Image.fromarray(g8_save).save(os.path.join(out_dir, f"{base}_{cl}.jpg"), quality=95)
                        Image.fromarray(col_save).save(os.path.join(out_dir, f"{base}_{cl}_{cn_safe}.jpg"), quality=95)

                if self.do_mtif.get():
                    mrgb = (colored.astype(np.float32) if mrgb is None
                            else np.clip(mrgb + colored.astype(np.float32), 0, 255))
                    if not hasattr(self, '_mgray') or self._mgray is None:
                        self._mgray = g8.astype(np.float32)
                    else:
                        self._mgray = np.clip(self._mgray + g8.astype(np.float32), 0, 255)
                    # 16-bit merged için ham değerleri birleştir
                    if self.do_16bit.get():
                        col16 = colorize_16bit(c2, lo, hi, gm, col_rgb)
                        a16   = arr16_normalize(c2, lo, hi, gm)
                        if not hasattr(self, '_mrgb16') or self._mrgb16 is None:
                            self._mrgb16  = col16.astype(np.float32)
                            self._mgray16 = a16.astype(np.float32)
                        else:
                            self._mrgb16  = np.clip(self._mrgb16  + col16.astype(np.float32), 0, 65535)
                            self._mgray16 = np.clip(self._mgray16 + a16.astype(np.float32),  0, 65535)

                self.prog.set(5 + 90 * (ci+1) / len(ch_arrays))

            if mrgb is not None:
                m8 = mrgb.astype(np.uint8)
                mg = self._mgray.astype(np.uint8) if hasattr(self,'_mgray') and self._mgray is not None else None

                if self.do_tif.get():
                    tifffile.imwrite(os.path.join(out_dir, f"{base}_MERGED.tif"), m8)
                    if mg is not None:
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_MERGED_gray.tif"), mg)
                if self.do_16bit.get() and hasattr(self,'_mrgb16') and self._mrgb16 is not None:
                    m16 = self._mrgb16.astype(np.uint16)
                    tifffile.imwrite(os.path.join(out_dir, f"{base}_MERGED_16bit.tif"), m16)
                    if hasattr(self,'_mgray16') and self._mgray16 is not None:
                        tifffile.imwrite(os.path.join(out_dir, f"{base}_MERGED_gray_16bit.tif"),
                                        self._mgray16.astype(np.uint16))
                    self._mrgb16 = None; self._mgray16 = None
                if self.do_png.get():
                    Image.fromarray(m8).save(os.path.join(out_dir, f"{base}_MERGED.png"))
                    if mg is not None:
                        Image.fromarray(mg).save(os.path.join(out_dir, f"{base}_MERGED_gray.png"))
                if self.do_jpg.get():
                    Image.fromarray(m8).save(os.path.join(out_dir, f"{base}_MERGED.jpg"), quality=95)
                    if mg is not None:
                        Image.fromarray(mg).save(os.path.join(out_dir, f"{base}_MERGED_gray.jpg"), quality=95)
                self._mgray = None

            if not self.state.get("licensed"):
                self.state["count"] = self.state.get("count",0) + 1
                _save_state(self.state)
                self.after(0, self._refresh_banner)

            self.prog.set(100)
            self.status.set(f"✓ Done! {base}")
            messagebox.showinfo("Done", f"Saved to:\n{out_dir}")

        except Exception as e:
            self.status.set(f"Error: {e}"); messagebox.showerror("Error", str(e))

    def _run_batch(self, files, lut_snapshot):
        try:
            self.prog.set(2)
            out_root = self.odir.get()
            total_files = len(files)

            for fi, (path, label) in enumerate(files):
                base = os.path.splitext(os.path.basename(path))[0]
                ext  = os.path.splitext(path)[1].lower()

                self.status.set(f"Loading {fi+1}/{total_files}: {base}…")

                # Dosya metadata
                if ext == ".nd2":
                    f   = nd2.ND2File(path); sz = dict(f.sizes)
                    f.close()
                    nch  = sz.get("C", 1)
                    npos = sz.get("P", sz.get("M", 1))
                    pk   = "P" if "P" in sz else "M"
                else:
                    _, sz, _ = read_czi(path)
                    nch  = sz.get("C", 1)
                    npos = sz.get("S", sz.get("M", 1))
                    pk   = "S" if "S" in sz else "M"

                dp = self.do_pos.get() and npos > 1

                # Klasör adı: dosya adı + aktif channel renkleri
                active_colors = [lut_snapshot[i]["color"]
                                 for i in range(min(len(lut_snapshot), nch))
                                 if lut_snapshot[i]["enabled"]]
                color_suffix = "_".join(c.split(" ")[0] for c in active_colors) if active_colors else "all"

                total_ops = (npos if dp else 1) * nch
                op = 0

                pos_range = range(npos) if dp else [None]

                for pi in pos_range:
                    plbl = f"Pos{pi+1:03d}" if pi is not None else None

                    # Her pozisyon için channel array'lerini yükle
                    ch_arrays = self._load_pos_channels(path, ext, sz, pk, pi)

                    if not ch_arrays:
                        self.status.set(f"Skipping {base} — could not read channels")
                        continue

                    # Çıktı klasörü
                    pos_label = f"{base}_{color_suffix}"
                    if plbl:
                        out_dir = os.path.join(out_root, pos_label, plbl)
                    else:
                        out_dir = os.path.join(out_root, pos_label)
                    os.makedirs(out_dir, exist_ok=True)

                    mrgb = None
                    for ci, c2 in enumerate(ch_arrays):
                        lut = lut_snapshot[ci] if ci < len(lut_snapshot) else None
                        if lut and not lut["enabled"]: op += 1; continue

                        pfx = f"{base}_{plbl}" if plbl else base
                        cl  = f"CH{ci+1:02d}"

                        if lut:
                            lo = lut["min"]; hi = lut["max"]; gm = lut["gamma"]
                            cn = lut["color"]
                        else:
                            lo, hi, gm = 0, 65535, 1.0
                            cn = list(COLORS.keys())[ci % len(COLORS)]

                        # DEBUG — terminalde görünür
                        print(f"[EXPORT] CH{ci+1}: min={lo} max={hi} gamma={gm} color={cn}")

                        g8      = apply_lut(c2, lo, hi, gm)
                        col_rgb = COLORS.get(cn, (255, 255, 255))
                        colored = colorize(g8, col_rgb)

                        if self.do_ch.get():
                            tifffile.imwrite(os.path.join(out_dir, f"{pfx}_{cl}.tif"), c2)
                            cn_safe = cn.replace(" ", "_").replace("(", "").replace(")", "").replace("/", "")
                            Image.fromarray(colored).save(
                                os.path.join(out_dir, f"{pfx}_{cl}_{cn_safe}.png"))

                        if self.do_mpng.get() or self.do_mtif.get():
                            mrgb = (colored.astype(np.float32) if mrgb is None
                                    else np.clip(mrgb + colored.astype(np.float32), 0, 255))
                        op += 1
                        self.prog.set(5 + 90 * ((fi * total_ops + op) / (total_files * total_ops)))
                        self.status.set(f"{fi+1}/{total_files}: {base}  CH{ci+1}/{nch}")

                    if mrgb is not None:
                        m8  = mrgb.astype(np.uint8)
                        pfx = f"{base}_{plbl}" if plbl else base
                        if self.do_mpng.get():
                            Image.fromarray(m8).save(os.path.join(out_dir, f"{pfx}_MERGED.png"))
                        if self.do_mtif.get():
                            tifffile.imwrite(os.path.join(out_dir, f"{pfx}_MERGED.tif"), m8)

                if not self.state.get("licensed"):
                    self.state["count"] = self.state.get("count", 0) + 1
                    _save_state(self.state)
                    self.after(0, self._refresh_banner)

            self.prog.set(100)
            self.status.set(f"✓ Done! {total_files} file(s) processed.")
            messagebox.showinfo("Done",
                f"{total_files} file(s) processed.\n\nOutput:\n{out_root}")
        except Exception as e:
            self.status.set(f"Error: {e}"); messagebox.showerror("Error", str(e))

    def _load_pos_channels(self, path, ext, sz, pk, pos_idx):
        """Belirli bir pozisyonun tüm channel 2D array'lerini döner."""
        try:
            if ext == ".nd2":
                f    = nd2.ND2File(path); data = f.asarray(); ao = list(dict(f.sizes).keys()); f.close()
                nch  = sz.get("C", 1)
            else:
                data, _, _ = read_czi(path)
                ao  = [k for k in ("T","S","M","Z","C","Y","X") if k in sz]
                nch = sz.get("C", 1)

            # Pozisyon eksenini kes
            if pk in ao and sz.get(pk, 1) > 1 and pos_idx is not None:
                pi_ax = ao.index(pk)
                data  = np.take(data, pos_idx, axis=pi_ax)
                ao    = [k for k in ao if k != pk]

            # T projeksiyonu
            if "T" in ao:
                data = data.max(axis=ao.index("T"))
                ao   = [k for k in ao if k != "T"]

            # Z projeksiyonu
            if "Z" in ao:
                data = data.max(axis=ao.index("Z"))
                ao   = [k for k in ao if k != "Z"]

            # Channel ayır
            ci_ax = ao.index("C") if "C" in ao else None
            result = []
            for i in range(nch):
                ch = np.take(data, i, axis=ci_ax) if ci_ax is not None else data
                # Kalan ekstra eksenler varsa max projeksiyon
                while ch.ndim > 2:
                    ch = ch.max(axis=0)
                result.append(ch)
            return result
        except Exception as e:
            print(f"[LOAD_POS] {e}")
            return []


if __name__ == "__main__":
    if len(sys.argv) == 3 and sys.argv[1] == "--genkey":
        email = sys.argv[2]; key = make_key(email)
        print(f"\n{'='*50}\n  {APP} — License Key Generator\n{'='*50}")
        print(f"  Email : {email}\n  Key   : {key}")
        print(f"{'='*50}\n  Send this key to the customer by email.\n{'='*50}\n")
    else:
        App().mainloop()
