#!/usr/bin/env python3
"""
AFOMONT Espai Visual — Eines fotogràfiques
GUI unificada per a: Signar · Full Contactes · Baser · EXIF · RAW>JPG
Versió 2.4.2  22/03/26  JSP (i Claude AI)

Dependències: pip install customtkinter Pillow piexif
Opcional:     pip install resvg_py psd-tools pyexiftool
"""

import sys
import threading
import queue
from pathlib import Path

import customtkinter as ctk
from tkinter import filedialog, messagebox
from PIL import Image


# ---------------------------------------------------------------------------
# Resolució de rutes — compatible amb execució normal, py2app i PyInstaller
# ---------------------------------------------------------------------------
def resource_path(filename: str) -> Path:
    """
    Retorna la ruta absoluta d'un recurs (script, imatge, dada...).
    - Execució normal:  <directori_script> / filename
    - PyInstaller -F:   carpeta temporal sys._MEIPASS / filename
    - py2app (.app):    .app/Contents/Resources/ / filename
    """
    if hasattr(sys, "_MEIPASS"):
        return Path(sys._MEIPASS) / filename
    try:
        bundle_res = Path(sys.executable).parent.parent / "Resources"
        if bundle_res.exists():
            c = bundle_res / filename
            if c.exists(): return c
    except Exception:
        pass
    return Path(__file__).parent / filename


# CONSTANTS
TAB1 = "         SIGNAR         "
TAB2 = "     FULL CONTACTES     "
TAB3 = "       MARC BASE        "
TAB4 = "       METADADES        "
TAB5 = "        RAW > JPG       "
VERSIO = "2.4.2"
PEU = "Ver. " + VERSIO + ", © 2026 Jordi Soler Pla  ·  per AFOMONT Espai Visual  ·  jordi@afomont.com  ·  www.afomont.com"

# ---------------------------------------------------------------------------
# Darrera carpeta mare visitada — compartida entre tots els diàlegs
# ---------------------------------------------------------------------------
def _default_pictures_dir() -> str:
    """Retorna la carpeta d'imatges de l'usuari del sistema (cross-platform).
    Fallback: carpeta personal si no existeix."""
    import platform
    home = Path.home()
    system = platform.system()
    if system == "Windows":
        # USERPROFILE\Pictures  (o via shell API si existeix)
        try:
            import ctypes.wintypes
            buf = ctypes.create_unicode_buffer(ctypes.wintypes.MAX_PATH)
            ctypes.windll.shell32.SHGetFolderPathW(None, 0x0027, None, 0, buf)
            p = Path(buf.value)
            if p.exists(): return str(p)
        except Exception:
            pass
        p = home / "Pictures"
    elif system == "Darwin":           # macOS
        p = home / "Pictures"
    else:                              # Linux / altres
        # Respecta XDG_PICTURES_DIR si està definit
        import subprocess, os
        xdg = os.environ.get("XDG_PICTURES_DIR", "")
        if not xdg:
            try:
                out = subprocess.check_output(
                    ["xdg-user-dir", "PICTURES"], text=True).strip()
                xdg = out
            except Exception:
                pass
        p = Path(xdg) if xdg else home / "Pictures"
    return str(p) if p.exists() else str(home)

_last_browse_dir: str = _default_pictures_dir()

def _update_last_dir(path: str) -> None:
    """Actualitza _last_browse_dir amb la carpeta mare del fitxer/carpeta triat."""
    global _last_browse_dir
    p = Path(path)
    # Si és un fitxer, agafem el seu pare; si és carpeta, el pare de la carpeta
    _last_browse_dir = str(p.parent if p.is_file() else p.parent)

# ---------------------------------------------------------------------------
# Texts dels HINTS (tooltips) — edita aquí fàcilment
# ---------------------------------------------------------------------------
HINTS = {
    # Signar
    "sign_file":     "Selecciona el fitxer de signatura (PNG ó SVG preferiblement)",
    "sign_src":      "Selecciona la carpeta que conté les fotografies originals",
    "sign_dst":      "Selecciona la carpeta on es desaran les imatges signades",
    "cant_A":        "Detecta automàticament la cantonada més homogènia (menys distraccions)",
    "cant_D":        "Col·loca la signatura a la cantonada inferior dreta",
    "cant_E":        "Col·loca la signatura a la cantonada inferior esquerra",
    "cant_C":        "Col·loca la signatura centrada a la part inferior",
    "btn_signar":    "Inicia el procés d'aplicació de la signatura a totes les fotos",
    # Full Contactes
    "fc_src":        "Selecciona la carpeta amb les imatges a incloure",
    "fc_dst":        "Selecciona on es desaran els fulls de contacte generats (opcional, per defecte carpeta origen)",
    "btn_fc":        "Genera els fulls de contacte amb les fotos de la carpeta indicada",
    # Baser
    "baser_base":    "Selecciona la imatge de fons o base (paspartú o marc)",
    "baser_src":     "Selecciona la carpeta que conté les fotografies a processar",
    "baser_dst":     "Selecciona la carpeta on es desaran les imatges resultants",
    "btn_baser":     "Inicia el procés d'enmarcar les fotografies",
    # Comuns
    "browse":        "Obrir diàleg per navegar i seleccionar fitxer o carpeta",
    "clear_log":     "Buida l'àrea de progrés",
    # EXIF
    "exif_src":      "Selecciona la carpeta amb les imatges JPG a modificar",
    "exif_dh_h":     "Nombre d'hores a sumar (positiu) o restar (negatiu). Accepta decimals: 1.5 = 1h 30min",
    "exif_dhset":    "Nova data i hora a establir. Format: YYYY-MM-DD HH:MM:SS",
    "exif_cr":       "Text de copyright a escriure. Deixa buit per esborrar el copyright existent",
    "exif_autor":    "Nom del artista a escriure. Deixa buit per esborrar el camp existent",
    "exif_titol":    "Títol o descripció de la imatge. Deixa buit per esborrar el camp existent",
    "btn_exif":      "Aplica l'acció EXIF seleccionada a totes les imatges JPG de la carpeta",
    # RAW>JPG
    "raw_src":       "Selecciona la carpeta amb els fitxers RAW (ARW, NEF, CR2, CR3, ORF…) o JPG",
    "raw_dst":       "Selecciona la carpeta on es desaran els fitxers JPG convertits",
    "btn_raw":       "Inicia la conversió de tots els fitxers RAW i JPG de la carpeta origen",
}

# ---------------------------------------------------------------------------
# Tema i colors
# ---------------------------------------------------------------------------
ctk.set_appearance_mode("light")
ctk.set_default_color_theme("blue")

C_BG            = "#efefef"   # Fons general
C_PANEL         = "#f8f8f8"   # Panells / targetes
C_HEADER        = "#efefef"   # Capçalera — igual que el fons perquè el logo es vegi bé
C_FOOTER        = "#383838"   # Peu de pàgina
C_BORDER        = "#cccccc"   # Vores
C_BTN           = "#aaaaaa"   # Botons normals (clar, contrast evident en hover)
C_BTN_HOV       = "#777777"   # Hover botons
C_BTN_RUN       = "#3a7d44"   # Botó d'acció principal — verd
C_BTN_RUN_HOV   = "#265c2e"   # Hover botó acció — verd fosc
C_CANT_SEL      = "#111111"   # Cantonada seleccionada — quasi negre
C_CANT_SEL_HOV  = "#000000"   # Hover cantonada seleccionada
C_TAB_SEL       = "#555555"   # Fons tabulador actiu
C_TAB_SEL_TXT   = "#ffffff"   # Text tabulador actiu — blanc absolut
C_TAB_UNSEL     = "#d0d0d0"   # Fons tabulador inactiu
C_TAB_UNSEL_TXT = "#555555"   # Text tabulador inactiu
C_TEXT          = "#222222"   # Text principal
C_TEXT2         = "#777777"   # Text secundari / hints
C_TITLE         = "#444444"   # Títols de secció
C_LOG_BG        = "#1c1c1c"
C_LOG_OK        = "#6abf69"
C_LOG_ERR       = "#e06060"
C_LOG_WARN      = "#e8a030"
C_LOG_INFO      = "#e0e0e0"
C_LOG_MUTED     = "#888888"
C_SLIDER_PROG   = "#606060"
C_CHECK         = "#606060"

FONT_TITLE  = ("Helvetica", 12, "bold")
FONT_LABEL  = ("Helvetica", 12)
FONT_SMALL  = ("Helvetica", 11)
FONT_HINT   = ("Helvetica", 10)
FONT_MONO   = ("Courier New", 11)
FONT_BTN    = ("Helvetica", 12, "bold")
FONT_TAB    = ("Helvetica", 15, "bold")   # Tabuladors — doble de gran
FONT_FOOTER = ("Helvetica", 12)


# ---------------------------------------------------------------------------
# Tooltip simple
# ---------------------------------------------------------------------------
class Tooltip:
    def __init__(self, widget, text: str):
        self._widget = widget
        self._text   = text
        self._tip    = None
        widget.bind("<Enter>", self._show, add="+")
        widget.bind("<Leave>", self._hide, add="+")

    def _show(self, _=None):
        if not self._text: return
        import tkinter as tk
        x = self._widget.winfo_rootx() + 20
        y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4
        self._tip = tk.Toplevel(self._widget)
        self._tip.wm_overrideredirect(True)
        self._tip.wm_geometry(f"+{x}+{y}")
        tk.Label(self._tip, text=self._text,
                 background="#ffffcc", foreground="#222222",
                 relief="solid", borderwidth=1,
                 font=("Helvetica", 9), padx=6, pady=3).pack()

    def _hide(self, _=None):
        if self._tip:
            self._tip.destroy()
            self._tip = None


# ---------------------------------------------------------------------------
# Redirecció de stdout a la cua
# ---------------------------------------------------------------------------
class QueueWriter:
    def __init__(self, q: queue.Queue, tag: str = "info"):
        self.q, self.tag = q, tag
    def write(self, text):
        if text.strip():
            self.q.put((self.tag, text.rstrip()))
    def flush(self): pass


# ---------------------------------------------------------------------------
# Widget: fila de fitxer/carpeta
# ---------------------------------------------------------------------------
class FileRow(ctk.CTkFrame):
    def __init__(self, parent, label, hint_entry, cmd, btn_hint="", **kw):
        super().__init__(parent, fg_color="transparent", **kw)
        self.var = ctk.StringVar()
        ctk.CTkLabel(self, text=label, font=FONT_LABEL,
                     text_color=C_TEXT, width=170, anchor="w"
                     ).pack(side="left", padx=(0, 6))
        ctk.CTkEntry(self, textvariable=self.var, font=FONT_SMALL,
                     placeholder_text=hint_entry,
                     fg_color=C_PANEL, border_color=C_BORDER,
                     text_color=C_TEXT, height=30
                     ).pack(side="left", fill="x", expand=True, padx=(0, 6))
        btn = ctk.CTkButton(self, text="…", font=FONT_BTN,
                            width=34, height=30,
                            fg_color=C_BTN, hover_color=C_BTN_HOV,
                            text_color="white", command=cmd)
        btn.pack(side="left")
        Tooltip(btn, btn_hint or HINTS["browse"])

    def get(self): return self.var.get()
    def set(self, v): self.var.set(v)


# ---------------------------------------------------------------------------
# Widget: slider opcional — hint a SOTA del control
# ---------------------------------------------------------------------------
class OptSlider(ctk.CTkFrame):
    def __init__(self, parent, label, hint, val_min, val_max, default, fmt, **kw):
        super().__init__(parent, fg_color="transparent", **kw)
        self.fmt     = fmt
        self._default = default
        self.var_on  = ctk.BooleanVar(value=False)
        self.var_val = ctk.IntVar(value=default)

        # Fila del control
        ctrl = ctk.CTkFrame(self, fg_color="transparent")
        ctrl.pack(fill="x")

        chk = ctk.CTkCheckBox(
            ctrl, text=label, font=FONT_LABEL,
            variable=self.var_on, text_color=C_TEXT,
            fg_color=C_CHECK, hover_color=C_BTN_HOV,
            checkmark_color="white", width=20,
            command=self._toggle)
        chk.pack(side="left", padx=(0, 10))

        self._lbl = ctk.CTkLabel(ctrl, text=fmt(default), font=FONT_LABEL,
                                  text_color=C_TEXT2, width=56, anchor="e")
        self._lbl.pack(side="right", padx=(0, 2))

        self._slider = ctk.CTkSlider(
            ctrl, from_=val_min, to=val_max,
            variable=self.var_val,
            number_of_steps=val_max - val_min,
            fg_color=C_BORDER, progress_color=C_SLIDER_PROG,
            button_color=C_BTN, button_hover_color=C_BTN_HOV,
            state="disabled",
            command=self._on_slide)
        self._slider.pack(side="left", fill="x", expand=True, padx=(0, 8))
        self._slider.set(default)

        # Hint a SOTA
        ctk.CTkLabel(self, text=f"   {hint}", font=FONT_HINT,
                     text_color=C_TEXT2, anchor="w"
                     ).pack(fill="x", padx=(2, 0), pady=(1, 4))

        # Activar el slider quan l'usuari el toca (clic o arrossegament)
        # Funciona interceptant els events del widget intern de tkinter
        try:
            inner = self._slider._canvas
            inner.bind("<ButtonPress-1>",   self._activate_from_slider, add="+")
            inner.bind("<B1-Motion>",        self._activate_from_slider, add="+")
        except Exception:
            pass

    def _activate_from_slider(self, _=None):
        """Activa el checkbox quan l'usuari interactua amb el slider."""
        if not self.var_on.get():
            self.var_on.set(True)
            self._toggle()

    def _on_slide(self, v):
        """Actualitza l'etiqueta de valor i activa el checkbox si cal."""
        self._lbl.configure(text=self.fmt(v))
        self._activate_from_slider()

    def _toggle(self):
        if self.var_on.get():
            self._slider.configure(state="normal")
            self._lbl.configure(text_color=C_TEXT)
        else:
            self._slider.configure(state="disabled")
            self._lbl.configure(text_color=C_TEXT2)
            # Reseteja al valor per defecte
            self.var_val.set(self._default)
            self._slider.set(self._default)
            self._lbl.configure(text=self.fmt(self._default))

    def active(self): return self.var_on.get()
    def value(self):  return self.var_val.get()


# ---------------------------------------------------------------------------
# Widget: àrea de log
# ---------------------------------------------------------------------------
class LogArea(ctk.CTkFrame):
    def __init__(self, parent, **kw):
        super().__init__(parent, fg_color=C_PANEL,
                         border_color=C_BORDER, border_width=1,
                         corner_radius=6, **kw)
        hdr = ctk.CTkFrame(self, fg_color="transparent")
        hdr.pack(fill="x", padx=8, pady=(6, 2))
        ctk.CTkLabel(hdr, text="Progrés", font=FONT_TITLE,
                     text_color=C_TITLE).pack(side="left")
        btn_cl = ctk.CTkButton(hdr, text="Netejar", font=FONT_SMALL,
                               width=66, height=22, corner_radius=4,
                               fg_color=C_BTN, hover_color=C_BTN_HOV,
                               text_color="white", command=self.clear)
        btn_cl.pack(side="right")
        Tooltip(btn_cl, HINTS["clear_log"])

        self._box = ctk.CTkTextbox(
            self, height=180, font=FONT_MONO,
            fg_color=C_LOG_BG, text_color=C_LOG_INFO,
            border_width=0, corner_radius=4,
            wrap="word", state="disabled",
            scrollbar_button_color="#555555",
            scrollbar_button_hover_color="#888888")
        self._box.pack(fill="both", expand=True, padx=8, pady=(0, 8))

        inner = self._box._textbox
        inner.tag_config("ok",    foreground=C_LOG_OK)
        inner.tag_config("error", foreground=C_LOG_ERR)
        inner.tag_config("warn",  foreground=C_LOG_WARN)
        inner.tag_config("info",  foreground=C_LOG_INFO)
        inner.tag_config("muted", foreground=C_LOG_MUTED)

    def append(self, text: str, tag: str = "info"):
        t = text.lower()
        if "✓" in text or "completat" in t or "correctament" in t or "✔" in text:
            tag = "ok"
        elif "✗" in text or "error" in t or "❌" in text:
            tag = "error"
        elif "ℹ" in text or "⚠" in text or "avis" in t:
            tag = "warn"
        self._box.configure(state="normal")
        self._box._textbox.insert("end", text + "\n", tag)
        self._box.see("end")
        self._box.configure(state="disabled")

    def clear(self):
        self._box.configure(state="normal")
        self._box.delete("0.0", "end")
        self._box.configure(state="disabled")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def card(parent, title):
    f = ctk.CTkFrame(parent, fg_color=C_PANEL,
                     border_color=C_BORDER, border_width=1, corner_radius=6)
    f.pack(fill="x", pady=(0, 8), padx=2)
    ctk.CTkLabel(f, text=title, font=FONT_TITLE, text_color=C_TITLE
                 ).pack(anchor="w", padx=10, pady=(7, 3))
    ctk.CTkFrame(f, height=1, fg_color=C_BORDER).pack(fill="x", padx=10, pady=(0, 8))
    return f

def run_btn(parent, text, cmd, hint=""):
    b = ctk.CTkButton(parent, text=text, font=FONT_BTN,
                      fg_color=C_BTN_RUN, hover_color=C_BTN_RUN_HOV,
                      text_color="white", height=40, corner_radius=6,
                      command=cmd)
    if hint: Tooltip(b, hint)
    return b


# ===========================================================================
# TAB 1 — SIGNAR
# ===========================================================================
class TabSignar(ctk.CTkFrame):
    def __init__(self, parent):
        super().__init__(parent, fg_color=C_BG)
        self._running = False
        self._q: queue.Queue = queue.Queue()
        self._build()
        self._poll()

    def _build(self):
        scroll = ctk.CTkFrame(self, fg_color=C_BG)
        scroll.pack(fill="both", expand=True, padx=10, pady=8)

        fc = card(scroll, "Fitxers i carpetes")
        self._sign = FileRow(fc, "Fitxer de signatura", "PNG · SVG · PSD",
                             self._browse_sign, btn_hint=HINTS["sign_file"])
        self._sign.pack(fill="x", padx=10, pady=3)
        self._src = FileRow(fc, "Carpeta origen", "Carpeta amb les fotografies",
                            lambda: self._browse_dir(self._src), btn_hint=HINTS["sign_src"])
        self._src.pack(fill="x", padx=10, pady=3)
        self._dst = FileRow(fc, "Carpeta destí", "On es desaran les imatges signades",
                            lambda: self._browse_dir(self._dst), btn_hint=HINTS["sign_dst"])
        self._dst.pack(fill="x", padx=10, pady=(3, 10))

        pc = card(scroll, "Posició de la signatura")
        row = ctk.CTkFrame(pc, fg_color="transparent")
        row.pack(fill="x", padx=10, pady=(0, 10))
        ctk.CTkLabel(row, text="Cantonada Inferior", font=FONT_LABEL,
                     text_color=C_TEXT, width=170, anchor="w"
                     ).pack(side="left", padx=(0, 6))
        self._cant = ctk.StringVar(value="A")
        self._cant_btns = {}
        for val, lbl, hint in [
            ("A", "Automàtic", HINTS["cant_A"]),
            ("E", "Esquerra",  HINTS["cant_E"]),
            ("C", "Centre",    HINTS["cant_C"]),
            ("D", "Dreta", HINTS["cant_D"]),
        ]:
            b = ctk.CTkButton(row, text=lbl, font=FONT_SMALL,
                              width=90, height=30, corner_radius=5,
                              fg_color=C_CANT_SEL if val == "A" else C_BTN,
                              hover_color=C_CANT_SEL_HOV if val == "A" else C_BTN_HOV,
                              text_color="white",
                              command=lambda v=val: self._set_cant(v))
            b.pack(side="left", padx=3)
            Tooltip(b, hint)
            self._cant_btns[val] = b

        oc = card(scroll, "Paràmetres opcionals")
        self._factor = OptSlider(oc,
            "Factor de mida  (1/N de l'amplada)",
            "Com més gran el valor N, més petita la signatura.  Per defecte: 1/14",
            4, 40, 14, lambda v: f"1/{int(v)}")
        self._factor.pack(fill="x", padx=10, pady=(0, 4))

        self._llindar = OptSlider(oc,
            "Llindar d'inversió  (% lluminositat)",
            "Per sota d'aquest % la signatura inverteix color.  0 = no s'inverteix mai.  Per defecte: 50%",
            0, 100, 50, lambda v: f"{int(v)} %")
        self._llindar.pack(fill="x", padx=10, pady=(0, 10))

        self._log = LogArea(scroll)
        self._log.pack(fill="x", pady=(0, 8))

        self._btn = run_btn(scroll, "▶  SIGNAR FOTOGRAFIES", self._run, hint=HINTS["btn_signar"])
        self._btn.pack(fill="x", pady=(0, 4))

    def _browse_sign(self):
        p = filedialog.askopenfilename(title="Selecciona la signatura",
            initialdir=_last_browse_dir,
            filetypes=[("Imatges", "*.png *.svg *.psd *.PNG *.SVG *.PSD"), ("Tots", "*.*")])
        if p: self._sign.set(p); _update_last_dir(p)

    def _browse_dir(self, w):
        p = filedialog.askdirectory(initialdir=_last_browse_dir)
        if p: w.set(p); _update_last_dir(p)

    def _set_cant(self, val):
        self._cant.set(val)
        for k, b in self._cant_btns.items():
            if k == val:
                b.configure(fg_color=C_CANT_SEL, hover_color=C_CANT_SEL_HOV)
            else:
                b.configure(fg_color=C_BTN, hover_color=C_BTN_HOV)

    def _validate(self):
        if not self._sign.get() or not Path(self._sign.get()).exists():
            messagebox.showwarning("Signar", "Selecciona un fitxer de signatura vàlid."); return False
        if not self._src.get() or not Path(self._src.get()).is_dir():
            messagebox.showwarning("Signar", "Selecciona la carpeta origen."); return False
        if not self._dst.get():
            messagebox.showwarning("Signar", "Selecciona la carpeta destí."); return False
        return True

    def _run(self):
        if self._running or not self._validate(): return
        self._running = True
        self._btn.configure(state="disabled", text="⏳  Processant…")
        self._log.clear()
        sign    = self._sign.get(); src = self._src.get(); dst = self._dst.get()
        cant    = self._cant.get()
        factor  = self._factor.value()  if self._factor.active()  else 14
        llindar = self._llindar.value() if self._llindar.active() else 50
        self._log.append(f"▶  Signatura: {Path(sign).name}  |  Cantonada: {cant}  |  Factor: 1/{factor}  |  Llindar: {llindar}%", "muted")
        self._log.append("─" * 54, "muted")
        threading.Thread(target=self._worker, args=(sign, src, dst, cant, factor, llindar), daemon=True).start()

    def _worker(self, sign_path, src, dst, cant, factor, llindar):
        old_out, old_err = sys.stdout, sys.stderr
        sys.stdout = QueueWriter(self._q, "info"); sys.stderr = QueueWriter(self._q, "error")
        try:
            import importlib.util
            script = resource_path("Signar.py")
            if not script.exists(): self._q.put(("error", "ERROR: No es troba Signar.py")); return
            spec = importlib.util.spec_from_file_location("signar", script)
            mod  = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
            sign_obj = mod.carregar_signatura(Path(sign_path))
            dst_path = Path(dst); dst_path.mkdir(parents=True, exist_ok=True)
            llindar_f = llindar / 100.0
            fitxers = sorted(f for f in Path(src).iterdir()
                             if f.is_file() and f.suffix.lower() in mod.EXTENSIONS)
            if not fitxers: self._q.put(("warn", "⚠  No s'han trobat imatges.")); return
            self._q.put(("info", f"   {len(fitxers)} imatge(s) trobades\n"))
            errors = []
            for f in fitxers:
                try: mod.signar_imatge(f, sign_obj, cant, dst_path, factor, llindar_f)
                except Exception as e:
                    self._q.put(("error", f"  ✗  {f.name}  →  {e}")); errors.append(f.name)
            self._q.put(("muted", "─" * 54))
            self._q.put(("ok", f"✔  Procés completat: {len(fitxers)-len(errors)}/{len(fitxers)} signades."))
            if errors: self._q.put(("error", f"   Errors: {', '.join(errors)}"))
        except Exception as e: self._q.put(("error", f"ERROR inesperat: {e}"))
        finally: sys.stdout, sys.stderr = old_out, old_err; self.after(0, self._done)

    def _done(self):
        self._running = False; self._btn.configure(state="normal", text="▶  SIGNAR FOTOGRAFIES")

    def _poll(self):
        try:
            while True: tag, text = self._q.get_nowait(); self._log.append(text, tag)
        except queue.Empty: pass
        self.after(80, self._poll)


# ===========================================================================
# TAB 2 — FULL CONTACTES
# ===========================================================================
class TabFullContactes(ctk.CTkFrame):
    def __init__(self, parent):
        super().__init__(parent, fg_color=C_BG)
        self._running = False
        self._q: queue.Queue = queue.Queue()
        self._build()
        self._poll()

    def _build(self):
        scroll = ctk.CTkFrame(self, fg_color=C_BG)
        scroll.pack(fill="both", expand=True, padx=10, pady=8)

        fc = card(scroll, "Fitxers i carpetes")
        self._src = FileRow(fc, "Carpeta d'imatges", "Carpeta amb les fotografies a incloure",
                            lambda: self._browse_dir(self._src), btn_hint=HINTS["fc_src"])
        self._src.pack(fill="x", padx=10, pady=3)

        self._dst = FileRow(fc, "Carpeta destí", "On es desaran els fulls (buit = carpeta origen)",
                            lambda: self._browse_dir(self._dst), btn_hint=HINTS["fc_dst"])
        self._dst.pack(fill="x", padx=10, pady=3)

        tr = ctk.CTkFrame(fc, fg_color="transparent")
        tr.pack(fill="x", padx=10, pady=(3, 10))
        ctk.CTkLabel(tr, text="Títol de la sessió", font=FONT_LABEL,
                     text_color=C_TEXT, width=170, anchor="w").pack(side="left", padx=(0, 6))
        self._titol = ctk.CTkEntry(tr, font=FONT_SMALL, placeholder_text="Event, Model, data, lloc... (opcional)",
                                   fg_color=C_PANEL, border_color=C_BORDER,
                                   text_color=C_TEXT, height=30)
        self._titol.pack(fill="x", expand=True)

        pc = card(scroll, "Paràmetres opcionals")
        self._cella = OptSlider(pc, "Mida de cel·la (px)",
            "Mida d'un costat del quadrat de cada fotografia.  Per defecte: 350 px",
            100, 900, 350, lambda v: f"{int(v)} px")
        self._cella.pack(fill="x", padx=10, pady=(0, 4))

        self._cols = OptSlider(pc, "Columnes per fulla",
            "Nombre de fotografies per fila.  Per defecte: 8",
            1, 20, 8, lambda v: f"{int(v)}")
        self._cols.pack(fill="x", padx=10, pady=(0, 4))

        self._files = OptSlider(pc, "Files per fulla",
            "Nombre de files de fotografies per pàgina.  Per defecte: 4",
            1, 20, 4, lambda v: f"{int(v)}")
        self._files.pack(fill="x", padx=10, pady=(0, 10))

        self._log = LogArea(scroll)
        self._log.pack(fill="x", pady=(0, 8))

        self._btn = run_btn(scroll, "▶  GENERAR FULLES DE CONTACTE", self._run, hint=HINTS["btn_fc"])
        self._btn.pack(fill="x", pady=(0, 4))

    def _browse_dir(self, w):
        p = filedialog.askdirectory(initialdir=_last_browse_dir)
        if p: w.set(p); _update_last_dir(p)

    def _validate(self):
        if not self._src.get() or not Path(self._src.get()).is_dir():
            messagebox.showwarning("Full Contactes", "Selecciona la carpeta d'imatges."); return False
        if not self._dst.get().strip():
            messagebox.showwarning("Full Contactes", "Selecciona la carpeta destí."); return False
        return True

    def _run(self):
        if self._running or not self._validate(): return
        self._running = True
        self._btn.configure(state="disabled", text="⏳  Generant…")
        self._log.clear()
        src   = self._src.get()
        dst   = self._dst.get().strip()
        titol = self._titol.get().strip()
        cella = self._cella.value() if self._cella.active() else 350
        cols  = self._cols.value()  if self._cols.active()  else 8
        files = self._files.value() if self._files.active() else 4
        self._log.append(f"▶  Origen: {src}", "muted")
        titol_log = titol if titol else "(llegeix EXIF ImageDescription)"
        self._log.append(f"   Destí: {dst}  |  Títol: {titol_log}  |  Cel·la: {cella}px  |  {cols}×{files}", "muted")
        self._log.append("─" * 54, "muted")
        threading.Thread(target=self._worker, args=(src, dst, titol, cella, cols, files), daemon=True).start()

    def _worker(self, src, dst, titol, cella, cols, files):
        import math
        old_out, old_err = sys.stdout, sys.stderr
        sys.stdout = QueueWriter(self._q, "info"); sys.stderr = QueueWriter(self._q, "error")
        try:
            import importlib.util
            script = resource_path("FullContactes.py")
            if not script.exists(): self._q.put(("error", "ERROR: No es troba FullContactes.py")); return
            spec = importlib.util.spec_from_file_location("fc", script)
            mod  = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
            src_path = Path(src); dst_path = Path(dst); dst_path.mkdir(parents=True, exist_ok=True)
            exts = {".jpg", ".jpeg", ".JPG", ".JPEG"}
            jpgs = sorted(f for f in src_path.iterdir() if f.is_file() and f.suffix in exts)
            if not jpgs: self._q.put(("warn", "⚠  No s'han trobat imatges JPG.")); return
            footer      = mod.read_exif_copyright(jpgs[0])
            # Títol: si buit, intentem llegir-lo de l'EXIF ImageDescription
            if not titol:
                titol = mod.read_exif_title(jpgs[0])
                if titol:
                    self._q.put(("info", f"   Títol llegit d'EXIF: {titol}"))
            font_cell   = mod.get_font(mod.FONT_SIZE)
            font_header = mod.get_font(mod.HEADER_FONT_SIZE)
            per_sheet   = cols * files
            total       = math.ceil(len(jpgs) / per_sheet)
            self._q.put(("info", f"   {len(jpgs)} imatge(s)  →  {total} fulla(es)\n"))
            for idx in range(total):
                batch = jpgs[idx * per_sheet: (idx + 1) * per_sheet]
                imgs  = []
                for f in batch:
                    try: imgs.append((f.stem[-3:], Image.open(f).convert("RGB")))
                    except Exception as e: self._q.put(("warn", f"  ⚠  '{f.name}': {e}"))
                mod.make_sheet(imgs, cols, files, cella, font_cell, font_header,
                               idx + 1, dst_path, header_text=titol, footer_text=footer)
            self._q.put(("muted", "─" * 54))
            self._q.put(("ok", f"✔  {total} fulla(es) generades a '{dst_path}'."))
        except Exception as e: self._q.put(("error", f"ERROR inesperat: {e}"))
        finally: sys.stdout, sys.stderr = old_out, old_err; self.after(0, self._done)

    def _done(self):
        self._running = False; self._btn.configure(state="normal", text="▶  GENERAR FULLES DE CONTACTE")

    def _poll(self):
        try:
            while True: tag, text = self._q.get_nowait(); self._log.append(text, tag)
        except queue.Empty: pass
        self.after(80, self._poll)


# ===========================================================================
# TAB 3 — BASER
# ===========================================================================
class TabBaser(ctk.CTkFrame):
    def __init__(self, parent):
        super().__init__(parent, fg_color=C_BG)
        self._running = False
        self._q: queue.Queue = queue.Queue()
        self._build()
        self._poll()

    def _build(self):
        scroll = ctk.CTkFrame(self, fg_color=C_BG)
        scroll.pack(fill="both", expand=True, padx=10, pady=8)

        fc = card(scroll, "Fitxers i carpetes")
        self._base = FileRow(fc, "Imatge base (marc de fons)", "JPG · PNG  (paspartú o fons)",
                             self._browse_base, btn_hint=HINTS["baser_base"])
        self._base.pack(fill="x", padx=10, pady=3)
        self._src = FileRow(fc, "Carpeta origen", "Carpeta amb les fotografies",
                            lambda: self._browse_dir(self._src), btn_hint=HINTS["baser_src"])
        self._src.pack(fill="x", padx=10, pady=3)
        self._dst = FileRow(fc, "Carpeta destí", "On es desaran les imatges emmarcades",
                            lambda: self._browse_dir(self._dst), btn_hint=HINTS["baser_dst"])
        self._dst.pack(fill="x", padx=10, pady=(3, 10))

        oc = card(scroll, "Paràmetres opcionals")
        self._pct = OptSlider(oc,
            "Percentatge de la foto sobre la base",
            "Mida de la fotografia en relació amb la imatge base.  Per defecte: 100%",
            1, 100, 100, lambda v: f"{int(v)} %")
        self._pct.pack(fill="x", padx=10, pady=(0, 10))

        self._log = LogArea(scroll)
        self._log.pack(fill="x", pady=(0, 8))

        self._btn = run_btn(scroll, "▶  APLICAR MARC BASE A LES FOTOGRAFIES", self._run, hint=HINTS["btn_baser"])
        self._btn.pack(fill="x", pady=(0, 4))

    def _browse_base(self):
        p = filedialog.askopenfilename(title="Selecciona la imatge marc base",
            initialdir=_last_browse_dir,
            filetypes=[("Imatges", "*.jpg *.jpeg *.png *.JPG *.PNG"), ("Tots", "*.*")])
        if p: self._base.set(p); _update_last_dir(p)

    def _browse_dir(self, w):
        p = filedialog.askdirectory(initialdir=_last_browse_dir)
        if p: w.set(p); _update_last_dir(p)

    def _validate(self):
        if not self._base.get() or not Path(self._base.get()).exists():
            messagebox.showwarning("Marc Base", "Selecciona una imatge base vàlida."); return False
        if not self._src.get() or not Path(self._src.get()).is_dir():
            messagebox.showwarning("Marc Base", "Selecciona la carpeta origen."); return False
        if not self._dst.get():
            messagebox.showwarning("Marc Base", "Selecciona la carpeta destí."); return False
        return True

    def _run(self):
        if self._running or not self._validate(): return
        self._running = True
        self._btn.configure(state="disabled", text="⏳  Processant…")
        self._log.clear()
        base = self._base.get(); src = self._src.get(); dst = self._dst.get()
        pct  = self._pct.value() if self._pct.active() else 100
        self._log.append(f"▶  Base: {Path(base).name}  |  Percentatge: {pct}%", "muted")
        self._log.append("─" * 54, "muted")
        threading.Thread(target=self._worker, args=(base, src, dst, pct), daemon=True).start()

    def _worker(self, base_path, src, dst, pct):
        old_out, old_err = sys.stdout, sys.stderr
        sys.stdout = QueueWriter(self._q, "info"); sys.stderr = QueueWriter(self._q, "error")
        try:
            import importlib.util
            script = resource_path("Baser.py")
            if not script.exists(): self._q.put(("error", "ERROR: No es troba Baser.py")); return
            spec = importlib.util.spec_from_file_location("baser", script)
            mod  = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
            imatge_base = Image.open(base_path).convert("RGBA")
            src_path    = Path(src); dst_path = Path(dst); dst_path.mkdir(parents=True, exist_ok=True)
            imatges = sorted(f for f in src_path.iterdir()
                             if f.is_file() and f.suffix.lower() in mod.EXTENSIONS_SUPORTADES)
            if not imatges: self._q.put(("warn", "⚠  No s'han trobat imatges.")); return
            self._q.put(("info", f"   {len(imatges)} imatge(s) trobades\n"))
            ok_n, err_n = 0, 0
            for f in imatges:
                if mod.processar_imatge(f, imatge_base, dst_path, float(pct)): ok_n += 1
                else: err_n += 1
            self._q.put(("muted", "─" * 54))
            self._q.put(("ok", f"✔  Procés completat: {ok_n}/{len(imatges)} processades."))
            if err_n: self._q.put(("error", f"   Errors en {err_n} imatge(s)."))
        except Exception as e: self._q.put(("error", f"ERROR inesperat: {e}"))
        finally: sys.stdout, sys.stderr = old_out, old_err; self.after(0, self._done)

    def _done(self):
        self._running = False; self._btn.configure(state="normal", text="▶  APLICAR MARC BASE A LES FOTOGRAFIES")

    def _poll(self):
        try:
            while True: tag, text = self._q.get_nowait(); self._log.append(text, tag)
        except queue.Empty: pass
        self.after(80, self._poll)


# ===========================================================================
# TAB 4 — METADADES
# ===========================================================================
class TabExif(ctk.CTkFrame):
    """
    Interfície per accions de EditarEXIF.py:
      DHFitxer · DH · DHSET · CR · Autor · Titol
    Les accions es presenten en vertical, cada una amb el seu camp de
    paràmetre a la mateixa alçada. El camp s'activa quan l'acció és seleccionada.
    """
    ACCIONS = ["DHFitxer", "DH", "DHSET", "CR", "Autor", "Titol"]

    # Text visual dels botons — edita lliurement sense afectar el comportament
    _LABEL = {
        "DHFitxer": "Data fitxer",
        "DH":       "Ajust hores",
        "DHSET":    "Fixar data",
        "CR":       "Copyright",
        "Autor":    "Autor",
        "Titol":    "Títol",
    }

    # Descripció curta per a cada acció (columna central de la fila)
    _DESC = {
        "DHFitxer": "Aplicar la data/hora registrada per la càmera al fitxer de la imatge",
        "DH":       "Suma o resta hores als tags EXIF de data i hora (DateTimeOriginal, DateTimeDigitized, DateTime)",
        "DHSET":    "Estableix una nova data i hora concreta als tags EXIF de data de totes les imatges",
        "CR":       "Escriu o esborra el tag EXIF Copyright (0x8298) de totes les imatges",
        "Autor":    "Escriu o esborra el tag EXIF Artist (0x013B) de totes les imatges",
        "Titol":    "Escriu o esborra el tag EXIF ImageDescription (0x010E) — títol o descripció de la imatge",
    }

    def __init__(self, parent):
        super().__init__(parent, fg_color=C_BG)
        self._running = False
        self._q: queue.Queue = queue.Queue()
        self._accio = ctk.StringVar(value="DHFitxer")
        self._build()
        self._poll()

    # ── Construcció de la UI ─────────────────────────────────────────────────

    def _build(self):
        scroll = ctk.CTkFrame(self, fg_color=C_BG)
        scroll.pack(fill="both", expand=True, padx=10, pady=8)

        # ── Carpeta ──────────────────────────────────────────────────────────
        fc = card(scroll, "Carpeta d'imatges")
        self._src = FileRow(fc, "Carpeta d'imatges", "Carpeta amb les fotografies JPG",
                            self._browse_src, btn_hint=HINTS["exif_src"])
        self._src.pack(fill="x", padx=10, pady=(3, 10))

        # ── Accions en vertical ───────────────────────────────────────────────
        ac = card(scroll, "Canviar Metadades EXIF")

        self._accio_btns:   dict[str, ctk.CTkButton] = {}
        self._param_frames: dict[str, ctk.CTkFrame]  = {}

        # Variables de paràmetre (una per acció que en necessita)
        self._dh_var    = ctk.StringVar(value="1")
        self._dhset_var = ctk.StringVar()
        self._cr_var    = ctk.StringVar()
        self._autor_var = ctk.StringVar()
        self._titol_var = ctk.StringVar()

        # Mapes: acció → variable i configuració del camp
        _param_cfg = {
            # acció:  (var,               placeholder,                   width, hint_key,      label)
            "DH":    (self._dh_var,    "p.ex.  2  o  -1.5",             110,  "exif_dh_h",   "Hores (±)"),
            "DHSET": (self._dhset_var, "YYYY-MM-DD HH:MM:SS",           200,  "exif_dhset",  "Data i hora"),
            "CR":    (self._cr_var,    "Buit per esborrar el copyright",   0,  "exif_cr",     "Copyright"),
            "Autor": (self._autor_var, "Buit per esborrar l'autor",        0,  "exif_autor",  "Autor"),
            "Titol": (self._titol_var, "Buit per esborrar el títol",       0,  "exif_titol",  "Títol"),
        }

        C_ROW_ACT   = "#e8f0e8"   # fons fila activa (verd molt pàl·lid)
        C_ROW_INACT = "transparent"

        for idx, accio in enumerate(self.ACCIONS):
            is_first = (accio == "DHFitxer")

            # Fila contenidora (botó + camp de paràmetre)
            row = ctk.CTkFrame(ac,
                               fg_color=C_ROW_ACT if is_first else C_ROW_INACT,
                               corner_radius=5)
            row.pack(fill="x", padx=10, pady=(2, 2))

            # Botó d'acció (amplada fixa a l'esquerra)
            b = ctk.CTkButton(
                row, text=self._LABEL[accio], font=FONT_LABEL,
                width=110, height=34, corner_radius=5, anchor="center",
                fg_color=C_CANT_SEL if is_first else C_BTN,
                hover_color=C_CANT_SEL_HOV if is_first else C_BTN_HOV,
                text_color="white",
                command=lambda v=accio: self._set_accio(v))
            b.pack(side="left", padx=(6, 8), pady=6)
            self._accio_btns[accio] = b

            # Descripció (sempre visible, text curt)
            ctk.CTkLabel(row, text=self._DESC[accio],
                         font=FONT_HINT, text_color=C_TEXT2,
                         anchor="w", justify="left", wraplength=300
                         ).pack(side="left", fill="x", expand=True, padx=(0, 8))

            # Camp de paràmetre (només si l'acció en necessita)
            if accio in _param_cfg:
                var, placeholder, width, hint_key, lbl_text = _param_cfg[accio]

                param_fr = ctk.CTkFrame(row, fg_color="transparent")
                param_fr.pack(side="right", padx=(0, 8), pady=4)

                ctk.CTkLabel(param_fr, text=lbl_text, font=FONT_HINT,
                             text_color=C_TEXT2, anchor="e", width=90
                             ).pack(side="left", padx=(0, 4))

                entry_kw = dict(
                    textvariable=var, font=FONT_SMALL,
                    placeholder_text=placeholder,
                    fg_color=C_PANEL, border_color=C_BORDER,
                    text_color=C_TEXT, height=28,
                    state="normal" if is_first else "disabled",
                )
                if width:
                    entry_kw["width"] = width

                e = ctk.CTkEntry(param_fr, **entry_kw)
                e.pack(side="left")
                Tooltip(e, HINTS[hint_key])

                # Guardem referència a l'entry per poder enable/disable
                self._param_frames[accio] = (row, e)
            else:
                # DHFitxer — sense camp, guardem només la fila
                self._param_frames[accio] = (row, None)

        # Separador inferior de la card
        ctk.CTkFrame(ac, height=1, fg_color=C_BORDER).pack(fill="x", padx=10, pady=(6, 0))
        ctk.CTkFrame(ac, height=8, fg_color="transparent").pack()

        # ── Log i botó ───────────────────────────────────────────────────────
        self._log = LogArea(scroll)
        self._log.pack(fill="x", pady=(8, 8))

        self._btn = run_btn(scroll, "▶  FER CANVIS DE METADADES (EXIF) A TOTES LES IMATGES", self._run, hint=HINTS["btn_exif"])
        self._btn.pack(fill="x", pady=(0, 4))

    # ── Helpers UI ───────────────────────────────────────────────────────────

    def _browse_src(self):
        p = filedialog.askdirectory(initialdir=_last_browse_dir)
        if p:
            self._src.set(p)
            _update_last_dir(p)

    def _set_accio(self, val: str):
        prev = self._accio.get()
        if prev == val:
            return
        self._accio.set(val)

        C_ROW_ACT   = "#e8f0e8"
        C_ROW_INACT = "transparent"

        for accio, (row, entry) in self._param_frames.items():
            active = (accio == val)
            # Color de fila
            row.configure(fg_color=C_ROW_ACT if active else C_ROW_INACT)
            # Botó
            self._accio_btns[accio].configure(
                fg_color=C_CANT_SEL    if active else C_BTN,
                hover_color=C_CANT_SEL_HOV if active else C_BTN_HOV,
            )
            # Camp de paràmetre: activa/desactiva
            if entry is not None:
                entry.configure(state="normal" if active else "disabled")

    # ── Validació i execució ─────────────────────────────────────────────────

    def _validate(self) -> bool:
        src = self._src.get()
        if not src or not Path(src).is_dir():
            messagebox.showwarning("METADADES", "Selecciona una carpeta d'imatges vàlida.")
            return False
        accio = self._accio.get()
        if accio == "DH":
            try:
                float(self._dh_var.get())
            except ValueError:
                messagebox.showwarning("METADADES", "El camp «Hores» ha de ser un número (p.ex. 2 o -1.5).")
                return False
        elif accio == "DHSET":
            if not self._dhset_var.get().strip():
                messagebox.showwarning("METADADES", "Introdueix la data i hora en format YYYY-MM-DD HH:MM:SS.")
                return False
        return True

    def _run(self):
        if self._running or not self._validate():
            return
        self._running = True
        self._btn.configure(state="disabled", text="⏳  Processant…")
        self._log.clear()

        accio = self._accio.get()
        src   = self._src.get()
        param = ""
        if accio == "DH":
            param = self._dh_var.get().strip()
        elif accio == "DHSET":
            param = self._dhset_var.get().strip()
        elif accio == "CR":
            param = self._cr_var.get()
        elif accio == "Autor":
            param = self._autor_var.get()
        elif accio == "Titol":
            param = self._titol_var.get()

        self._log.append(f"▶  Acció: {accio}  |  Carpeta: {src}", "muted")
        if param:
            self._log.append(f"   Paràmetre: {param}", "muted")
        self._log.append("─" * 54, "muted")

        threading.Thread(target=self._worker, args=(src, accio, param), daemon=True).start()

    def _worker(self, src: str, accio: str, param: str):
        old_out, old_err = sys.stdout, sys.stderr
        sys.stdout = QueueWriter(self._q, "info")
        sys.stderr = QueueWriter(self._q, "error")
        try:
            import importlib.util
            script = resource_path("EditarEXIF.py")
            if not script.exists():
                self._q.put(("error", "ERROR: No es troba EditarEXIF.py al costat d'AfomontEines.py"))
                return

            spec = importlib.util.spec_from_file_location("editarexif", script)
            mod  = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)

            src_path = Path(src)
            files    = mod.get_jpg_files(src_path)
            self._q.put(("info", f"   {len(files)} imatge(s) trobades\n"))

            if accio == "DHFitxer":
                mod.accio_dhfitxer(files)
            elif accio == "DH":
                mod.accio_dh(files, float(param))
            elif accio == "DHSET":
                nova_dt = mod.parse_user_datetime(param)
                mod.accio_dhset(files, nova_dt)
            elif accio == "CR":
                mod.accio_cr(files, param)
            elif accio == "Autor":
                mod.accio_autor(files, param)
            elif accio == "Titol":
                mod.accio_titol(files, param)

            self._q.put(("muted", "─" * 54))
            self._q.put(("ok", f"✔  Acció '{accio}' completada sobre {len(files)} imatge(s)."))

        except SystemExit:
            pass   # get_jpg_files fa sys.exit(0) si no hi ha imatges
        except Exception as e:
            self._q.put(("error", f"ERROR inesperat: {e}"))
        finally:
            sys.stdout, sys.stderr = old_out, old_err
            self.after(0, self._done)

    def _done(self):
        self._running = False
        self._btn.configure(state="normal", text="▶  FER CANVIS DE METADADES (EXIF) A TOTES LES IMATGES")

    def _poll(self):
        try:
            while True:
                tag, text = self._q.get_nowait()
                self._log.append(text, tag)
        except queue.Empty:
            pass
        self.after(80, self._poll)


# ===========================================================================
# TAB 5 — RAW > JPG
# ===========================================================================
class TabReformat(ctk.CTkFrame):
    def __init__(self, parent):
        super().__init__(parent, fg_color=C_BG)
        self._running = False
        self._q: queue.Queue = queue.Queue()
        self._build()
        self._poll()

    def _build(self):
        scroll = ctk.CTkFrame(self, fg_color=C_BG)
        scroll.pack(fill="both", expand=True, padx=10, pady=8)

        # ── Carpetes ─────────────────────────────────────────────────────────
        fc = card(scroll, "Fitxers i carpetes")
        self._src = FileRow(fc, "Carpeta origen",
                            "RAW (ARW · NEF · CR2 · CR3 · ORF…) i JPG",
                            lambda: self._browse_dir(self._src),
                            btn_hint=HINTS["raw_src"])
        self._src.pack(fill="x", padx=10, pady=3)
        self._dst = FileRow(fc, "Carpeta destí",
                            "On es desaran els fitxers JPG convertits",
                            lambda: self._browse_dir(self._dst),
                            btn_hint=HINTS["raw_dst"])
        self._dst.pack(fill="x", padx=10, pady=(3, 10))

        # ── Paràmetres opcionals ──────────────────────────────────────────────
        oc = card(scroll, "Paràmetres opcionals")
        self._factor = OptSlider(oc,
            "Factor de reducció de resolució",
            "La resolució es divideix per aquest factor.  Factor 2 = meitat de mida.  Per defecte: sense reducció",
            2, 20, 2, lambda v: f"/ {int(v)}")
        self._factor.pack(fill="x", padx=10, pady=(0, 6))

        # Indicador exiftool — mateixa lògica de cerca que Reformat.py,
        # incloent bundle PyInstaller (_MEIPASS) i executable compilat (frozen)
        import os as _os
        import shutil as _shutil
        def _detectar_exiftool() -> bool:
            exe = "exiftool.exe" if sys.platform == "win32" else "exiftool"
            if hasattr(sys, "_MEIPASS"):
                c = _os.path.join(sys._MEIPASS, exe)
                if _os.path.isfile(c): return True
            if getattr(sys, "frozen", False):
                c = _os.path.join(_os.path.dirname(sys.executable), exe)
                if _os.path.isfile(c): return True
            if _shutil.which("exiftool"): return True
            for p in ["/usr/local/bin/exiftool", "/opt/homebrew/bin/exiftool",
                      "/opt/local/bin/exiftool", "/usr/bin/exiftool", "/usr/local/sbin/exiftool"]:
                if _os.path.isfile(p) and _os.access(p, _os.X_OK): return True
            return False
        et_ok = _detectar_exiftool()
        et_txt = "✓  exiftool detectat — detecció B&N activa per a tots els formats" if et_ok \
            else "⚠  exiftool no trobat — detecció B&N limitada (CR3 no suportat sense exiftool)"
        et_col = C_LOG_OK if et_ok else C_LOG_WARN
        ctk.CTkLabel(oc, text=et_txt, font=FONT_HINT,
                     text_color=et_col, anchor="w"
                     ).pack(fill="x", padx=14, pady=(0, 8))

        # ── Log i botó ────────────────────────────────────────────────────────
        self._log = LogArea(scroll)
        self._log.pack(fill="x", pady=(0, 8))

        self._btn = run_btn(scroll, "▶  CONVERTIR RAW A JPG", self._run, hint=HINTS["btn_raw"])
        self._btn.pack(fill="x", pady=(0, 4))

    def _browse_dir(self, w):
        p = filedialog.askdirectory(initialdir=_last_browse_dir)
        if p:
            w.set(p)
            _update_last_dir(p)

    def _validate(self):
        if not self._src.get() or not Path(self._src.get()).is_dir():
            messagebox.showwarning("RAW > JPG", "Selecciona la carpeta origen."); return False
        if not self._dst.get():
            messagebox.showwarning("RAW > JPG", "Selecciona la carpeta destí."); return False
        return True

    def _run(self):
        if self._running or not self._validate(): return
        self._running = True
        self._btn.configure(state="disabled", text="⏳  Convertint…")
        self._log.clear()
        src    = self._src.get()
        dst    = self._dst.get()
        factor = self._factor.value() if self._factor.active() else None
        self._log.append(f"▶  Origen: {src}", "muted")
        self._log.append(f"   Destí:  {dst}" +
                         (f"  |  Factor: /{factor}" if factor else ""), "muted")
        self._log.append("─" * 54, "muted")
        threading.Thread(target=self._worker, args=(src, dst, factor), daemon=True).start()

    def _worker(self, src: str, dst: str, factor):
        old_out, old_err = sys.stdout, sys.stderr
        sys.stdout = QueueWriter(self._q, "info")
        sys.stderr = QueueWriter(self._q, "error")
        try:
            import importlib.util
            script = resource_path("Reformat.py")
            if not script.exists():
                self._q.put(("error", "ERROR: No es troba Reformat.py al costat d'AfomontEines.py"))
                return

            spec = importlib.util.spec_from_file_location("reformat", script)
            mod  = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(mod)

            src_path = Path(src)
            dst_path = Path(dst)
            dst_path.mkdir(parents=True, exist_ok=True)

            fitxers = mod.trobar_fitxers(src_path)
            if not fitxers:
                self._q.put(("warn", "⚠  No s'han trobat fitxers RAW ni JPG a la carpeta origen."))
                return

            self._q.put(("info", f"   {len(fitxers)} fitxer(s) trobat(s)\n"))

            errors = []
            bn_count = 0
            for i, raw_path in enumerate(fitxers, start=1):
                rel      = raw_path.relative_to(src_path)
                jpg_path = dst_path / rel.with_suffix(".jpg")
                try:
                    _, bn = mod.convertir(raw_path, jpg_path, factor)
                    if bn:
                        bn_count += 1
                    etiqueta = "  [B&N]" if bn else ""
                    self._q.put(("ok", f"  ✓  {rel}{etiqueta}"))
                except Exception as e:
                    self._q.put(("error", f"  ✗  {rel}  ({e})"))
                    errors.append(raw_path.name)

            self._q.put(("muted", "─" * 54))
            resum = f"✔  Conversió completada: {len(fitxers) - len(errors)}/{len(fitxers)} fitxers convertits."
            if bn_count:
                resum += f"  ({bn_count} en B&N)"
            self._q.put(("ok", resum))
            if errors:
                self._q.put(("error", f"   Errors en: {', '.join(errors)}"))

        except Exception as e:
            self._q.put(("error", f"ERROR inesperat: {e}"))
        finally:
            sys.stdout, sys.stderr = old_out, old_err
            self.after(0, self._done)

    def _done(self):
        self._running = False
        self._btn.configure(state="normal", text="▶  CONVERTIR RAW A JPG")

    def _poll(self):
        try:
            while True:
                tag, text = self._q.get_nowait()
                self._log.append(text, tag)
        except queue.Empty:
            pass
        self.after(80, self._poll)


# ===========================================================================
# Finestra principal
# ===========================================================================
class AfomontApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("AFOMONT — Eines Fotogràfiques")
        self.minsize(740, 500)
        self.configure(fg_color=C_BG)
        self.resizable(True, True)

        # ── Centrat automàtic a la pantalla ──────────────────────────────
        w, h   = 740, 980
        sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
        h      = min(h, sh - 80)
        x      = (sw - w) // 2
        y      = (sh - h) // 2
        self.geometry(f"{w}x{h}+{x}+{y}")
        # ─────────────────────────────────────────────────────────────────

        self._build()

        # ── Icona alta resolució (Win32) — cal esperar que la finestra ────
        # ── estigui completament inicialitzada abans de cridar l'API    ────
        self.after(100, self._set_win_icon)

    def _set_win_icon(self):
        """Assigna la icona a alta resolució via API Win32.
        Es crida amb after(100) per garantir que la finestra té hwnd vàlid.
        Silenciosa al Mac/Linux (windll no existeix fora de Windows)."""
        try:
            import ctypes
            ico = resource_path("LogoAfomont.ico")
            if not ico.exists():
                return
            IMAGE_ICON      = 1
            LR_LOADFROMFILE = 0x00000010
            hicon = ctypes.windll.user32.LoadImageW(
                None, str(ico), IMAGE_ICON, 256, 256, LR_LOADFROMFILE)
            if hicon:
                WM_SETICON = 0x0080
                ICON_SMALL = 0
                ICON_BIG   = 1
                hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
                ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_BIG,   hicon)
                ctypes.windll.user32.SendMessageW(hwnd, WM_SETICON, ICON_SMALL, hicon)
        except Exception:
            pass

    def _build(self):
        # Capçalera i peu FIXOS (fora del scroll — sempre visibles)
        self._build_header()
        self._build_footer()
        # Scroll global — conté només els tabuladors
        self._scroll = ctk.CTkScrollableFrame(
            self, fg_color=C_BG,
            scrollbar_fg_color=C_BG,
            scrollbar_button_color=C_BORDER,
            scrollbar_button_hover_color=C_BTN_HOV,
        )
        self._scroll.pack(fill="both", expand=True)
        self._build_tabs(self._scroll)

    def _build_header(self):
        hdr = ctk.CTkFrame(self, fg_color=C_HEADER, corner_radius=0, height=80)
        hdr.pack(fill="x")
        hdr.pack_propagate(False)

        logo_img = None
        for candidate in [
            resource_path("LogoAfomont.png"),
            Path("LogoAfomont.png"),
        ]:
            if candidate.exists():
                try: logo_img = Image.open(candidate); break
                except Exception: pass

        if logo_img:
            lw, lh  = logo_img.size
            new_w   = int(lw * 104 / lh)   # doble: 104px d'alçada
            logo_rs = logo_img.resize((new_w, 104), Image.LANCZOS)
            logo_ctk = ctk.CTkImage(light_image=logo_rs, dark_image=logo_rs, size=(new_w, 104))
            ctk.CTkLabel(hdr, image=logo_ctk, text="").pack(side="left", padx=16, pady=8)
        else:
            ctk.CTkLabel(hdr, text="AFOMONT", font=("Helvetica", 24, "bold"),
                         text_color=C_TEXT).pack(side="left", padx=16, pady=8)

        lbl = ctk.CTkLabel(hdr, text="Eines Fotogràfiques",
                     font=("Helvetica", 36), text_color=C_TEXT2)
        lbl._label.configure(pady=6)   # espai intern real al widget tk
        lbl.pack(side="left", pady=8)

        ctk.CTkLabel(hdr, text="Versió "+VERSIO+"     ",
                     font=("Helvetica", 10), text_color=C_TEXT2
                     ).pack(side="right", pady=8)

    def _build_tabs(self, parent):
        tv = ctk.CTkTabview(
            parent,
            fg_color=C_BG,
            segmented_button_fg_color=C_TAB_UNSEL,
            segmented_button_selected_color=C_TAB_SEL,
            segmented_button_selected_hover_color=C_BTN_RUN_HOV,
            segmented_button_unselected_color=C_TAB_UNSEL,
            segmented_button_unselected_hover_color="#c8c8c8",
            text_color=C_TAB_UNSEL_TXT,
            text_color_disabled=C_TEXT2,
            border_color=C_BORDER,
            border_width=1,
        )
        tv.pack(fill="both", expand=True, padx=12, pady=(8, 4))

        for name in (TAB1, TAB2, TAB3, TAB4, TAB5):
            tv.add(name)

        # Font gran i colors de text als tabuladors via widget intern
        try:
            sb = tv._segmented_button
            sb.configure(
                fg_color=C_TAB_UNSEL,
                selected_color=C_TAB_SEL,
                selected_hover_color=C_BTN_RUN_HOV,
                unselected_color=C_TAB_UNSEL,
                unselected_hover_color="#c8c8c8",
                font=FONT_TAB,
                height=44,                          # altura doble dels tabuladors
            )
            # Forcem text dels botons interns: inactiu gris, actiu blanc
            for btn_name, btn_widget in sb._buttons_dict.items():
                btn_widget.configure(text_color=C_TAB_UNSEL_TXT, font=FONT_TAB, height=44)
            active = tv.get()
            if active in sb._buttons_dict:
                sb._buttons_dict[active].configure(text_color=C_TAB_SEL_TXT)
            # Interceptem canvi de tab per actualitzar colors de text
            original_set = tv.set
            def patched_set(name, *a, **kw):
                original_set(name, *a, **kw)
                for n, b in sb._buttons_dict.items():
                    b.configure(text_color=C_TAB_SEL_TXT if n == name else C_TAB_UNSEL_TXT)
            tv.set = patched_set
            # Clic directe als botons
            for btn_name, btn_widget in sb._buttons_dict.items():
                orig_cmd = btn_widget.cget("command") if hasattr(btn_widget, "cget") else None
                def make_click(n, orig):
                    def on_click():
                        if orig: orig()
                        for nn, bb in sb._buttons_dict.items():
                            bb.configure(text_color=C_TAB_SEL_TXT if nn == n else C_TAB_UNSEL_TXT)
                    return on_click
                try:
                    btn_widget.configure(command=make_click(btn_name, orig_cmd))
                except Exception:
                    pass
        except Exception:
            pass

        TabSignar(tv.tab(TAB1)).pack(fill="both", expand=True)
        TabFullContactes(tv.tab(TAB2)).pack(fill="both", expand=True)
        TabBaser(tv.tab(TAB3)).pack(fill="both", expand=True)
        TabExif(tv.tab(TAB4)).pack(fill="both", expand=True)
        TabReformat(tv.tab(TAB5)).pack(fill="both", expand=True)

    def _build_footer(self):
        foot = ctk.CTkFrame(self, fg_color=C_FOOTER, corner_radius=0, height=26)
        foot.pack(fill="x", side="bottom")
        foot.pack_propagate(False)
        ctk.CTkLabel(foot, text=PEU,
                     font=FONT_FOOTER, text_color="#999999").pack(expand=True)


# ---------------------------------------------------------------------------
if __name__ == "__main__":
    app = AfomontApp()
    app.mainloop()
