# -*- coding: utf-8 -*-

import os
import sys
import subprocess
import numpy as np

from osgeo import gdal, ogr

from qgis.PyQt import uic
from qgis.PyQt.QtGui import QIcon, QFont, QPixmap, QPainter, QColor, QPen, QLinearGradient
from qgis.PyQt.QtCore import QRect, Qt
from qgis.PyQt.QtWidgets import QAction, QDialog, QFileDialog, QApplication, QPushButton, QMessageBox

from qgis import processing
from qgis.core import (
    QgsProject, QgsRasterLayer, QgsVectorLayer, QgsPalettedRasterRenderer, QgsRasterTransparency,
    QgsRasterShader, QgsColorRampShader, QgsSingleBandPseudoColorRenderer,
    QgsCoordinateTransform, QgsPrintLayout, QgsLayoutItemMap, QgsLayoutItemLabel,
    QgsLayoutItemLegend, QgsLayoutItemScaleBar, QgsLayoutItemPicture,
    QgsLayoutPoint, QgsLayoutSize, QgsLayoutExporter, QgsUnitTypes,
    QgsLayoutItemMapGrid, QgsRectangle,
)

FORM_CLASS, _ = uic.loadUiType(os.path.join(os.path.dirname(__file__), "mapic_dialog_base.ui"))


class MapicDialog(QDialog, FORM_CLASS):
    def __init__(self):
        super().__init__()
        self.setupUi(self)
        self._mapic_owner = None

    def resizeEvent(self, event):
        # Évite le glitch visuel pendant le redimensionnement.
        super().resizeEvent(event)


class Mapic:
    def __init__(self, iface):
        self.iface = iface
        self.plugin_dir = os.path.dirname(__file__)
        self.dlg = None
        self.action = None

    def initGui(self):
        icon_path = self.resolve_asset_path("icon.png") or self.resolve_asset_path("images", "icon.png") or ""
        self.action = QAction(QIcon(icon_path), "MapIC - Calcul des indices Sentinel-2", self.iface.mainWindow())
        self.action.triggered.connect(self.run)
        self.iface.addToolBarIcon(self.action)
        self.iface.addPluginToMenu("&MapIC", self.action)

    def unload(self):
        if self.action:
            self.iface.removePluginMenu("&MapIC", self.action)
            self.iface.removeToolBarIcon(self.action)

    def run(self):
        self.dlg = MapicDialog()
        self.dlg._mapic_owner = self
        self.dlg.resize(980, 445)
        self.dlg.setMinimumSize(900, 410)

        self.dlg.btn_get_started.clicked.connect(self.start_plugin)
        self.dlg.btn_input.clicked.connect(self.select_input)
        self.dlg.btn_output.clicked.connect(self.select_output)
        self.dlg.btn_samples.clicked.connect(self.select_samples)
        self.dlg.btn_run.clicked.connect(self.run_indices)
        self.dlg.btn_classify.clicked.connect(self.run_classification)
        self.dlg.btn_export_layout.clicked.connect(self.export_layout)
        if hasattr(self.dlg, 'btn_preview_layout'):
            self.dlg.btn_preview_layout.clicked.connect(self.preview_layout)
        else:
            try:
                self.dlg.btn_preview_layout = QPushButton("Aperçu de la carte")
                self.dlg.btn_preview_layout.setObjectName("btn_preview_layout")
                self.dlg.btn_preview_layout.clicked.connect(self.preview_layout)
                self.dlg.verticalLayout_layout.addWidget(self.dlg.btn_preview_layout)
            except Exception:
                pass
        self.dlg.btn_refresh_layers.clicked.connect(self.populate_layout_layers)
        self.dlg.btn_clear.clicked.connect(self.clear_log)
        self.dlg.btn_close.clicked.connect(self.dlg.close)

        self.dlg.progressBar.setValue(0)
        self.dlg.label_header.hide()
        self.dlg.progressBar.hide()
        self.dlg.btn_clear.hide()
        self.dlg.btn_close.hide()
        self.dlg.tabWidget.tabBar().hide()
        self.dlg.tabWidget.setCurrentIndex(0)

        self.apply_responsive_layout()
        self.dlg.show()
        self.center_dialog_on_qgis()
        QApplication.processEvents()
        self.update_welcome_image()


    def resolve_asset_path(self, *parts):
        """Retourne un chemin absolu fiable vers un fichier du plugin."""
        candidates = [
            os.path.join(self.plugin_dir, *parts),
            os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts),
        ]

        if parts and parts[0] != "images":
            candidates.append(os.path.join(self.plugin_dir, "images", *parts))

        for p in candidates:
            if p and os.path.exists(p):
                return os.path.normpath(p)
        return None


    # -------------------- VISUELS --------------------
    def get_header_path(self):
        """Recherche la bannière MapIC avec les noms réellement utilisés."""
        names = [
            ("images", "casablanca_banner.jpg"),
            ("images", "casablanca_banner.png"),
            ("images", "header_banner.png"),
            ("casablanca_banner.jpg",),
            ("casablanca_banner.png",),
            ("header_banner.png",),
        ]
        for parts in names:
            p = self.resolve_asset_path(*parts)
            if p:
                return p
        return None


    def get_logo_path(self):
        """Recherche le logo MapIC avec les noms réellement utilisés."""
        names = [
            ("images", "mapic_logo.png"),
            ("images", "icon.png"),
            ("mapic_logo.png",),
            ("icon.png",),
        ]
        for parts in names:
            p = self.resolve_asset_path(*parts)
            if p:
                return p
        return None


    def draw_visual_banner(self, label, mode="header"):
        """Bannière MapIC : image pleine largeur, icône complète, dégradé bleu restauré."""
        w = max(label.width(), 760)
        h = max(label.height(), 112 if mode == "header" else 230)

        header_path = self.get_header_path()
        logo_path = self.get_logo_path()

        canvas = QPixmap(w, h)
        canvas.fill(QColor("#16364a"))

        painter = QPainter(canvas)
        painter.setRenderHint(QPainter.Antialiasing)
        painter.setRenderHint(QPainter.SmoothPixmapTransform)

        if header_path:
            banner = QPixmap(header_path)
            if not banner.isNull():
                scaled = banner.scaled(w, h, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
                x_crop = max(int((scaled.width() - w) / 2), 0)
                y_crop = max(int((scaled.height() - h) / 2), 0)
                painter.drawPixmap(0, 0, scaled.copy(x_crop, y_crop, w, h))
        else:
            painter.fillRect(QRect(0, 0, w, h), QColor("#16364a"))

        if mode == "welcome":
            band_h = int(h * 0.42)
            logo_size = 82
            logo_x = 40
            title_font = 22
            sub_font = 12
            title = "MapIC - Mapping of Intelligent Cartography"
            subtitle = "Calcul des indices Sentinel-2"
        else:
            band_h = int(h * 0.50)
            logo_size = 58
            logo_x = 28
            title_font = 21
            sub_font = 9
            title = "MapIC"
            subtitle = "Mapping of Intelligent Cartography"

        band_y = h - band_h

        # Bande bleue basse + léger angle pour l'effet professionnel.
        try:
            from qgis.PyQt.QtGui import QPolygon
            from qgis.PyQt.QtCore import QPoint
            poly = QPolygon([QPoint(0, band_y), QPoint(int(w * 0.70), band_y), QPoint(int(w * 0.77), h), QPoint(0, h)])
            painter.setPen(Qt.NoPen)
            painter.setBrush(QColor(13, 55, 76, 175))
            painter.drawPolygon(poly)
        except Exception:
            painter.fillRect(QRect(0, band_y, int(w * 0.75), band_h), QColor(13, 55, 76, 175))

        grad_width = int(w * 0.58)
        gradient = QLinearGradient(0, 0, grad_width, 0)
        gradient.setColorAt(0.00, QColor(13, 55, 76, 155))
        gradient.setColorAt(0.68, QColor(13, 55, 76, 85))
        gradient.setColorAt(1.00, QColor(13, 55, 76, 0))
        painter.fillRect(QRect(0, 0, grad_width, h), gradient)

        logo_y = band_y + max(4, int((band_h - logo_size) / 2))
        if logo_path:
            logo = QPixmap(logo_path)
            if not logo.isNull():
                painter.drawPixmap(logo_x, logo_y, logo.scaled(logo_size, logo_size, Qt.KeepAspectRatio, Qt.SmoothTransformation))

        sep_x = logo_x + logo_size + 18
        painter.setPen(QPen(QColor(255, 255, 255, 160), 1.4))
        painter.drawLine(sep_x, band_y + 8, sep_x, h - 8)

        text_x = sep_x + 18
        painter.setPen(QColor("#ffffff"))
        painter.setFont(QFont("Segoe UI", title_font, QFont.Bold))
        painter.drawText(text_x, band_y + int(band_h * 0.42), title)

        painter.setPen(QColor("#e8f2f7"))
        painter.setFont(QFont("Segoe UI", sub_font))
        painter.drawText(text_x, band_y + int(band_h * 0.72), subtitle)

        painter.end()
        label.setPixmap(canvas)


    def update_header(self):
        if self.dlg and hasattr(self.dlg, "label_header"):
            self.draw_visual_banner(self.dlg.label_header, mode="header")

    def update_welcome_image(self):
        if self.dlg and hasattr(self.dlg, "label_welcome_image"):
            self.draw_visual_banner(self.dlg.label_welcome_image, mode="welcome")

    def start_plugin(self):
        try:
            if self.dlg.tabWidget.indexOf(self.dlg.tab_welcome) != -1:
                self.dlg.tabWidget.removeTab(self.dlg.tabWidget.indexOf(self.dlg.tab_welcome))
            self.dlg.tabWidget.tabBar().show()
            self.dlg.tabWidget.setCurrentIndex(0)
        except Exception:
            pass

        self.dlg.label_header.show()
        self.dlg.progressBar.show()
        self.dlg.btn_clear.show()
        self.dlg.btn_close.show()

        self.apply_responsive_layout()
        self.center_dialog_on_qgis()
        QApplication.processEvents()
        self.update_header()
        self.populate_layout_layers()



    def center_dialog_on_qgis(self):
        """Centre la fenêtre du plugin sur la fenêtre principale QGIS."""
        try:
            parent_geo = self.iface.mainWindow().geometry()
            dlg_geo = self.dlg.frameGeometry()
            dlg_geo.moveCenter(parent_geo.center())
            self.dlg.move(dlg_geo.topLeft())
        except Exception:
            pass


    def apply_responsive_layout(self):
        """Accueil stable, interface interne centrée et non masquée par la barre des tâches."""
        if not self.dlg:
            return
        try:
            if hasattr(self.dlg, "tab_welcome") and self.dlg.tabWidget.indexOf(self.dlg.tab_welcome) != -1 and self.dlg.tabWidget.currentWidget() == self.dlg.tab_welcome:
                self.dlg.setMinimumSize(900, 410)
                self.dlg.resize(980, 445)
                if hasattr(self.dlg, "label_welcome_image"):
                    self.dlg.label_welcome_image.setMinimumHeight(225)
                    self.dlg.label_welcome_image.setMaximumHeight(235)
            else:
                self.dlg.setMinimumSize(930, 535)
                self.dlg.resize(1020, 570)
                if hasattr(self.dlg, "label_header"):
                    self.dlg.label_header.setMinimumHeight(112)
                    self.dlg.label_header.setMaximumHeight(122)

            if hasattr(self.dlg, "horizontalLayout_param_and_log"):
                self.dlg.horizontalLayout_param_and_log.setStretch(0, 3)
                self.dlg.horizontalLayout_param_and_log.setStretch(1, 2)
        except Exception:
            pass


    def log(self, msg):
        self.dlg.log.appendPlainText(str(msg))
        self.dlg.log.verticalScrollBar().setValue(self.dlg.log.verticalScrollBar().maximum())
        QApplication.processEvents()

    def clear_log(self):
        self.dlg.log.clear()
        self.dlg.progressBar.setValue(0)

    def set_progress(self, value):
        self.dlg.progressBar.setValue(value)
        QApplication.processEvents()

    def select_input(self):
        folder = QFileDialog.getExistingDirectory(self.dlg, "Choisir le dossier contenant les bandes Sentinel")
        if folder:
            self.dlg.line_input.setText(folder)
            self.log("Input : " + folder)

    def select_output(self):
        folder = QFileDialog.getExistingDirectory(self.dlg, "Choisir le dossier de sortie")
        if folder:
            self.dlg.line_output.setText(folder)
            self.log("Output : " + folder)

    def select_samples(self):
        path, _ = QFileDialog.getOpenFileName(self.dlg, "Choisir la couche d'échantillons", "", "Shapefile (*.shp);;GeoPackage (*.gpkg);;Tous les fichiers (*.*)")
        if path:
            self.dlg.line_samples.setText(path)
            self.log("Samples : " + path)

    # -------------------- RASTERS --------------------
    def find_band_file(self, folder, aliases):
        exts = (".tif", ".tiff", ".jp2", ".img", ".vrt")
        found = []
        for root, dirs, files in os.walk(folder):
            for fn in files:
                low = fn.lower()
                if not low.endswith(exts) or low.endswith(".aux.xml"):
                    continue
                if any(a.lower() in low for a in aliases):
                    found.append(os.path.join(root, fn))
        if not found:
            return None
        for f in found:
            if "10m" in os.path.basename(f).lower():
                return f
        return found[0]

    def apply_index_style(self, layer, name):
        """Symbologie robuste : NoData transparent, légende claire et contraste adapté."""
        try:
            provider = layer.dataProvider()
            try:
                provider.setNoDataValue(1, -9999.0)
            except Exception:
                pass

            # Statistiques réelles avec GDAL pour éviter d'intégrer NoData.
            ds = gdal.Open(layer.source())
            arr = ds.GetRasterBand(1).ReadAsArray().astype(np.float32)
            valid = np.isfinite(arr) & (arr != -9999.0) & (arr >= -1.0) & (arr <= 1.0)
            if not np.any(valid):
                self.log("Attention : aucune valeur valide pour " + name)
                return
            vals = arr[valid]
            real_min, real_max = float(vals.min()), float(vals.max())

            # Contraste d'affichage basé sur les percentiles, mais borné par [-1,+1].
            p2, p98 = np.percentile(vals, [2, 98])
            vmin = max(-1.0, float(p2))
            vmax = min(1.0, float(p98))
            if abs(vmax - vmin) < 0.02:
                vmin, vmax = max(-1.0, real_min), min(1.0, real_max)
            if abs(vmax - vmin) < 0.02:
                c = (vmin + vmax) / 2.0
                vmin, vmax = max(-1.0, c - 0.15), min(1.0, c + 0.15)
            vmid = 0.0 if vmin < 0 < vmax else (vmin + vmax) / 2.0

            n = name.upper()
            if "SAVI" in n:
                items = [
                    QgsColorRampShader.ColorRampItem(vmin, QColor("#f7fcf5"), f"Faible ({real_min:.3f})"),
                    QgsColorRampShader.ColorRampItem(vmid, QColor("#a1d99b"), "Moyen"),
                    QgsColorRampShader.ColorRampItem(vmax, QColor("#006d2c"), f"Végétation forte ({real_max:.3f})"),
                ]
            elif "BSI" in n:
                items = [
                    QgsColorRampShader.ColorRampItem(vmin, QColor("#e6f2ff"), f"Faible ({real_min:.3f})"),
                    QgsColorRampShader.ColorRampItem(vmid, QColor("#fff7bc"), "Moyen"),
                    QgsColorRampShader.ColorRampItem(vmax, QColor("#b35806"), f"Sol nu fort ({real_max:.3f})"),
                ]
            elif "NDBI" in n:
                items = [
                    QgsColorRampShader.ColorRampItem(vmin, QColor("#1a9850"), f"Non bâti ({real_min:.3f})"),
                    QgsColorRampShader.ColorRampItem(vmid, QColor("#ffffbf"), "Moyen"),
                    QgsColorRampShader.ColorRampItem(vmax, QColor("#d73027"), f"Bâti fort ({real_max:.3f})"),
                ]
            else:
                return

            ramp = QgsColorRampShader()
            ramp.setColorRampType(QgsColorRampShader.Interpolated)
            ramp.setColorRampItemList(items)
            try:
                ramp.setMinimumValue(vmin); ramp.setMaximumValue(vmax)
            except Exception:
                pass

            shader = QgsRasterShader()
            shader.setRasterShaderFunction(ramp)
            renderer = QgsSingleBandPseudoColorRenderer(layer.dataProvider(), 1, shader)
            try:
                renderer.setClassificationMin(vmin)
                renderer.setClassificationMax(vmax)
            except Exception:
                pass
            layer.setRenderer(renderer)
            try:
                tr = QgsRasterTransparency()
                px = []
                no = QgsRasterTransparency.TransparentSingleValuePixel()
                no.min = -9999.0
                no.max = -9999.0
                no.percentTransparent = 100
                px.append(no)
                tr.setTransparentSingleValuePixelList(px)
                layer.renderer().setRasterTransparency(tr)
            except Exception:
                pass
            layer.triggerRepaint()
            self.log(f"Symbologie {name} appliquée : min réel={real_min:.4f}, max réel={real_max:.4f}, affichage contrasté.")
        except Exception as e:
            self.log("Attention : style de l'indice non appliqué : " + str(e))


    def remove_existing_layers(self, name=None, path=None):
        """Supprime les anciennes couches du projet pour éviter l'affichage 0-255 d'une ancienne version."""
        try:
            project = QgsProject.instance()
            for lyr in list(project.mapLayers().values()):
                same_name = (name is not None and lyr.name().upper() == name.upper())
                same_path = False
                if path is not None:
                    try:
                        same_path = os.path.normcase(os.path.abspath(lyr.source().split('|')[0])) == os.path.normcase(os.path.abspath(path))
                    except Exception:
                        same_path = False
                if same_name or same_path:
                    project.removeMapLayer(lyr.id())
        except Exception as e:
            self.log("Attention : ancienne couche non supprimée : " + str(e))

    def add_raster_layer(self, path, name):
        # Supprimer l'ancienne couche avant de charger la nouvelle symbologie.
        self.remove_existing_layers(name=name, path=path)

        layer = QgsRasterLayer(path, name)
        if layer.isValid():
            self.apply_index_style(layer, name)
            QgsProject.instance().addMapLayer(layer)
            self.log(f"{name} ajouté dans QGIS avec symbologie indice [-1 ; +1].")
            self.populate_layout_layers()
            return layer
        self.log(f"Erreur : impossible d'ajouter {name} dans QGIS.")
        return None

    # -------------------- INDICES --------------------

    def unlock_output_path(self, path):
        """Supprime l'ancienne couche et l'ancien fichier raster avant recalcul."""
        self.remove_existing_layers(path=path)
        try:
            if os.path.exists(path):
                gdal.Unlink(path)
        except Exception:
            try:
                os.remove(path)
            except Exception as e:
                self.log("Attention : impossible de remplacer " + path + " : " + str(e))

    def _array_from_band(self, path, ref_ds=None):
        """Lit une bande en Float32, alignée sur ref_ds si nécessaire."""
        src = gdal.Open(path)
        if src is None:
            raise Exception("Impossible d'ouvrir : " + str(path))

        # Alignement automatique sur la grille de référence.
        if ref_ds is not None:
            same_grid = (
                src.RasterXSize == ref_ds.RasterXSize and
                src.RasterYSize == ref_ds.RasterYSize and
                src.GetGeoTransform() == ref_ds.GetGeoTransform()
            )
            if not same_grid:
                mem = gdal.Warp(
                    "", src,
                    format="MEM",
                    width=ref_ds.RasterXSize,
                    height=ref_ds.RasterYSize,
                    outputBounds=(
                        ref_ds.GetGeoTransform()[0],
                        ref_ds.GetGeoTransform()[3] + ref_ds.RasterYSize * ref_ds.GetGeoTransform()[5],
                        ref_ds.GetGeoTransform()[0] + ref_ds.RasterXSize * ref_ds.GetGeoTransform()[1],
                        ref_ds.GetGeoTransform()[3]
                    ),
                    dstSRS=ref_ds.GetProjection(),
                    resampleAlg=gdal.GRA_Bilinear
                )
                src = mem

        band = src.GetRasterBand(1)
        arr = band.ReadAsArray().astype(np.float32)
        nodata = band.GetNoDataValue()
        valid = np.isfinite(arr)
        if nodata is not None:
            valid &= (arr != nodata)
        # Sentinel-2 L2A/L1C est souvent en entier *10000. Si déjà Float réflectance, on garde.
        mx = np.nanmax(arr[valid]) if np.any(valid) else 0
        scale = 10000.0 if mx > 2.0 else 1.0
        arr = arr / scale
        valid &= (arr > 0) & (arr < 2.0)
        return arr, valid, src

    def write_index_raster(self, out_path, arr, ref_ds):
        self.unlock_output_path(out_path)
        driver = gdal.GetDriverByName("GTiff")
        ds = driver.Create(out_path, ref_ds.RasterXSize, ref_ds.RasterYSize, 1, gdal.GDT_Float32, options=["COMPRESS=LZW", "TILED=YES"])
        ds.SetGeoTransform(ref_ds.GetGeoTransform())
        ds.SetProjection(ref_ds.GetProjection())
        band = ds.GetRasterBand(1)
        band.WriteArray(arr.astype(np.float32))
        band.SetNoDataValue(-9999.0)
        band.FlushCache()
        ds.FlushCache()
        ds = None

    def calc_and_write_indices(self, tasks, blue, red, nir, swir, output_folder):
        """Calcule les indices avec NumPy pour éviter les erreurs de type/échelle du raster calculator."""
        ref_ds = gdal.Open(nir or red or blue or swir)
        if ref_ds is None:
            raise Exception("Impossible d'ouvrir la bande de référence.")

        b2 = b4 = b8 = b11 = None
        v2 = v4 = v8 = v11 = None

        if blue:
            b2, v2, _ = self._array_from_band(blue, ref_ds)
        if red:
            b4, v4, _ = self._array_from_band(red, ref_ds)
        if nir:
            b8, v8, _ = self._array_from_band(nir, ref_ds)
        if swir:
            b11, v11, _ = self._array_from_band(swir, ref_ds)

        outputs = []
        eps = 1e-6

        if "SAVI" in tasks:
            valid = v8 & v4 & ((b8 + b4 + 0.5) > eps)
            out = np.full(b8.shape, -9999.0, dtype=np.float32)
            out[valid] = 1.5 * ((b8[valid] - b4[valid]) / (b8[valid] + b4[valid] + 0.5))
            out[valid] = np.clip(out[valid], -1.0, 1.0)
            path = os.path.join(output_folder, "SAVI.tif")
            self.write_index_raster(path, out, ref_ds)
            outputs.append((path, "SAVI"))

        if "BSI" in tasks:
            valid = v11 & v4 & v8 & v2 & ((b11 + b4 + b8 + b2) > eps)
            out = np.full(b8.shape, -9999.0, dtype=np.float32)
            out[valid] = ((b11[valid] + b4[valid]) - (b8[valid] + b2[valid])) / ((b11[valid] + b4[valid]) + (b8[valid] + b2[valid]))
            out[valid] = np.clip(out[valid], -1.0, 1.0)
            path = os.path.join(output_folder, "BSI.tif")
            self.write_index_raster(path, out, ref_ds)
            outputs.append((path, "BSI"))

        if "NDBI" in tasks:
            valid = v11 & v8 & ((b11 + b8) > eps)
            out = np.full(b8.shape, -9999.0, dtype=np.float32)
            out[valid] = (b11[valid] - b8[valid]) / (b11[valid] + b8[valid])
            out[valid] = np.clip(out[valid], -1.0, 1.0)
            path = os.path.join(output_folder, "NDBI.tif")
            self.write_index_raster(path, out, ref_ds)
            outputs.append((path, "NDBI"))

        return outputs

    def normalize_index_file(self, path):
        """Compatibilité : force Float32 et NoData."""
        ds = gdal.Open(path, gdal.GA_Update)
        if ds is None:
            return
        band = ds.GetRasterBand(1)
        arr = band.ReadAsArray().astype(np.float32)
        valid = np.isfinite(arr) & (arr != -9999.0)
        arr[~valid] = -9999.0
        arr[valid] = np.clip(arr[valid], -1.0, 1.0)
        band.WriteArray(arr)
        band.SetNoDataValue(-9999.0)
        ds.FlushCache()
        ds = None


    def run_indices(self):
        self.dlg.tabWidget.setCurrentIndex(0)
        self.dlg.progressBar.setValue(0)
        self.log("Calcul des indices lancé...")

        input_folder = self.dlg.line_input.text().strip()
        output_folder = self.dlg.line_output.text().strip()
        if not input_folder or not os.path.isdir(input_folder):
            self.log("Erreur : dossier Sentinel invalide."); self.set_progress(0); return
        if not output_folder:
            self.log("Erreur : dossier de sortie non renseigné."); self.set_progress(0); return
        os.makedirs(output_folder, exist_ok=True)
        self.set_progress(10)

        blue = self.find_band_file(input_folder, ["B02", "B2_", "B2.", "blue"])
        red = self.find_band_file(input_folder, ["B04", "B4_", "B4.", "red"])
        nir = self.find_band_file(input_folder, ["B08", "B8_", "B8.", "nir"])
        swir = self.find_band_file(input_folder, ["B11", "swir"])
        self.log("Bandes détectées :")
        self.log(f"B02/Blue : {blue}"); self.log(f"B04/Red  : {red}"); self.log(f"B08/NIR  : {nir}"); self.log(f"B11/SWIR : {swir}")

        try:
            tasks = []
            if self.dlg.chk_savi.isChecked(): tasks.append("SAVI")
            if self.dlg.chk_bsi.isChecked(): tasks.append("BSI")
            if self.dlg.chk_ndbi.isChecked(): tasks.append("NDBI")
            if not tasks:
                self.log("Erreur : aucun indice sélectionné."); self.set_progress(0); return
            if "SAVI" in tasks and (not nir or not red):
                self.log("Erreur : SAVI nécessite B08 et B04."); self.set_progress(0); return
            if ("BSI" in tasks or "NDBI" in tasks) and (not swir or not nir):
                self.log("Erreur : BSI/NDBI nécessitent B11 et B08."); self.set_progress(0); return
            if "BSI" in tasks and (not blue or not red):
                self.log("Erreur : BSI nécessite B02 et B04."); self.set_progress(0); return

            self.set_progress(20)
            outputs = self.calc_and_write_indices(tasks, blue, red, nir, swir, output_folder)
            step = 75 / max(len(outputs), 1)
            progress = 20
            for out, name in outputs:
                layer = self.add_raster_layer(out, name)
                progress += step
                self.set_progress(int(progress))
                try:
                    ds = gdal.Open(out)
                    arr = ds.GetRasterBand(1).ReadAsArray().astype(np.float32)
                    valid = np.isfinite(arr) & (arr != -9999.0)
                    if np.any(valid):
                        self.log(f"{name} : min={arr[valid].min():.4f}, max={arr[valid].max():.4f} — raster Float32 [-1 ; +1].")
                except Exception:
                    pass
            self.set_progress(100); self.log("Calcul des indices terminé."); self.populate_layout_layers()
        except Exception as e:
            self.log(f"Erreur pendant le calcul des indices : {e}"); self.set_progress(0)

    # -------------------- CLASSIFICATION --------------------

    def make_valid_data_mask(self, savi, bsi, ndbi):
        """Masque de pixels réellement calculés : sépare le fond NoData des classes.
        Le fond extérieur reste NoData même si une classe utilise la valeur 0.
        """
        try:
            mask = (
                np.isfinite(savi) & np.isfinite(bsi) & np.isfinite(ndbi) &
                (savi != -9999.0) & (bsi != -9999.0) & (ndbi != -9999.0) &
                (savi >= -1.0) & (savi <= 1.0) &
                (bsi >= -1.0) & (bsi <= 1.0) &
                (ndbi >= -1.0) & (ndbi <= 1.0)
            )
            # En dehors de la zone Sentinel, les trois indices sont généralement nuls/NoData.
            # On ne supprime pas une classe 0 : on supprime uniquement les pixels sans information spectrale.
            mask &= ~((np.abs(savi) < 1e-7) & (np.abs(bsi) < 1e-7) & (np.abs(ndbi) < 1e-7))
            return mask
        except Exception:
            return np.isfinite(savi) & np.isfinite(bsi) & np.isfinite(ndbi)



    def find_qgis_python_exe(self):
        """Retourne le vrai python.exe utilisé par QGIS sous Windows.

        sys.executable peut parfois pointer vers qgis-bin.exe.
        Dans ce cas, QGIS interprète les arguments pip comme des sources de données.
        """
        candidates = []

        try:
            candidates.append(os.path.join(sys.prefix, "python.exe"))
        except Exception:
            pass

        try:
            candidates.append(os.path.join(sys.exec_prefix, "python.exe"))
        except Exception:
            pass

        try:
            base = os.path.dirname(sys.prefix)
            candidates.extend([
                os.path.join(base, "Python312", "python.exe"),
                os.path.join(base, "Python311", "python.exe"),
                os.path.join(base, "Python310", "python.exe"),
                os.path.join(base, "Python39", "python.exe"),
            ])
        except Exception:
            pass

        for p in candidates:
            if p and os.path.exists(p) and os.path.basename(p).lower() == "python.exe":
                return os.path.normpath(p)

        return None


    def ensure_sklearn_available(self):
        """Vérifie scikit-learn et installe automatiquement via le vrai python.exe de QGIS."""
        try:
            from sklearn.ensemble import RandomForestClassifier
            return RandomForestClassifier
        except Exception:
            pass

        self.log("scikit-learn introuvable. Recherche du vrai python.exe de QGIS...")
        QApplication.processEvents()

        python_exe = self.find_qgis_python_exe()

        if not python_exe:
            self.log("Erreur : impossible de trouver python.exe interne de QGIS.")
            try:
                QMessageBox.warning(
                    self.dlg,
                    "Python QGIS introuvable",
                    "Impossible de trouver automatiquement python.exe de QGIS.\\n\\n"
                    "Solution manuelle :\\n"
                    "1) Ouvrez QGIS > Plugins > Python Console\\n"
                    "2) Collez :\\n"
                    "import sys, subprocess, os\\n"
                    "python_exe = os.path.join(sys.prefix, 'python.exe')\\n"
                    "subprocess.check_call([python_exe, '-m', 'pip', 'install', '--user', 'scikit-learn'])\\n"
                    "3) Redémarrez QGIS."
                )
            except Exception:
                pass
            return None

        self.log("Python QGIS détecté : " + python_exe)
        self.log("Installation automatique de scikit-learn en cours...")
        QApplication.processEvents()

        try:
            subprocess.check_call(
                [python_exe, "-m", "pip", "install", "--user", "scikit-learn"],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            )

            from sklearn.ensemble import RandomForestClassifier
            self.log("scikit-learn installé avec succès. Classification en cours...")
            return RandomForestClassifier

        except Exception as e:
            self.log("Erreur : installation automatique de scikit-learn échouée.")
            self.log(str(e))
            try:
                QMessageBox.warning(
                    self.dlg,
                    "Installation scikit-learn échouée",
                    "L'installation automatique de scikit-learn a échoué.\\n\\n"
                    "Commande manuelle à utiliser dans PowerShell :\\n\\n"
                    f'& "{python_exe}" -m pip install --user scikit-learn'
                )
            except Exception:
                pass
            return None





    def run_classification(self):
        self.dlg.tabWidget.setCurrentIndex(0)
        self.dlg.progressBar.setValue(0)
        self.log("Classification lancée...")
        RandomForestClassifier = self.ensure_sklearn_available()
        if RandomForestClassifier is None:
            self.set_progress(0)
            return

        output_folder = self.dlg.line_output.text().strip()
        samples = self.dlg.line_samples.text().strip()
        class_field = self.dlg.line_class.text().strip()
        if not output_folder or not samples or not os.path.exists(samples) or not class_field:
            self.log("Erreur : paramètres de classification incomplets."); return
        savi_path = os.path.join(output_folder, "SAVI.tif"); bsi_path = os.path.join(output_folder, "BSI.tif"); ndbi_path = os.path.join(output_folder, "NDBI.tif")
        for p in [savi_path, bsi_path, ndbi_path]:
            if not os.path.exists(p): self.log(f"Erreur : indice manquant : {p}"); return
        try:
            self.set_progress(10)
            ds_savi, ds_bsi, ds_ndbi = gdal.Open(savi_path), gdal.Open(bsi_path), gdal.Open(ndbi_path)
            savi = ds_savi.GetRasterBand(1).ReadAsArray().astype(np.float32); bsi = ds_bsi.GetRasterBand(1).ReadAsArray().astype(np.float32); ndbi = ds_ndbi.GetRasterBand(1).ReadAsArray().astype(np.float32)
            rows, cols = savi.shape; gt = ds_savi.GetGeoTransform(); proj = ds_savi.GetProjection(); self.set_progress(25)
            vds = ogr.Open(samples); lyr = vds.GetLayer(); names = [lyr.GetLayerDefn().GetFieldDefn(i).GetName() for i in range(lyr.GetLayerDefn().GetFieldCount())]
            if class_field not in names: self.log(f"Erreur : le champ '{class_field}' n'existe pas."); return
            mem = gdal.GetDriverByName("MEM").Create("", cols, rows, 1, gdal.GDT_Int16); mem.SetGeoTransform(gt); mem.SetProjection(proj); mem.GetRasterBand(1).Fill(0)
            gdal.RasterizeLayer(mem, [1], lyr, options=[f"ATTRIBUTE={class_field}"]); labels = mem.GetRasterBand(1).ReadAsArray(); self.set_progress(40)
            mask = (labels > 0) & np.isfinite(savi) & np.isfinite(bsi) & np.isfinite(ndbi)
            if np.count_nonzero(mask) == 0: self.log("Erreur : aucun échantillon valide trouvé."); return
            X = np.column_stack((savi[mask], bsi[mask], ndbi[mask])); y = labels[mask]; self.log(f"Pixels d'apprentissage : {len(y)}")
            model = RandomForestClassifier(n_estimators=100, random_state=42, n_jobs=-1); model.fit(X, y); self.set_progress(60)
            valid = self.make_valid_data_mask(savi, bsi, ndbi); out_arr = np.full((rows, cols), 255, dtype=np.uint8)
            ids = np.where(valid.ravel())[0]; flat = out_arr.ravel(); sf, bf, nf = savi.ravel(), bsi.ravel(), ndbi.ravel()
            for i in range(0, len(ids), 250000):
                part = ids[i:i+250000]; flat[part] = model.predict(np.column_stack((sf[part], bf[part], nf[part]))).astype(np.uint8); QApplication.processEvents()
            out_arr = flat.reshape((rows, cols)); self.set_progress(80)
            out_path = os.path.join(output_folder, "Classification_RF.tif"); drv = gdal.GetDriverByName("GTiff")
            out_arr = self.smooth_classification_array(out_arr)
            ods = drv.Create(out_path, cols, rows, 1, gdal.GDT_Byte, options=["COMPRESS=LZW"]); ods.SetGeoTransform(gt); ods.SetProjection(proj); ods.GetRasterBand(1).WriteArray(out_arr); ods.GetRasterBand(1).SetNoDataValue(255); ods.FlushCache(); ods = None
            raster_layer = self.add_raster_layer(out_path, "Classification_RF")
            if raster_layer: self.apply_classification_style(raster_layer, samples, class_field)
            self.set_progress(100); self.log("Classification terminée."); self.populate_layout_layers()
        except Exception as e:
            self.log(f"Erreur classification : {e}"); self.set_progress(0)


    def smooth_classification_array(self, arr):
        """Nettoyage léger du bruit rouge par filtre de majorité 3x3 sans dépendance scipy."""
        try:
            import numpy as _np
            src = arr.copy()
            out = arr.copy()
            rows, cols = src.shape
            for r in range(1, rows - 1):
                if r % 50 == 0:
                    QApplication.processEvents()
                for c in range(1, cols - 1):
                    v = src[r, c]
                    if v == 255:
                        continue
                    win = src[r-1:r+2, c-1:c+2].ravel()
                    win = win[win != 255]
                    if len(win) == 0:
                        continue
                    vals, counts = _np.unique(win, return_counts=True)
                    maj = vals[_np.argmax(counts)]
                    if maj != v and counts.max() >= 5:
                        out[r, c] = maj
            return out
        except Exception:
            return arr


    def apply_classification_style(self, raster_layer, samples, class_field):
        try:
            provider = raster_layer.dataProvider()
            try:
                provider.setNoDataValue(1, 255)
            except Exception:
                pass

            vds = ogr.Open(samples)
            lyr = vds.GetLayer()
            defn = lyr.GetLayerDefn()
            names = [defn.GetFieldDefn(i).GetName() for i in range(defn.GetFieldCount())]
            label_field = next((f for f in ["class_name","classe","nom","name","libelle","label","type","occupation"] if f in names and f != class_field), None)

            labels = {}
            lyr.ResetReading()
            for feat in lyr:
                try:
                    v = int(feat.GetField(class_field))
                except Exception:
                    continue
                lbl = feat.GetField(label_field) if label_field else f"Classe {v}"
                labels[v] = str(lbl) if lbl else f"Classe {v}"

            # Si les classes commencent à 0, elles restent valides. NoData est 255.
            colors = {
                0: QColor(220,60,50),
                1: QColor(220,60,50),
                2: QColor(60,160,75),
                3: QColor(240,200,60),
                4: QColor(70,130,180),
                5: QColor(180,120,60),
                6: QColor(160,90,180),
                7: QColor(80,180,180),
                8: QColor(120,120,120),
            }

            classes = [QgsPalettedRasterRenderer.Class(v, colors.get(v, QColor(200,200,200)), lbl) for v, lbl in sorted(labels.items()) if v != 255]
            if classes:
                renderer = QgsPalettedRasterRenderer(raster_layer.dataProvider(), 1, classes)
                try:
                    tr = QgsRasterTransparency()
                    p = QgsRasterTransparency.TransparentSingleValuePixel()
                    p.min = 255
                    p.max = 255
                    p.percentTransparent = 100
                    tr.setTransparentSingleValuePixelList([p])
                    renderer.setRasterTransparency(tr)
                except Exception:
                    pass
                raster_layer.setRenderer(renderer)
                raster_layer.triggerRepaint()
                self.log("Symbologie de classification appliquée : NoData=255 transparent, classes conservées.")
        except Exception as e:
            self.log(f"Attention : symbologie non appliquée : {e}")

    # -------------------- EXPORT --------------------

    def is_mapic_result_layer(self, name):
        n = name.lower(); return any(k in n for k in ["savi", "bsi", "ndbi", "classification", "classification_rf"])

    def populate_layout_layers(self):
        if not self.dlg or not hasattr(self.dlg, "combo_layout_layer"):
            return
        current_id = self.dlg.combo_layout_layer.currentData() if self.dlg.combo_layout_layer.count() else None
        self.dlg.combo_layout_layer.clear()

        candidates = []
        for layer in QgsProject.instance().mapLayers().values():
            if self.is_mapic_result_layer(layer.name()):
                n = layer.name().lower()
                priority = 0 if "classification" in n else (1 if layer.isValid() else 9)
                candidates.append((priority, layer.name().lower(), layer))

        for _, __, layer in sorted(candidates, key=lambda x: (x[0], x[1])):
            label = layer.name()
            self.dlg.combo_layout_layer.addItem(label, layer.id())

        if self.dlg.combo_layout_layer.count() == 0:
            self.dlg.combo_layout_layer.addItem("Aucun résultat MapIC trouvé", "")
            return

        if current_id:
            for i in range(self.dlg.combo_layout_layer.count()):
                if self.dlg.combo_layout_layer.itemData(i) == current_id:
                    self.dlg.combo_layout_layer.setCurrentIndex(i)
                    break



    def valid_raster_extent(self, layer):
        """Retourne l'emprise des pixels valides pour éviter une carte blanche."""
        try:
            ds = gdal.Open(layer.source())
            band = ds.GetRasterBand(1)
            arr = band.ReadAsArray()
            nodata = band.GetNoDataValue()
            valid = np.isfinite(arr)
            if nodata is not None:
                valid &= (arr != nodata)
            valid &= (arr != -9999)

            # Pour les classifications, la valeur 0 est souvent NoData/fond.
            lname = layer.name().lower()
            if "classification" in lname:
                valid &= (arr != 0)

            if not np.any(valid):
                return layer.extent()

            ys, xs = np.where(valid)
            gt = ds.GetGeoTransform()
            xmin = gt[0] + xs.min() * gt[1]
            xmax = gt[0] + (xs.max() + 1) * gt[1]
            ymax = gt[3] + ys.min() * gt[5]
            ymin = gt[3] + (ys.max() + 1) * gt[5]
            rect = QgsRectangle(min(xmin, xmax), min(ymin, ymax), max(xmin, xmax), max(ymin, ymax))
            rect.scale(1.02)
            return rect
        except Exception:
            return layer.extent()


    def build_layout(self, target_layer, open_only=False):
        """Mise en page robuste : affiche la couche sélectionnée et conserve les retouches faites dans l'aperçu."""
        project = QgsProject.instance()
        manager = project.layoutManager()
        layout_name = "MapIC_LAYOUT_ACTIVE"

        for lo in list(manager.printLayouts()):
            if lo.name() == layout_name:
                if not open_only:
                    return lo
                manager.removeLayout(lo)
                break

        try:
            node = QgsProject.instance().layerTreeRoot().findLayer(target_layer.id())
            if node is not None:
                node.setItemVisibilityChecked(True)
            target_layer.triggerRepaint()
            self.iface.mapCanvas().setExtent(target_layer.extent())
            self.iface.mapCanvas().refresh()
            QApplication.processEvents()
        except Exception:
            pass

        layout = QgsPrintLayout(project)
        layout.initializeDefaults()
        layout.setName(layout_name)
        manager.addLayout(layout)

        title_text = self.dlg.line_title.text().strip() or ("Carte MapIC - " + target_layer.name())

        try:
            export_extent = target_layer.extent()
            export_extent.scale(1.03)
        except Exception:
            export_extent = self.iface.mapCanvas().extent()

        outer = QgsLayoutItemLabel(layout)
        outer.setText("")
        layout.addLayoutItem(outer)
        outer.attemptMove(QgsLayoutPoint(6, 6, QgsUnitTypes.LayoutMillimeters))
        outer.attemptResize(QgsLayoutSize(285, 198, QgsUnitTypes.LayoutMillimeters))
        try:
            outer.setFrameEnabled(True)
            outer.setFrameStrokeColor(QColor("#1f1f1f"))
            outer.setFrameStrokeWidth(0.45)
        except Exception:
            pass

        title = QgsLayoutItemLabel(layout)
        title.setText(title_text)
        title.setFont(QFont("Times New Roman", 16, QFont.Bold))
        layout.addLayoutItem(title)
        title.attemptMove(QgsLayoutPoint(92, 10, QgsUnitTypes.LayoutMillimeters))
        title.attemptResize(QgsLayoutSize(112, 12, QgsUnitTypes.LayoutMillimeters))
        try:
            title.setFrameEnabled(True)
            title.setFrameStrokeColor(QColor("#4a4a4a"))
            title.setFrameStrokeWidth(0.35)
            title.setHAlign(Qt.AlignCenter)
            title.setVAlign(Qt.AlignVCenter)
        except Exception:
            pass

        map_item = QgsLayoutItemMap(layout)
        layout.addLayoutItem(map_item)
        map_item.attemptMove(QgsLayoutPoint(18, 30, QgsUnitTypes.LayoutMillimeters))
        map_item.attemptResize(QgsLayoutSize(216, 136, QgsUnitTypes.LayoutMillimeters))

        try:
            map_layers = [target_layer]
            root = QgsProject.instance().layerTreeRoot()
            for lyr in QgsProject.instance().mapLayers().values():
                node = root.findLayer(lyr.id())
                if lyr.id() != target_layer.id() and node is not None and node.isVisible() and hasattr(lyr, "geometryType"):
                    map_layers.append(lyr)

            map_item.setLayers(map_layers)
            map_item.setKeepLayerSet(True)
            map_item.setExtent(export_extent)
            try:
                map_item.setCrs(target_layer.crs())
            except Exception:
                pass
            map_item.setFrameEnabled(True)
            map_item.setFrameStrokeColor(QColor("#222222"))
            map_item.setFrameStrokeWidth(0.35)
            map_item.refresh()
            layout.refresh()
        except Exception:
            try:
                map_item.setKeepLayerSet(False)
                map_item.setExtent(self.iface.mapCanvas().extent())
                map_item.refresh()
                layout.refresh()
            except Exception:
                pass

        try:
            grid = map_item.grid()
            grid.setEnabled(True)
            grid.setStyle(QgsLayoutItemMapGrid.Cross)
            grid.setIntervalX(export_extent.width() / 4)
            grid.setIntervalY(export_extent.height() / 4)
            grid.setAnnotationEnabled(True)
            grid.setAnnotationDisplay(QgsLayoutItemMapGrid.ShowAll, QgsLayoutItemMapGrid.Left)
            grid.setAnnotationDisplay(QgsLayoutItemMapGrid.ShowAll, QgsLayoutItemMapGrid.Right)
            grid.setAnnotationDisplay(QgsLayoutItemMapGrid.ShowAll, QgsLayoutItemMapGrid.Top)
            grid.setAnnotationDisplay(QgsLayoutItemMapGrid.ShowAll, QgsLayoutItemMapGrid.Bottom)
            grid.setAnnotationFont(QFont("Arial", 6, QFont.Bold))
            grid.setAnnotationPrecision(0)
            grid.setGridLineColor(QColor(80, 80, 80, 90))
            grid.setGridLineWidth(0.08)
            grid.setCrossLength(1.3)
        except Exception:
            pass

        north = QgsLayoutItemPicture(layout)
        north.setPicturePath(":/images/north_arrows/layout_default_north_arrow.svg")
        layout.addLayoutItem(north)
        north.attemptMove(QgsLayoutPoint(16, 12, QgsUnitTypes.LayoutMillimeters))
        north.attemptResize(QgsLayoutSize(19, 19, QgsUnitTypes.LayoutMillimeters))

        legend = QgsLayoutItemLegend(layout)
        legend.setTitle("Légende")
        legend.setLinkedMap(map_item)
        legend.setAutoUpdateModel(False)
        try:
            root = legend.model().rootGroup()
            root.clear()
            root.addLayer(target_layer)
        except Exception:
            pass
        layout.addLayoutItem(legend)
        legend.attemptMove(QgsLayoutPoint(240, 123, QgsUnitTypes.LayoutMillimeters))
        legend.attemptResize(QgsLayoutSize(44, 50, QgsUnitTypes.LayoutMillimeters))
        try:
            legend.setFrameEnabled(True)
            legend.setFrameStrokeColor(QColor("#111111"))
            legend.setFrameStrokeWidth(0.35)
            legend.setBackgroundEnabled(True)
            legend.setBackgroundColor(QColor(255, 255, 255, 245))
            legend.setTitleFont(QFont("Arial", 11, QFont.Bold))
        except Exception:
            pass

        crs_box = QgsLayoutItemLabel(layout)
        crs_text = "Coordinate System: " + target_layer.crs().authid() + "\nProjection: " + target_layer.crs().description() + "\nDatum / Units: Meter"
        crs_box.setText(crs_text)
        crs_box.setFont(QFont("Times New Roman", 7))
        layout.addLayoutItem(crs_box)
        crs_box.attemptMove(QgsLayoutPoint(18, 171, QgsUnitTypes.LayoutMillimeters))
        crs_box.attemptResize(QgsLayoutSize(84, 21, QgsUnitTypes.LayoutMillimeters))
        try:
            crs_box.setFrameEnabled(True)
            crs_box.setFrameStrokeColor(QColor("#555555"))
            crs_box.setFrameStrokeWidth(0.25)
        except Exception:
            pass

        scalebar = QgsLayoutItemScaleBar(layout)
        scalebar.setStyle("Single Box")
        scalebar.setLinkedMap(map_item)
        scalebar.setUnits(QgsUnitTypes.DistanceMeters)
        scalebar.setNumberOfSegments(2)
        scalebar.setNumberOfSegmentsLeft(0)
        scalebar.setUnitsPerSegment(2000)
        scalebar.setUnitLabel("m")
        scalebar.setFont(QFont("Arial", 7))
        layout.addLayoutItem(scalebar)
        scalebar.attemptMove(QgsLayoutPoint(118, 181, QgsUnitTypes.LayoutMillimeters))
        scalebar.attemptResize(QgsLayoutSize(86, 8, QgsUnitTypes.LayoutMillimeters))

        creator_text = "Réalisé par : Jamal Ben Talb"
        try:
            if hasattr(self.dlg, "line_author"):
                creator_text = self.dlg.line_author.text().strip() or creator_text
        except Exception:
            pass

        creator = QgsLayoutItemLabel(layout)
        creator.setText(creator_text)
        creator.setFont(QFont("Arial", 6, QFont.Bold))
        layout.addLayoutItem(creator)
        creator.attemptMove(QgsLayoutPoint(18, 195, QgsUnitTypes.LayoutMillimeters))
        creator.attemptResize(QgsLayoutSize(110, 4, QgsUnitTypes.LayoutMillimeters))

        source = QgsLayoutItemLabel(layout)
        source.setText("Source : Sentinel-2 | Traitement : MapIC | Couche : " + target_layer.name())
        source.setFont(QFont("Arial", 6))
        layout.addLayoutItem(source)
        source.attemptMove(QgsLayoutPoint(18, 200, QgsUnitTypes.LayoutMillimeters))
        source.attemptResize(QgsLayoutSize(130, 4, QgsUnitTypes.LayoutMillimeters))

        try:
            map_item.refresh()
            legend.refresh()
            layout.refresh()
        except Exception:
            pass

        return layout


    def get_selected_layout_layer(self):
        # Ne pas forcer le choix sur BSI : on respecte la couche sélectionnée dans la liste.
        if self.dlg.combo_layout_layer.count() == 0:
            self.populate_layout_layers()
        layer_id = self.dlg.combo_layout_layer.currentData()
        if not layer_id:
            self.log("Erreur : aucune couche MapIC sélectionnée.")
            return None
        target_layer = QgsProject.instance().mapLayer(layer_id)
        if target_layer is None or not target_layer.isValid():
            self.log("Erreur : couche sélectionnée introuvable ou invalide.")
            return None
        name = target_layer.name().upper()
        if any(k in name for k in ["SAVI", "BSI", "NDBI"]):
            self.apply_index_style(target_layer, target_layer.name())
        return target_layer


    def preview_layout(self):
        self.dlg.tabWidget.setCurrentIndex(1)
        self.log("Création de l'aperçu de la mise en page...")
        target_layer = self.get_selected_layout_layer()
        if target_layer is None:
            return

        # open_only=True recrée proprement l'aperçu pour éviter la carte blanche.
        layout = self.build_layout(target_layer, open_only=True)

        try:
            self.iface.openLayoutDesigner(layout)
            self.log("Aperçu ouvert. Les retouches faites ici seront conservées à l'export.")
        except Exception as e:
            self.log("Erreur pendant l'ouverture de l'aperçu : " + str(e))


    def export_layout(self):
        self.dlg.tabWidget.setCurrentIndex(1)
        self.dlg.progressBar.setValue(0)
        self.log("Préparation de la mise en page cartographique...")

        target_layer = self.get_selected_layout_layer()
        if target_layer is None:
            self.set_progress(0)
            return

        self.set_progress(40)

        # IMPORTANT : build_layout(open_only=False) retourne le layout déjà modifié dans QGIS.
        layout = self.build_layout(target_layer, open_only=False)

        self.set_progress(80)
        default_name = target_layer.name().replace(" ", "_") + "_MapIC.pdf"
        pdf_path, _ = QFileDialog.getSaveFileName(self.dlg, "Exporter la mise en page en PDF", default_name, "PDF (*.pdf)")
        if not pdf_path:
            self.log("Export annulé.")
            self.set_progress(0)
            return

        if not pdf_path.lower().endswith(".pdf"):
            pdf_path += ".pdf"

        try:
            layout.refresh()
        except Exception:
            pass

        result = QgsLayoutExporter(layout).exportToPdf(pdf_path, QgsLayoutExporter.PdfExportSettings())

        if result == QgsLayoutExporter.Success:
            self.set_progress(100)
            self.log("Mise en page exportée avec succès : " + pdf_path)
        else:
            self.log("Erreur lors de l'export PDF.")
            self.set_progress(0)

