"""
TLB_ASTRO-OSC_Preprocessing.py
TLB Astro - Script Python (sirilpy) pour Siril 1.4+, avec interface
graphique (Tkinter) pour pretraiter et empiler une cible OSC unique,
capturee sur une ou plusieurs sessions (nuits). Une seule session
suffit pour un usage classique ; le formulaire permet d'en ajouter
d'autres si la cible a ete reprise sur plusieurs nuits.

Principe general :
  - chaque session a son propre dossier de lights (obligatoire) et,
    si besoin, son propre dark et/ou son propre flat (optionnels)
  - un bias et un dark "partages" peuvent etre definis une seule fois
    pour tout le projet ; une session ne les redefinit que si elle a
    vraiment besoin d'un dark/bias different (capteur non refroidi,
    changement de gain...). Regle appliquee : valeur de la session si
    renseignee, sinon valeur partagee, sinon aucune.
  - le flat reste TOUJOURS propre a chaque session (une caméra
    demontee/remontee entre deux nuits change son motif de poussiere)
  - chaque session est calibree separement (son propre dark/flat),
    puis toutes les sessions calibrees sont fusionnees (par lien, pas
    par copie) en une seule grande sequence pour un alignement et un
    empilement globaux
  - alignement en 2 passes (-2pass) : Siril choisit automatiquement la
    meilleure image de reference sur l'ensemble des sessions, ce qui
    reste pertinent meme a une seule session (choix automatique de la
    meilleure brute) et devient important des qu'il y en a plusieurs
    (qualite variable d'une nuit a l'autre)
  - empilement par lots : avec plusieurs sessions, un stack partiel
    par session (utile pour reperer une nuit de moins bonne qualite),
    avec sous-decoupage supplementaire uniquement si une session, a
    elle seule, depasse le seuil de securite (limite Windows du
    nombre de fichiers ouverts simultanement, 2048 au maximum absolu ;
    seuil par defaut 2000, modifiable dans l'interface) ; puis un
    empilement final consolide tous les stacks partiels en un seul
    resultat. Avec une seule session, le decoupage ne s'applique que
    si elle depasse ce seuil a elle seule.
  - drizzle (Bayer Drizzle) optionnel : si coche, l'interface demande
    la longueur focale et le seeing typique du site pour calculer une
    echelle de drizzle recommandee (modifiable).

Nom du fichier resultat, construit par le script (pas par les tokens
de path-parsing de Siril, qui ne seraient plus fiables une fois qu'on
empile des stacks de stacks) :
  - temps de pose unitaire identique sur toutes les sessions :
        result_<OBJECT>_<N>x<EXPTIME>s_<TEMPS_TOTAL>.fit
  - temps de pose different d'une session a l'autre :
        result_<OBJECT>_<N>poses_<M>sessions_<TEMPS_TOTAL>.fit
  <OBJECT> et <EXPTIME> sont lus directement dans l'entete FITS des
  brutes (mot-cle standard OBJECT / EXPTIME), pas dans un stack deja
  empile, pour ne pas dependre du sort de ces informations une fois
  fusionnees.

Un choix de capteurs courants (Player One, ZWO, QHY...) est proposé
dans l'interface, regroupes par capteur reel quand plusieurs cameras
partagent le meme (voir SENSOR_PROFILES) ; une entree "Autre" permet
de saisir directement une taille de pixel personnalisee.
"""

import os
import re
import sys
import glob
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

import sirilpy as s


# ============================================================
# Metadonnees du script
# ============================================================
SCRIPT_VERSION = "1.5.0"
SCRIPT_NAME = "TLB_ASTRO-OSC_Preprocessing"

# Logo TLB Astro, encode en PNG base64 (redimensionne, integre pour que le
# script reste autonome, sans fichier image separe a distribuer)
LOGO_PNG_B64 = (
    "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"
    "p1qtsHD+zLNaINFlZ3RC3HT23WiFKrniLDMEhMRMza9NeGJwAUeOcwFrXSlnvPCv8txy7OTQ9y1kJ0oPJTLZI7tk8Rw23rSG2TOn"
    "g+iQpkGdPnPs1Ojw6c8BbNu2zQGYLVu2hFKwsO+Wo0v2rFw+a9aEkEmpqCglCAvnTidLE5AI1733oKLuIkkVuZ3gpVUZayLYsw6U"
    "TlFMyFgo2b+pgd8EOIzYSUB0VIuEgNYK788q0yY84uz5ORvQJ3ieUH6eK//+uisv4ZffdwdpmqAE6lVDpzPC0OixI5/+TNeB9evP"
    "SHnCMYC/5557UuDoiaPDW4eGWxtmzOgtqdBIfWitcM7TKYrJXU5MBJU+QGEtSxZMJ000eeEmHzKEwLIlM+MC2p2o6xFK3c9UpYmU"
    "tVKpOQqxrpo9s4vuriojo60fSSDR211j+dLZvOGqZdxx61qUEqz1KG0Q5WT3zmP5n3/m2L2/+4nWSBna/CSS3rJliwVk194jD151"
    "fPG/mD17Wi9BBZUYcTbGW6WjUGlCQRabq440TRkZaXHX7dewdPF8Wq0WaWpQOnrXuksXYvMc633ZdY2FxxT2OSebe4KLXjTWbLHq"
    "kjn8q1/5Jzz82EtnW8rWR4G6ji2q2J52hEDUQfrArJldLFsyi0a9yqpL5jJ/3jQK7+i0c4J3mOBwgE/69bqx1le3Tnz497Segwjh"
    "pf3Hn1634/DutWsWXK9MhhKhUk1xNrZ9olVkkhbwHlKJVXWtlnDrTStx1pd9eAMERsab5IWdRM6h1A5OqXv3DleKPbWOKg0kEJTm"
    "9g2XccVls2i1LbhAxzq0KBqNKtoY2p2cVrNFURR0cof3HqU1tWpGkRd08pzBwVFqtZTgPONNS1e38QP9x9WBg6f+91ZwU6k7wkc/"
    "igLshjv7/2TPnsNWqzDZXMsqGdpotERRpmiDSVKMMbjgEaXwzjN4ZoShkSbDw02GhsYYHBzDWY+IKnWMIQqbXGzyTckC+hjLoig0"
    "xrs8bzHeakZ8FCSOTWmFLsUQlWpKtZJgjEHrKGaoVBIgMDrWotUp8EHIiyhK73QcIZjgrA/bdx4++qefO/wJzpIvF6o7yoEP/+Ff"
    "PfTJZ5469KdDpwdFG4KEyLYpKRWsOi5McATvMaoUjpsEbTLSpBR2y1meR8oq2rsQu56ip9a7a41OM5I0Q6sUJQkgeBcbfJ1OfC+8"
    "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"
    "ST/D/wfXpze7SSRmDAAAAABJRU5ErkJggg=="
)


# ============================================================
# Palette "TLB Astro" en mode sombre
# ============================================================
BG_DARK = "#0f1c33"      # fond general de la fenetre
BG_PANEL = "#152744"     # fond des lignes/panneaux
BG_HEADER = "#1a3a6c"    # bandeau d'en-tete (navy de la charte)
BG_ENTRY = "#1e2d47"     # fond des champs de saisie
ACCENT = "#c9971f"       # or (charte TLB Astro)
BORDER = "#2c5aa0"       # navy clair (charte)
TEXT_LIGHT = "#e8edf5"
TEXT_MUTED = "#93a4c2"


def apply_dark_theme(root):
    """Applique un theme sombre coherent avec la charte TLB Astro a tous
    les widgets ttk de la fenetre."""
    root.configure(bg=BG_DARK)
    style = ttk.Style(root)
    style.theme_use("clam")

    style.configure("TFrame", background=BG_DARK)
    style.configure("Panel.TFrame", background=BG_PANEL)
    style.configure("Header.TFrame", background=BG_HEADER)

    style.configure("TLabel", background=BG_DARK, foreground=TEXT_LIGHT)
    style.configure("Panel.TLabel", background=BG_PANEL, foreground=TEXT_LIGHT)
    style.configure("Header.TLabel", background=BG_HEADER, foreground=TEXT_LIGHT)
    style.configure("Muted.TLabel", background=BG_DARK, foreground=TEXT_MUTED)
    style.configure("Version.TLabel", background=BG_HEADER, foreground="#c7d3e8",
                     font=("", 8))
    style.configure("Accent.TLabel", background=BG_DARK, foreground=ACCENT,
                     font=("", 9, "bold"))

    style.configure("TEntry", fieldbackground=BG_ENTRY, foreground=TEXT_LIGHT,
                     insertcolor=TEXT_LIGHT, bordercolor=BORDER,
                     lightcolor=BG_ENTRY, darkcolor=BG_ENTRY)

    style.configure("TButton", background=BORDER, foreground=TEXT_LIGHT,
                     borderwidth=0, padding=6, focuscolor=BORDER)
    style.map("TButton", background=[("active", ACCENT)],
              foreground=[("active", "#1a1a1a")])

    style.configure("Accent.TButton", background=ACCENT, foreground="#1a1a1a",
                     borderwidth=0, padding=6)
    style.map("Accent.TButton", background=[("active", "#e0af2c")])

    style.configure("TScrollbar", background=BG_PANEL, troughcolor=BG_DARK,
                     bordercolor=BG_DARK, arrowcolor=TEXT_LIGHT)

    style.configure("TCheckbutton", background=BG_DARK, foreground=TEXT_LIGHT,
                     indicatorbackground=BG_ENTRY, indicatordiameter=13,
                     focuscolor=BG_DARK)
    style.map("TCheckbutton",
              indicatorbackground=[("selected", ACCENT), ("active", BORDER)],
              background=[("active", BG_DARK)],
              foreground=[("active", TEXT_LIGHT)])

    style.configure("TSeparator", background=BORDER)

    return style


def norm_path(path):
    """Normalise l'affichage d'un chemin en '/' uniquement, pour eviter le
    melange '/' et '\\' dans les champs de saisie (ex: chemins construits
    avec os.path.join() sous Windows, mixes avec des chemins renvoyes en
    '/' par les boites de dialogue)."""
    return path.replace("\\", "/") if path else path


def make_logo_image():
    """Cree une image Tkinter a partir du logo TLB Astro embarque."""
    try:
        return tk.PhotoImage(data=LOGO_PNG_B64)
    except Exception:
        return None


def show_about(parent):
    """Fenetre 'A propos' : logo, nom du script, version, courte description."""
    win = tk.Toplevel(parent)
    win.title("A propos")
    win.configure(bg=BG_DARK)
    win.resizable(False, False)
    win.transient(parent)
    win.grab_set()

    logo_img = make_logo_image()
    if logo_img is not None:
        win.logo_ref = logo_img  # eviter le garbage collection
        ttk.Label(win, image=logo_img, style="TLabel").pack(pady=(20, 8))

    ttk.Label(win, text="TLB Astro", font=("", 13, "bold")).pack()
    ttk.Label(win, text=f"{SCRIPT_NAME}.py — version {SCRIPT_VERSION}",
              style="Muted.TLabel").pack(pady=(0, 12))
    ttk.Label(
        win,
        text="Pretraitement et empilement d'une cible OSC unique,\n"
             "capturee sur une ou plusieurs sessions (nuits). Calibration\n"
             "adaptative bias/dark/flat, drizzle optionnel, pour Siril 1.4+.",
        justify="center",
    ).pack(padx=24, pady=(0, 16))
    ttk.Button(win, text="Fermer", style="Accent.TButton",
               command=win.destroy).pack(pady=(0, 18))


class FollowingPath:
    """Gere un champ de chemin qui se met a jour automatiquement en
    fonction du repertoire de travail (ex: biases/ ou darks/ a l'interieur
    de celui-ci), jusqu'a ce que l'utilisateur le modifie lui-meme (saisie
    manuelle ou bouton Parcourir) : le suivi automatique s'arrete alors
    pour ce champ, meme si le repertoire de travail change ensuite."""

    def __init__(self, var, workdir_var, subfolder):
        self.var = var
        self.workdir_var = workdir_var
        self.subfolder = subfolder
        self.auto = True
        self._updating = False
        workdir_var.trace_add("write", self._on_workdir_change)
        var.trace_add("write", self._on_var_change)
        self._on_workdir_change()

    def _on_workdir_change(self, *_):
        if self.auto:
            wd = self.workdir_var.get().strip()
            self._updating = True
            self.var.set(norm_path(os.path.join(wd, self.subfolder)) if wd else "")
            self._updating = False

    def _on_var_change(self, *_):
        if not self._updating:
            self.auto = False

    def browse(self):
        d = filedialog.askdirectory(title=f"Choisir le dossier {self.subfolder}")
        if d:
            self.auto = False
            self.var.set(norm_path(d))


def sanitize_name(name):
    """Nettoie un nom de panneau pour un usage sûr dans des chemins de fichiers."""
    name = name.strip().replace(" ", "_")
    name = re.sub(r"[^A-Za-z0-9_\-]", "", name)
    return name


def read_fits_header(path, max_blocks=40):
    """Lit les cartes d'en-tete FITS d'un fichier, sans dependance externe
    (pas besoin d'astropy). Un en-tete FITS est une suite de blocs de
    2880 octets, chacun compose de 36 cartes de 80 caracteres ASCII ;
    la lecture s'arrete a la carte "END" ou apres max_blocks blocs (pour
    ne pas lire tout le fichier si l'en-tete est corrompu/atypique).
    Renvoie un dict {mot-cle: valeur (str ou float)}."""
    header = {}
    with open(path, "rb") as f:
        for _ in range(max_blocks):
            block = f.read(2880)
            if len(block) < 2880:
                break
            for i in range(0, 2880, 80):
                card = block[i:i + 80].decode("ascii", errors="replace")
                key = card[0:8].strip()
                if key == "END":
                    return header
                if not key or key in ("COMMENT", "HISTORY"):
                    continue
                if len(card) >= 10 and card[8:10] == "= ":
                    value_part = card[10:].split("/")[0].strip()
                    if value_part.startswith("'"):
                        value = value_part.strip("'").strip()
                    else:
                        try:
                            value = float(value_part)
                        except ValueError:
                            value = value_part
                    header[key] = value
    return header


def read_session_object_exptime(light_dir):
    """Lit OBJECT et EXPTIME dans le premier fichier FITS trouve dans
    light_dir (on suppose un temps de pose uniforme au sein d'une meme
    session, ce qui est la pratique courante). Renvoie (objet, exptime)
    avec None pour ce qui n'a pas pu etre lu."""
    candidates = sorted(
        glob.glob(os.path.join(light_dir, "*.fit"))
        + glob.glob(os.path.join(light_dir, "*.fits"))
    )
    if not candidates:
        return None, None
    header = read_fits_header(candidates[0])
    obj = header.get("OBJECT")
    exptime = header.get("EXPTIME")
    if exptime is None:
        exptime = header.get("EXPOSURE")
    if isinstance(obj, str):
        obj = obj.strip()
    if isinstance(exptime, str):
        try:
            exptime = float(exptime)
        except ValueError:
            exptime = None
    return obj, exptime


def format_duration(total_seconds):
    """Formate une duree en secondes en une chaine lisible, ex: '15h00m',
    '45m30s', '38s'. N'affiche que les unites necessaires."""
    total_seconds = int(round(total_seconds))
    hours, rem = divmod(total_seconds, 3600)
    minutes, seconds = divmod(rem, 60)
    if hours:
        return f"{hours}h{minutes:02d}m" if seconds == 0 else f"{hours}h{minutes:02d}m{seconds:02d}s"
    if minutes:
        return f"{minutes}m{seconds:02d}s" if seconds else f"{minutes}m"
    return f"{seconds}s"


def count_fits_files(light_dir):
    """Compte les brutes (FITS) presentes dans un dossier de lights."""
    return len(
        glob.glob(os.path.join(light_dir, "*.fit"))
        + glob.glob(os.path.join(light_dir, "*.fits"))
    )


def link_or_copy(src, dst):
    """Cree un lien physique (hard link) vers src a l'emplacement dst,
    ce qui evite toute copie physique des donnees (fonctionne sans
    droits particuliers sous Windows, contrairement au lien symbolique,
    tant que src et dst sont sur le meme volume). Repli sur une copie
    uniquement si le lien physique echoue (ex: volumes differents)."""
    try:
        os.link(src, dst)
    except OSError:
        import shutil
        shutil.copy2(src, dst)


def compute_chunks(total, threshold):
    """Decoupe l'intervalle [1..total] (base 1, inclusif) en tranches
    contigues d'au plus 'threshold' elements. Renvoie une liste de
    tuples (start, end)."""
    chunks = []
    start = 1
    while start <= total:
        end = min(start + threshold - 1, total)
        chunks.append((start, end))
        start = end + 1
    return chunks

# ============================================================
# Profils capteur
# ------------------------------------------------------------
# Plusieurs capteurs geres, regroupes par capteur reel (le parametre
# qui compte pour le calcul du drizzle) meme quand plusieurs marques
# vendent des cameras autour du meme capteur Sony. Une entree
# "Autre" permet de saisir directement une taille de pixel si la
# camera de l'utilisateur n'est pas dans la liste.
# ============================================================
SENSOR_PROFILES = {
    "Player One Artemis-C Pro / ZWO ASI294MC Pro / QHY294C Pro (IMX294)": {
        "pixel_um": 4.63,
    },
    "Player One Uranus-C / ZWO ASI585MC Pro (IMX585)": {
        "pixel_um": 2.90,
    },
    "ZWO ASI183MC Pro / QHY183C (IMX183)": {
        "pixel_um": 2.40,
    },
    "Player One Neptune-C II / ZWO ASI662MC / iOptron iCam 464C (IMX464)": {
        "pixel_um": 2.90,
    },
    "ZWO ASI533MC Pro / Player One Saturn-C / QHY533C (IMX533)": {
        "pixel_um": 3.76,
    },
    "ZWO ASI2600MC Pro / QHY268C (IMX571)": {
        "pixel_um": 3.76,
    },
    "ZWO ASI6200MC Pro / QHY600C (IMX455)": {
        "pixel_um": 3.76,
    },
}
DEFAULT_SENSOR = "Player One Artemis-C Pro / ZWO ASI294MC Pro / QHY294C Pro (IMX294)"

# Entree speciale (n'est pas une clef de SENSOR_PROFILES) : selectionnee,
# elle fait apparaitre un champ de saisie libre pour la taille de pixel,
# pour toute camera absente de la liste ci-dessus.
CUSTOM_SENSOR_LABEL = "Autre (taille de pixel personnalisée)"


def compute_sampling_and_scale(pixel_um, focal_mm, seeing_arcsec):
    """Calcule l'echantillonnage (secondes d'arc/pixel), l'echantillonnage
    ideal (seeing/2, critere de Nyquist usuel) et l'echelle de drizzle
    recommandee (1.0 si deja bien echantillonne, jusqu'a 3.0 si tres
    sous-echantillonne). Leve ValueError si pixel_um, focal_mm ou
    seeing_arcsec ne sont pas des nombres strictement positifs."""
    if pixel_um <= 0 or focal_mm <= 0 or seeing_arcsec <= 0:
        raise ValueError("La taille de pixel, la longueur focale et le seeing "
                          "doivent être positifs.")
    sampling = 206.265 * pixel_um / focal_mm
    ideal_sampling = seeing_arcsec / 2.0
    ratio = sampling / ideal_sampling
    recommended_scale = round(min(max(ratio, 1.0), 3.0), 1)
    return sampling, ideal_sampling, recommended_scale


class OptionalDirField:
    """Un champ de dossier optionnel (biases / darks / flats) : Entree +
    Parcourir + Effacer, avec suivi automatique (FollowingPath) d'un
    dossier parent tant que l'utilisateur n'a rien saisi ou efface
    lui-meme. Un champ laisse vide desactive l'etape correspondante."""

    def __init__(self, parent, row, label, traitement_var, subfolder):
        ttk.Label(parent, text=label).grid(
            row=row, column=0, sticky="w", pady=(4, 0))
        self.var = tk.StringVar()
        ttk.Entry(parent, textvariable=self.var).grid(
            row=row, column=1, padx=6, pady=(4, 0), sticky="ew")
        self.follow = FollowingPath(self.var, traitement_var, subfolder)
        ttk.Button(parent, text="Parcourir…", command=self.follow.browse).grid(
            row=row, column=2, padx=2, pady=(4, 0))
        ttk.Button(parent, text="Effacer", command=self.clear).grid(
            row=row, column=3, padx=(2, 0), pady=(4, 0))

    def clear(self):
        # Efface le champ ; le trace de FollowingPath desactive alors
        # automatiquement le suivi (voir _on_var_change), donc le champ
        # reste vide meme si le repertoire de traitement change ensuite.
        self.var.set("")

    def get(self):
        return self.var.get().strip()


class SessionRow:
    """Une ligne de la liste des sessions (nuits) dans l'interface."""

    def __init__(self, parent, index, remove_callback):
        self.frame = ttk.Frame(parent, padding=(4, 6), style="Panel.TFrame")
        self.frame.pack(fill="x", expand=True, pady=2)
        self.remove_callback = remove_callback

        ttk.Label(self.frame, text="Nom :", style="Panel.TLabel").grid(
            row=0, column=0, sticky="w")
        self.name_var = tk.StringVar(value=f"session{index}")
        ttk.Entry(self.frame, textvariable=self.name_var, width=14).grid(
            row=0, column=1, padx=4)

        ttk.Label(self.frame, text="Dossier lights :", style="Panel.TLabel").grid(
            row=0, column=2, sticky="w")
        self.light_var = tk.StringVar(value="")
        ttk.Entry(self.frame, textvariable=self.light_var, width=32).grid(
            row=0, column=3, padx=4)
        ttk.Button(self.frame, text="Parcourir…", command=self.browse_light).grid(
            row=0, column=4, padx=2)
        ttk.Button(self.frame, text="Supprimer cette session",
                   command=self.remove).grid(row=0, column=5, padx=(10, 0))

        ttk.Label(self.frame, text="Bias (si different du partage) :",
                  style="Panel.TLabel").grid(row=1, column=0, columnspan=2,
                                              sticky="w", pady=(4, 0))
        self.bias_var = tk.StringVar(value="")
        ttk.Entry(self.frame, textvariable=self.bias_var, width=32).grid(
            row=1, column=3, padx=4, pady=(4, 0))
        ttk.Button(self.frame, text="Parcourir…", command=self.browse_bias).grid(
            row=1, column=4, padx=2, pady=(4, 0))
        ttk.Button(self.frame, text="Effacer", command=self.clear_bias).grid(
            row=1, column=5, padx=(10, 0), pady=(4, 0))

        ttk.Label(self.frame, text="Dark (si different du partage) :",
                  style="Panel.TLabel").grid(row=2, column=0, columnspan=2,
                                              sticky="w", pady=(4, 0))
        self.dark_var = tk.StringVar(value="")
        ttk.Entry(self.frame, textvariable=self.dark_var, width=32).grid(
            row=2, column=3, padx=4, pady=(4, 0))
        ttk.Button(self.frame, text="Parcourir…", command=self.browse_dark).grid(
            row=2, column=4, padx=2, pady=(4, 0))
        ttk.Button(self.frame, text="Effacer", command=self.clear_dark).grid(
            row=2, column=5, padx=(10, 0), pady=(4, 0))

        ttk.Label(self.frame, text="Flat (optionnel) :", style="Panel.TLabel").grid(
            row=3, column=0, columnspan=2, sticky="w", pady=(4, 0))
        self.flat_var = tk.StringVar(value="")
        ttk.Entry(self.frame, textvariable=self.flat_var, width=32).grid(
            row=3, column=3, padx=4, pady=(4, 0))
        ttk.Button(self.frame, text="Parcourir…", command=self.browse_flat).grid(
            row=3, column=4, padx=2, pady=(4, 0))
        ttk.Button(self.frame, text="Effacer", command=self.clear_flat).grid(
            row=3, column=5, padx=(10, 0), pady=(4, 0))

        ttk.Separator(self.frame, orient="horizontal").grid(
            row=4, column=0, columnspan=6, sticky="ew", pady=(6, 0))

    def browse_light(self):
        d = filedialog.askdirectory(title="Choisir le dossier des lights de cette session")
        if d:
            self.light_var.set(norm_path(d))
            if not self.name_var.get().strip():
                self.name_var.set(os.path.basename(os.path.normpath(d)))

    def browse_bias(self):
        d = filedialog.askdirectory(title="Choisir le dossier de bias pour cette session")
        if d:
            self.bias_var.set(norm_path(d))

    def browse_dark(self):
        d = filedialog.askdirectory(title="Choisir le dossier de dark pour cette session")
        if d:
            self.dark_var.set(norm_path(d))

    def browse_flat(self):
        d = filedialog.askdirectory(title="Choisir le dossier de flat pour cette session")
        if d:
            self.flat_var.set(norm_path(d))

    def clear_bias(self):
        self.bias_var.set("")

    def clear_dark(self):
        self.dark_var.set("")

    def clear_flat(self):
        self.flat_var.set("")

    def remove(self):
        self.frame.destroy()
        self.remove_callback(self)

    def get_values(self):
        return {
            "name": self.name_var.get().strip(),
            "light_dir": self.light_var.get().strip(),
            "bias_dir": self.bias_var.get().strip() or None,
            "dark_dir": self.dark_var.get().strip() or None,
            "flat_dir": self.flat_var.get().strip() or None,
        }


class DrizzleScaleTracker:
    """Calcule automatiquement l'echelle de drizzle recommandee a partir de
    la longueur focale et du seeing renseignes, tant que l'utilisateur n'a
    pas modifie lui-meme le champ d'echelle (meme principe que
    FollowingPath : l'auto-calcul s'arrete des que l'utilisateur intervient
    sur le champ resultat)."""

    def __init__(self, scale_var, focal_var, seeing_var, pixel_um, sampling_label_var):
        self.scale_var = scale_var
        self.focal_var = focal_var
        self.seeing_var = seeing_var
        self.pixel_um = pixel_um
        self.sampling_label_var = sampling_label_var
        self.auto = True
        self._updating = False
        focal_var.trace_add("write", self._recompute)
        seeing_var.trace_add("write", self._recompute)
        scale_var.trace_add("write", self._on_scale_change)
        self._recompute()

    def _recompute(self, *_):
        try:
            focal_mm = float(self.focal_var.get())
            seeing_arcsec = float(self.seeing_var.get())
            sampling, ideal, recommended = compute_sampling_and_scale(
                self.pixel_um, focal_mm, seeing_arcsec)
        except ValueError:
            self.sampling_label_var.set("Échantillonnage : (valeurs invalides)")
            return
        self.sampling_label_var.set(
            f"Échantillonnage : {sampling:.2f}\"/px — idéal (seeing/2) : {ideal:.2f}\"/px"
        )
        if self.auto:
            self._updating = True
            self.scale_var.set(f"{recommended}")
            self._updating = False

    def _on_scale_change(self, *_):
        if not self._updating:
            self.auto = False


class MultiSessionConfigGUI:
    def __init__(self, default_workdir=""):
        self.result = None
        self.session_rows = []

        self.root = tk.Tk()
        self.root.title(f"TLB Astro — Configuration pré-traitement OSC — v{SCRIPT_VERSION}")
        self.root.geometry("880x660")

        apply_dark_theme(self.root)

        # ---------- Bandeau d'en-tete ----------
        header = ttk.Frame(self.root, padding=(16, 12), style="Header.TFrame")
        header.pack(fill="x")

        logo_img = make_logo_image()
        if logo_img is not None:
            self.logo_ref = logo_img
            ttk.Label(header, image=logo_img, style="Header.TLabel").pack(side="left")

        title_box = ttk.Frame(header, style="Header.TFrame")
        title_box.pack(side="left", padx=12)
        ttk.Label(title_box, text="TLB Astro — Pré-traitement OSC",
                  style="Header.TLabel", font=("", 12, "bold")).pack(anchor="w")
        ttk.Label(title_box, text=f"{SCRIPT_NAME}.py — v{SCRIPT_VERSION}",
                  style="Version.TLabel").pack(anchor="w")

        ttk.Button(header, text="À propos", command=lambda: show_about(self.root)).pack(
            side="right")

        # ---------- Répertoire de traitement ----------
        top = ttk.Frame(self.root, padding=10)
        top.pack(fill="x")
        ttk.Label(top, text="Répertoire de traitement :", font=("", 10, "bold")).pack(
            side="left")
        self.traitement_var = tk.StringVar(value=default_workdir)
        ttk.Entry(top, textvariable=self.traitement_var, width=50).pack(side="left", padx=6)
        ttk.Button(top, text="Parcourir…", command=self.browse_traitement).pack(side="left")

        ttk.Label(
            self.root,
            text="Recevra le sous-dossier process/ et le fichier résultat final.",
            style="Muted.TLabel",
            wraplength=820,
        ).pack(anchor="w", padx=12)

        # ---------- Bias / Dark partages ----------
        shared = ttk.Frame(self.root, padding=(10, 10, 10, 4))
        shared.pack(fill="x")
        shared.columnconfigure(1, weight=1)

        self.shared_bias_field = OptionalDirField(
            shared, 0, "Bias partagé (optionnel) :", self.traitement_var, "biases")
        self.shared_dark_field = OptionalDirField(
            shared, 1, "Dark partagé (optionnel) :", self.traitement_var, "darks")

        ttk.Label(
            self.root,
            text="Utilisés pour toutes les sessions qui ne définissent pas leur propre "
                 "bias/dark ci-dessous (ex : capteur refroidi → un seul dark partagé pour "
                 "tout le projet). Une session avec son propre bias/dark ignore le partagé "
                 "pour ce seul réglage.",
            style="Muted.TLabel",
            wraplength=820,
        ).pack(anchor="w", padx=12, pady=(0, 4))

        # ---------- Sessions ----------
        mid_label = ttk.Frame(self.root, padding=(10, 10, 10, 0))
        mid_label.pack(fill="x")
        ttk.Label(mid_label, text="Sessions (une par défaut, ajoute-en si la cible a été "
                                   "reprise sur plusieurs nuits) :",
                  font=("", 10, "bold")).pack(side="left")
        ttk.Button(mid_label, text="+ Ajouter une session", command=self.add_session).pack(
            side="right")

        sessions_area = ttk.Frame(self.root)
        sessions_area.pack(fill="both", expand=True, padx=10, pady=6)

        canvas = tk.Canvas(sessions_area, borderwidth=0, highlightthickness=0, bg=BG_DARK)
        scrollbar = ttk.Scrollbar(sessions_area, orient="vertical", command=canvas.yview)
        self.sessions_frame = ttk.Frame(canvas)

        self.sessions_frame.bind(
            "<Configure>",
            lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
        )
        canvas.create_window((0, 0), window=self.sessions_frame, anchor="nw")
        canvas.configure(yscrollcommand=scrollbar.set)

        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")

        # Une seule session vide par defaut : le cas courant (une nuit) ne
        # doit pas obliger a supprimer une ligne inutile.
        self.add_session()

        # ---------- Options ----------
        options = ttk.Frame(self.root, padding=(10, 8, 10, 4))
        options.pack(fill="x")

        self.drizzle_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(
            options, text="Utiliser le drizzle (Bayer Drizzle)",
            variable=self.drizzle_var, command=self.toggle_drizzle_params,
        ).grid(row=0, column=0, sticky="w", columnspan=2)

        # --- Parametres drizzle, affiches uniquement si la case est cochee ---
        self.drizzle_params_frame = ttk.Frame(options, padding=(20, 6, 0, 4))
        self.drizzle_params_frame.grid(row=1, column=0, columnspan=2, sticky="w")

        ttk.Label(self.drizzle_params_frame, text="Longueur focale (mm) :").grid(
            row=0, column=0, sticky="w")
        self.focal_var = tk.StringVar(value="750")
        ttk.Entry(self.drizzle_params_frame, textvariable=self.focal_var, width=8).grid(
            row=0, column=1, sticky="w", padx=6)

        ttk.Label(self.drizzle_params_frame, text="Seeing typique du site (\", FWHM) :").grid(
            row=1, column=0, sticky="w", pady=(4, 0))
        self.seeing_var = tk.StringVar(value="2.5")
        ttk.Entry(self.drizzle_params_frame, textvariable=self.seeing_var, width=8).grid(
            row=1, column=1, sticky="w", padx=6, pady=(4, 0))

        self.sampling_label_var = tk.StringVar(value="")
        ttk.Label(self.drizzle_params_frame, textvariable=self.sampling_label_var,
                  style="Accent.TLabel").grid(row=2, column=0, columnspan=2, sticky="w",
                                               pady=(4, 0))

        ttk.Label(self.drizzle_params_frame,
                  text="Échelle à utiliser (calculée automatiquement, modifiable) :").grid(
            row=3, column=0, sticky="w", pady=(4, 0))
        self.scale_var = tk.StringVar(value="1.0")
        ttk.Entry(self.drizzle_params_frame, textvariable=self.scale_var, width=8).grid(
            row=3, column=1, sticky="w", padx=6, pady=(4, 0))

        pixel_um = SENSOR_PROFILES[DEFAULT_SENSOR]["pixel_um"]
        self.drizzle_tracker = DrizzleScaleTracker(
            self.scale_var, self.focal_var, self.seeing_var, pixel_um,
            self.sampling_label_var)

        if not self.drizzle_var.get():
            self.drizzle_params_frame.grid_remove()

        ttk.Label(options, text="Capteur :").grid(row=2, column=0, sticky="w", pady=(8, 0))
        self.sensor_var = tk.StringVar(value=DEFAULT_SENSOR)
        sensor_combo = ttk.Combobox(
            options, textvariable=self.sensor_var,
            values=list(SENSOR_PROFILES) + [CUSTOM_SENSOR_LABEL],
            state="readonly", width=52)
        sensor_combo.grid(row=2, column=1, sticky="w", padx=6, pady=(8, 0))

        # --- Taille de pixel personnalisee, affichee seulement pour "Autre" ---
        self.custom_sensor_frame = ttk.Frame(options, padding=(20, 4, 0, 0))
        self.custom_sensor_frame.grid(row=3, column=0, columnspan=2, sticky="w")
        ttk.Label(self.custom_sensor_frame, text="Taille de pixel (µm) :").grid(
            row=0, column=0, sticky="w")
        self.custom_pixel_var = tk.StringVar(value=f"{pixel_um}")
        ttk.Entry(self.custom_sensor_frame, textvariable=self.custom_pixel_var, width=8).grid(
            row=0, column=1, sticky="w", padx=6)
        self.custom_sensor_frame.grid_remove()

        self.sensor_var.trace_add("write", self.on_sensor_change)
        self.custom_pixel_var.trace_add("write", self.on_custom_pixel_change)

        ttk.Label(options, text="Seuil de découpage par lot :").grid(
            row=4, column=0, sticky="w", pady=(8, 0))
        self.threshold_var = tk.StringVar(value="2000")
        ttk.Entry(options, textvariable=self.threshold_var, width=8).grid(
            row=4, column=1, sticky="w", padx=6, pady=(8, 0))
        ttk.Label(
            options,
            text="(limite Windows du nombre de fichiers ouverts simultanément : 2048 au "
                 "maximum absolu — garder une marge de sécurité)",
            style="Muted.TLabel",
        ).grid(row=5, column=0, columnspan=2, sticky="w")

        # ---------- Boutons Annuler / Go ----------
        bottom = ttk.Frame(self.root, padding=10)
        bottom.pack(fill="x", side="bottom")
        ttk.Button(bottom, text="Go", style="Accent.TButton", command=self.on_go).pack(
            side="right", padx=4)
        ttk.Button(bottom, text="Annuler", command=self.on_cancel).pack(
            side="right", padx=4)

        self.root.protocol("WM_DELETE_WINDOW", self.on_cancel)

    def toggle_drizzle_params(self):
        if self.drizzle_var.get():
            self.drizzle_params_frame.grid()
        else:
            self.drizzle_params_frame.grid_remove()

    def on_sensor_change(self, *_):
        if self.sensor_var.get() == CUSTOM_SENSOR_LABEL:
            self.custom_sensor_frame.grid()
            self.on_custom_pixel_change()
        else:
            self.custom_sensor_frame.grid_remove()
            pixel_um = SENSOR_PROFILES[self.sensor_var.get()]["pixel_um"]
            self.drizzle_tracker.pixel_um = pixel_um
            self.drizzle_tracker._recompute()

    def on_custom_pixel_change(self, *_):
        if self.sensor_var.get() != CUSTOM_SENSOR_LABEL:
            return
        try:
            pixel_um = float(self.custom_pixel_var.get().strip())
        except ValueError:
            return
        self.drizzle_tracker.pixel_um = pixel_um
        self.drizzle_tracker._recompute()

    def browse_traitement(self):
        d = filedialog.askdirectory(title="Choisir le répertoire de traitement")
        if d:
            self.traitement_var.set(norm_path(d))

    def add_session(self):
        index = len(self.session_rows) + 1
        row = SessionRow(self.sessions_frame, index, self.remove_session)
        self.session_rows.append(row)

    def remove_session(self, row):
        if row in self.session_rows:
            self.session_rows.remove(row)

    def on_cancel(self):
        self.result = None
        self.root.destroy()

    def on_go(self):
        traitement_dir = self.traitement_var.get().strip()
        if not traitement_dir or not os.path.isdir(traitement_dir):
            messagebox.showerror("Erreur", "Répertoire de traitement invalide ou vide.")
            return

        shared_bias_dir = self.shared_bias_field.get()
        if shared_bias_dir and not os.path.isdir(shared_bias_dir):
            messagebox.showerror("Erreur", "Dossier de bias partagé invalide.")
            return
        shared_dark_dir = self.shared_dark_field.get()
        if shared_dark_dir and not os.path.isdir(shared_dark_dir):
            messagebox.showerror("Erreur", "Dossier de dark partagé invalide.")
            return

        try:
            threshold = int(self.threshold_var.get().strip())
            if threshold <= 0:
                raise ValueError
        except ValueError:
            messagebox.showerror(
                "Erreur", "Le seuil de découpage par lot doit être un entier positif.")
            return
        if threshold > 2048:
            messagebox.showerror(
                "Erreur", "Le seuil de découpage par lot doit rester ≤ 2048 "
                          "(limite absolue de Windows).")
            return

        drizzle = self.drizzle_var.get()
        focal_length = None
        seeing = None
        drizzle_scale = None
        if drizzle:
            try:
                focal_length = float(self.focal_var.get().strip())
                seeing = float(self.seeing_var.get().strip())
                drizzle_scale = float(self.scale_var.get().strip())
                if focal_length <= 0 or seeing <= 0 or drizzle_scale <= 0:
                    raise ValueError
            except ValueError:
                messagebox.showerror(
                    "Erreur", "Longueur focale, seeing et échelle de drizzle doivent être "
                              "des nombres positifs.")
                return
            if self.sensor_var.get() == CUSTOM_SENSOR_LABEL:
                try:
                    custom_pixel = float(self.custom_pixel_var.get().strip())
                    if custom_pixel <= 0:
                        raise ValueError
                except ValueError:
                    messagebox.showerror(
                        "Erreur", "La taille de pixel personnalisée doit être un nombre "
                                  "positif.")
                    return

        sessions = [r.get_values() for r in self.session_rows]
        if not sessions:
            messagebox.showerror("Erreur", "Ajoute au moins une session.")
            return

        seen_names = set()
        for sess in sessions:
            if not sess["light_dir"] or not os.path.isdir(sess["light_dir"]):
                messagebox.showerror(
                    "Erreur", f"Dossier de lights invalide pour la session "
                              f"« {sess['name'] or '(sans nom)'} ».")
                return
            for key, label in (("bias_dir", "bias"), ("dark_dir", "dark"), ("flat_dir", "flat")):
                if sess[key] and not os.path.isdir(sess[key]):
                    messagebox.showerror(
                        "Erreur", f"Dossier de {label} invalide pour la session "
                                  f"« {sess['name']} ».")
                    return
            name = sanitize_name(sess["name"]) or f"session{len(seen_names) + 1}"
            if name in seen_names:
                messagebox.showerror(
                    "Erreur", f"Nom de session en double : « {name} ». "
                              f"Donne un nom unique à chaque session.")
                return
            seen_names.add(name)
            sess["name"] = name

        self.result = {
            "traitement_dir": traitement_dir,
            "shared_bias_dir": shared_bias_dir or None,
            "shared_dark_dir": shared_dark_dir or None,
            "sessions": sessions,
            "drizzle": drizzle,
            "focal_length_mm": focal_length,
            "seeing_arcsec": seeing,
            "drizzle_scale": drizzle_scale,
            "sensor": self.sensor_var.get(),
            "threshold": threshold,
        }
        self.root.destroy()

    def run(self):
        self.root.mainloop()
        return self.result


# ============================================================
# Connexion a Siril (AVANT la fenetre, pour recuperer son
# repertoire de travail actuel et pre-remplir le champ)
# ============================================================
siril = s.SirilInterface()
try:
    siril.connect()
except Exception as e:
    print(f"Connexion a Siril impossible : {e}")
    raise SystemExit

try:
    default_workdir = siril.get_siril_wd()
except Exception:
    default_workdir = ""

# ============================================================
# Lancement de l'interface, pre-remplie avec le wd de Siril
# ============================================================
gui = MultiSessionConfigGUI(default_workdir=default_workdir)
config = gui.run()

if config is None:
    siril.log("Configuration annulee par l'utilisateur.")
    sys.exit(0)

TRAITEMENT_DIR = config["traitement_dir"]
SHARED_BIAS_DIR = config["shared_bias_dir"]
SHARED_DARK_DIR = config["shared_dark_dir"]
SESSIONS = config["sessions"]
USE_DRIZZLE = config["drizzle"]
SENSOR = config["sensor"]
THRESHOLD = config["threshold"]

siril.log(f"Connecte a Siril. ({SCRIPT_NAME}.py v{SCRIPT_VERSION})", s.LogColor.GREEN)
if USE_DRIZZLE:
    siril.log(f"Capteur : {SENSOR} | Drizzle : oui (focale {config['focal_length_mm']} mm, "
              f"seeing {config['seeing_arcsec']}\", echelle {config['drizzle_scale']}) | "
              f"Seuil de lot : {THRESHOLD}")
else:
    siril.log(f"Capteur : {SENSOR} | Drizzle : non | Seuil de lot : {THRESHOLD}")
siril.log(f"{len(SESSIONS)} session(s) configuree(s) : "
          + ", ".join(sess["name"] for sess in SESSIONS))


def run(*args):
    """Execute une commande Siril, log et arrete le script en cas d'echec."""
    try:
        siril.cmd(*args)
    except Exception as e:
        siril.log(f"ECHEC commande '{' '.join(args)}' : {e}", s.LogColor.RED)
        raise


def q(path):
    """Entoure un chemin de guillemets pour Siril et normalise les
    separateurs vers '/' (voir TLB_ASTRO-OSC_mosaique.py pour le detail
    du probleme des chemins sous Windows)."""
    normalized = str(path).replace("\\", "/")
    return f'"{normalized}"'


process_dir = os.path.join(TRAITEMENT_DIR, "process")
merge_staging_dir = os.path.join(process_dir, "merge_staging")
merged_dir = os.path.join(process_dir, "merged")
partials_dir = os.path.join(process_dir, "partial_stacks")
# Dossiers distincts pour la consolidation des stacks partiels : Siril
# doit lire une sequence dans un dossier different de celui ou il ecrit
# la sequence convertie (convertir un dossier sur lui-meme corrompt les
# fichiers, source et destination ne doivent jamais coincider).
partial_staging_dir = os.path.join(process_dir, "partial_staging")
partial_seq_dir = os.path.join(process_dir, "partial_seq")

# ============================================================
# Bias / Dark partages (construits une seule fois, utilises par
# toute session qui ne definit pas sa propre surcharge)
# ============================================================
shared_bias_master = None
if SHARED_BIAS_DIR:
    siril.log("--- Bias partage ---", s.LogColor.BLUE)
    shared_bias_master = os.path.join(process_dir, "bias_stacked_shared")
    run("cd", q(SHARED_BIAS_DIR))
    run("convert", "bias", q(f"-out={process_dir}"))
    run("cd", q(process_dir))
    run("stack", "bias", "rej", "3", "3", "-nonorm", q(f"-out={shared_bias_master}"))
else:
    siril.log("Pas de bias partage defini.")

shared_dark_master = None
if SHARED_DARK_DIR:
    siril.log("--- Dark partage ---", s.LogColor.BLUE)
    shared_dark_master = os.path.join(process_dir, "dark_stacked_shared")
    run("cd", q(SHARED_DARK_DIR))
    run("convert", "dark", q(f"-out={process_dir}"))
    run("cd", q(process_dir))
    run("stack", "dark", "rej", "3", "3", "-nonorm", q(f"-out={shared_dark_master}"))
else:
    siril.log("Pas de dark partage defini.")

# ============================================================
# Traitement par session : bias/dark propres si definis (sinon
# partage), flat propre, conversion + calibration des lights
# ============================================================
os.makedirs(merge_staging_dir, exist_ok=True)

global_index = 0
session_ranges = []   # liste ordonnee de (name, start, end) 1-based
session_counts = []   # nb de brutes calibrees par session, meme ordre
session_objects = []  # OBJECT lu par session (ou None)
session_exptimes = []  # EXPTIME lu par session (ou None)

for sess in SESSIONS:
    name = sess["name"]
    siril.log(f"=== Session : {name} ===", s.LogColor.BLUE)
    sess_process_dir = os.path.join(process_dir, name)

    # --- bias/dark propres a cette session, sinon partages ---
    bias_master = None
    if sess["bias_dir"]:
        siril.log(f"Bias specifique pour {name}.")
        bias_master = os.path.join(process_dir, f"bias_stacked_{name}")
        run("cd", q(sess["bias_dir"]))
        run("convert", "bias", q(f"-out={process_dir}"))
        run("cd", q(process_dir))
        run("stack", "bias", "rej", "3", "3", "-nonorm", q(f"-out={bias_master}"))
    else:
        bias_master = shared_bias_master

    dark_master = None
    if sess["dark_dir"]:
        siril.log(f"Dark specifique pour {name}.")
        dark_master = os.path.join(process_dir, f"dark_stacked_{name}")
        run("cd", q(sess["dark_dir"]))
        run("convert", "dark", q(f"-out={process_dir}"))
        run("cd", q(process_dir))
        run("stack", "dark", "rej", "3", "3", "-nonorm", q(f"-out={dark_master}"))
    else:
        dark_master = shared_dark_master

    # --- flat, toujours propre a la session ---
    flat_master = None
    if sess["flat_dir"]:
        siril.log(f"Flat specifique pour {name}.")
        flat_dir_process = os.path.join(process_dir, f"{name}_flat")
        flat_master = os.path.join(flat_dir_process, "flat_stacked")
        run("cd", q(sess["flat_dir"]))
        run("convert", "flat", q(f"-out={flat_dir_process}"))
        run("cd", q(flat_dir_process))
        calibrate_flat_args = ["calibrate", "flat"]
        if bias_master:
            calibrate_flat_args.append(q(f"-bias={bias_master}"))
        run(*calibrate_flat_args)
        run("stack", "pp_flat", "rej", "3", "3", "-norm=mul", q(f"-out={flat_master}"))

    # --- lecture OBJECT / EXPTIME dans les brutes d'origine (avant
    # calibration), pour construire le nom de fichier final ---
    obj, exptime = read_session_object_exptime(sess["light_dir"])
    session_objects.append(obj)
    session_exptimes.append(exptime)

    # --- conversion + calibration des lights de cette session ---
    run("cd", q(sess["light_dir"]))
    run("convert", "light", q(f"-out={sess_process_dir}"))
    run("cd", q(sess_process_dir))

    calibrate_args = ["calibrate", "light"]
    if bias_master:
        calibrate_args.append(q(f"-bias={bias_master}"))
    if dark_master:
        calibrate_args.append(q(f"-dark={dark_master}"))
        calibrate_args += ["-cc=dark", "-cfa"]
        if bias_master:
            calibrate_args.append("-opt")
    if flat_master:
        calibrate_args.append(q(f"-flat={flat_master}"))
        calibrate_args.append("-equalize_cfa")
    if not USE_DRIZZLE:
        calibrate_args.append("-debayer")
    run(*calibrate_args)

    # --- fusion : lien physique de chaque brute calibree vers le
    # dossier de fusion commun, avec un index global sequentiel qui
    # garantit l'ordre des sessions et permet de retrouver plus tard
    # les plages d'indices correspondant a chacune ---
    calibrated_files = sorted(glob.glob(os.path.join(sess_process_dir, "pp_light_*.fit")))
    start_index = global_index + 1
    for f in calibrated_files:
        global_index += 1
        link_or_copy(f, os.path.join(merge_staging_dir, f"{global_index:06d}.fit"))
    end_index = global_index
    session_ranges.append((name, start_index, end_index))
    session_counts.append(end_index - start_index + 1)

    siril.log(f"Session {name} : {end_index - start_index + 1} brute(s) calibree(s) "
              f"et fusionnee(s).")

total_lights = global_index
if total_lights == 0:
    siril.log("Aucune brute calibree n'a ete trouvee, arret.", s.LogColor.RED)
    sys.exit(1)

# ============================================================
# Fusion en une sequence unique, alignement global (-2pass)
# ============================================================
siril.log("--- Fusion et alignement global ---", s.LogColor.BLUE)
run("cd", q(merge_staging_dir))
run("convert", "merged", q(f"-out={merged_dir}"))
run("cd", q(merged_dir))

# -2pass : Siril choisit automatiquement la meilleure image de
# reference sur l'ensemble des sessions (plus pertinent ici que sur
# une seule nuit, la qualite pouvant varier fortement d'une session a
# l'autre). -2pass ne fait que calculer les transformations : il faut
# ensuite seqapplyreg pour produire la sequence enregistree.
run("register", "merged", "-2pass")

seqapplyreg_args = ["seqapplyreg", "merged"]
if USE_DRIZZLE:
    scale = config["drizzle_scale"]
    seqapplyreg_args += ["-drizzle", f"-scale={scale}", "-pixfrac=1.0", "-kernel=square"]
run(*seqapplyreg_args)

# ============================================================
# L'alignement peut exclure certaines brutes (echec de detection
# d'etoiles, qualite insuffisante...) : la sequence enregistree peut
# donc etre plus courte que le nombre de brutes fusionnees. Utiliser
# l'ancien total (total_lights) pour les select/unselect ferait
# depasser le nombre reel d'images de la sequence et provoquerait une
# erreur Siril. On recale donc sur le nombre reellement enregistre.
# ============================================================
actual_registered_count = len(glob.glob(os.path.join(merged_dir, "r_merged_*.fit")))
if actual_registered_count == 0:
    siril.log("Aucune image enregistree n'a ete produite par l'alignement, arret.",
              s.LogColor.RED)
    sys.exit(1)

registration_mismatch = actual_registered_count != total_lights
if registration_mismatch:
    siril.log(
        f"Attention : {total_lights - actual_registered_count} brute(s) ont ete exclues "
        f"pendant l'alignement (echec de detection d'etoiles ou qualite insuffisante). "
        f"Sequence enregistree : {actual_registered_count} image(s) sur {total_lights} "
        f"fusionnee(s) au depart.",
        s.LogColor.RED,
    )
    if len(SESSIONS) > 1:
        siril.log(
            "Plusieurs sessions sont configurees : la repartition par session des stacks "
            "partiels ne peut plus etre garantie exactement apres cette exclusion. Un "
            "decoupage simple (sans distinction de session) sera utilise a la place pour "
            "l'empilement.",
            s.LogColor.RED,
        )
    total_lights = actual_registered_count

# ============================================================
# Empilement par lots
# ------------------------------------------------------------
# - une seule session : comportement simple, un seul stack sauf si le
#   nombre de brutes depasse le seuil (auquel cas decoupage + stack de
#   consolidation, comme pour le script mono-session)
# - plusieurs sessions, sans exclusion pendant l'alignement : toujours
#   un stack partiel par session (utile pour reperer une nuit de moins
#   bonne qualite), avec sous-decoupage supplementaire uniquement si
#   une session depasse a elle seule le seuil ; puis un stack de
#   consolidation
# - plusieurs sessions, AVEC exclusion pendant l'alignement : repli
#   sur un decoupage simple (voir avertissement ci-dessus)
# ============================================================
if len(SESSIONS) == 1 or registration_mismatch:
    chunks = compute_chunks(total_lights, THRESHOLD) if total_lights > THRESHOLD \
        else [(1, total_lights)]
else:
    chunks = []
    for (name, start, end) in session_ranges:
        count = end - start + 1
        for (s_rel, e_rel) in compute_chunks(count, THRESHOLD):
            chunks.append((start + s_rel - 1, start + e_rel - 1))


# ============================================================
# Nom du fichier final, construit a partir des informations lues
# dans les entetes FITS d'origine (pas de token Siril dynamique, qui
# ne serait plus fiable une fois des stacks partiels combines entre
# eux)
# ============================================================
distinct_objects = {o for o in session_objects if o}
reference_object = next((o for o in session_objects if o), None)
if len(distinct_objects) > 1:
    siril.log(
        f"Attention : OBJECT differe selon les sessions ({', '.join(distinct_objects)}). "
        f"Le nom de fichier utilisera : {reference_object}",
        s.LogColor.RED,
    )
sanitized_object = sanitize_name(reference_object) if reference_object else "cible"

known_exptimes = [e for e in session_exptimes if e is not None]
uniform_exptime = (
    len(known_exptimes) == len(SESSIONS)
    and len(known_exptimes) > 0
    and (max(known_exptimes) - min(known_exptimes)) < 0.01
)
if len(known_exptimes) < len(SESSIONS):
    siril.log(
        "Attention : temps de pose illisible pour au moins une session ; le temps total "
        "sera sous-estime pour cette/ces session(s) et le nom de fichier basculera sur "
        "le format <poses>/<sessions>.",
        s.LogColor.RED,
    )

total_duration = sum(
    c * e for c, e in zip(session_counts, session_exptimes) if e is not None
)

if uniform_exptime:
    exptime_int = int(round(known_exptimes[0]))
    name_part = f"{total_lights}x{exptime_int}s"
else:
    name_part = f"{total_lights}poses_{len(SESSIONS)}sessions"

result_filename = f"result_{sanitized_object}_{name_part}_{format_duration(total_duration)}"
final_result_path = os.path.join(TRAITEMENT_DIR, result_filename)

# ============================================================
# Execution de l'empilement (direct ou par lots + consolidation)
# ============================================================
if len(chunks) == 1:
    siril.log("--- Empilement (lot unique) ---", s.LogColor.BLUE)
    run("stack", "r_merged", "rej", "3", "3", "-norm=addscale", "-output_norm",
        "-rgb_equal", q(f"-out={final_result_path}"))
else:
    siril.log(f"--- Empilement par lots ({len(chunks)} lot(s)) ---", s.LogColor.BLUE)
    os.makedirs(partials_dir, exist_ok=True)
    for i, (start, end) in enumerate(chunks, start=1):
        run("unselect", "r_merged", "1", str(total_lights))
        run("select", "r_merged", str(start), str(end))
        partial_path = os.path.join(partials_dir, f"partial_{i:05d}")
        siril.log(f"Lot {i}/{len(chunks)} : images {start} a {end} ({end - start + 1}).")
        run("stack", "r_merged", "rej", "3", "3", "-norm=addscale", "-output_norm",
            "-rgb_equal", q(f"-out={partial_path}"))
    run("select", "r_merged", "1", str(total_lights))

    siril.log("--- Consolidation des stacks partiels ---", s.LogColor.BLUE)
    os.makedirs(partial_staging_dir, exist_ok=True)
    partial_files = sorted(glob.glob(os.path.join(partials_dir, "partial_*.fit")))
    for i, f in enumerate(partial_files, start=1):
        link_or_copy(f, os.path.join(partial_staging_dir, f"{i:05d}.fit"))
    run("cd", q(partial_staging_dir))
    run("convert", "partialseq", q(f"-out={partial_seq_dir}"))
    run("cd", q(partial_seq_dir))
    run("stack", "partialseq", "rej", "3", "3", "-norm=addscale", "-output_norm",
        "-rgb_equal", q(f"-out={final_result_path}"))

run("cd", q(TRAITEMENT_DIR))

siril.log(
    f"Pretraitement termine : {total_lights} brute(s) sur "
    f"{len(SESSIONS)} session(s). Fichier resultat : "
    f"{os.path.basename(final_result_path)}.fit",
    s.LogColor.GREEN,
)
