# -*- coding: utf-8 -*-
"""Tool 4: Random Forest classification + accuracy assessment (OA, kappa,
confusion matrix, producer's / user's accuracy, class areas)."""

import traceback

from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QFormLayout, QGroupBox, QSpinBox, QPushButton,
    QLabel, QTextEdit, QMessageBox, QApplication, QFileDialog, QHBoxLayout,
    QComboBox, QCheckBox, QDoubleSpinBox, QScrollArea, QWidget,
)
from qgis.gui import QgsMapLayerComboBox
from qgis.core import (
    QgsMapLayerProxyModel, QgsCoordinateTransform, QgsProject, QgsRasterLayer,
    QgsTask, QgsApplication,
)
import os
import re
import tempfile

from .. import gee_utils as gu
from .help_texts import help_button


class ClassifyDialog(QDialog):
    def __init__(self, iface, state, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.state = state
        self.classified = None
        self._last_run = None
        self._cluster_img = None
        self.report_text = ""
        self.setWindowTitle("4. Classify & Accuracy")
        self.setMinimumSize(600, 640)
        self.resize(640, 820)
        self._build_ui()
        self._mode_changed(0)      # start in supervised mode
        self._sync_scheme()

    def _build_ui(self):
        root = QVBoxLayout(self)
        scroll = QScrollArea(); scroll.setWidgetResizable(True)
        content = QWidget()
        lay = QVBoxLayout(content)

        gb = QGroupBox("Inputs")
        f = QFormLayout(gb)
        self._form_inputs = f
        self.gb_inputs = gb
        self.cb_pts = QgsMapLayerComboBox()
        self.cb_pts.setFilters(QgsMapLayerProxyModel.Filter.PointLayer)
        f.addRow("Labeled points layer:", self.cb_pts)
        self.cb_level = QComboBox()
        self.cb_level.addItems([
            "Level 1 (recommended)",
            "Level 2 (LU2003 detail, needs many labeled points)",
        ])
        f.addRow("Classification level:", self.cb_level)
        self.lbl_scheme = QLabel("")
        self.lbl_scheme.setStyleSheet("color:#0a6; font-weight:bold;")
        self.lbl_scheme.setWordWrap(True)
        f.addRow("Active scheme:", self.lbl_scheme)
        self.cb_mode = QComboBox()
        self.cb_mode.addItems([
            "Supervised (uses your labeled points)",
            "Unsupervised (clustering, no labels needed)",
        ])
        self.cb_mode.currentIndexChanged.connect(self._mode_changed)
        f.addRow("Mode:", self.cb_mode)

        self.cb_clf = QComboBox()
        self.cb_clf.addItems(gu.CLASSIFIERS)
        self.cb_clf.currentIndexChanged.connect(self._algo_changed)
        f.addRow("Classifier:", self.cb_clf)

        self.cb_cluster = QComboBox()
        self.cb_cluster.addItems(gu.UNSUPERVISED)
        f.addRow("Cluster algorithm:", self.cb_cluster)
        self.sp_nclusters = QSpinBox()
        self.sp_nclusters.setRange(2, 30); self.sp_nclusters.setValue(8)
        f.addRow("Number of clusters:", self.sp_nclusters)
        self.chk_compare = QCheckBox(
            "Compare all 4 classifiers (adds a table to the report)")
        f.addRow("", self.chk_compare)
        self.sp_trees = QSpinBox()
        self.sp_trees.setRange(10, 2000); self.sp_trees.setValue(300)
        f.addRow("Number of trees:", self.sp_trees)
        self.sp_scale = QSpinBox()
        self.sp_scale.setRange(10, 100)
        self.sp_scale.setValue(int(self.state.get("scale") or 10))
        self.sp_scale.setSuffix(" m")
        f.addRow("Scale:", self.sp_scale)
        lay.addWidget(gb)

        gbp = QGroupBox("Post-processing (standard map cleaning)")
        fp = QFormLayout(gbp)
        self.chk_majority = QCheckBox("Apply 3x3 majority (mode) filter")
        self.chk_majority.setChecked(True)
        fp.addRow("", self.chk_majority)
        self.sp_mmu = QDoubleSpinBox()
        self.sp_mmu.setRange(0.0, 100.0); self.sp_mmu.setSingleStep(0.1)
        self.sp_mmu.setValue(0.5); self.sp_mmu.setSuffix(" ha")
        self.sp_mmu.setToolTip(
            "Minimum mapping unit: patches smaller than this are replaced "
            "by the neighbourhood mode. 0 = off.")
        fp.addRow("Minimum mapping unit:", self.sp_mmu)
        self.chk_fill = QCheckBox(
            "Fill single-pixel holes inside patches")
        self.chk_fill.setChecked(True)
        fp.addRow("", self.chk_fill)
        self.sp_smooth = QSpinBox()
        self.sp_smooth.setRange(0, 3); self.sp_smooth.setValue(0)
        self.sp_smooth.setToolTip(
            "Extra mode-filter passes to smooth ragged class boundaries. "
            "0 = off. Use 1 for cleaner cartography.")
        fp.addRow("Boundary smoothing passes:", self.sp_smooth)
        lay.addWidget(gbp)

        gpa = QGroupBox("After classification")
        hpa = QHBoxLayout(gpa)
        self.btn_edit_pts = QPushButton("Edit sample points (Tool 3)")
        self.btn_edit_pts.setToolTip(
            "Open the labeling panel to fix labels, then re-run.")
        self.btn_edit_pts.clicked.connect(self.open_labeling)
        self.btn_reapply = QPushButton("Re-apply post-processing")
        self.btn_reapply.setToolTip(
            "Re-run the filters and accuracy on the SAME trained model "
            "(fast - no retraining).")
        self.btn_reapply.clicked.connect(self.reapply_post)
        self.btn_reapply.setEnabled(False)
        hpa.addWidget(self.btn_edit_pts)
        hpa.addWidget(self.btn_reapply)
        self.btn_reclass = QPushButton("Reclassify clusters ...")
        self.btn_reclass.setToolTip(
            "Assign each unsupervised cluster to a land-cover class.")
        self.btn_reclass.clicked.connect(self.reclassify_clusters)
        self.btn_reclass.setEnabled(False)
        hpa.addWidget(self.btn_reclass)
        lay.addWidget(gpa)

        self.btn_run = QPushButton("Run classification + accuracy assessment")
        self.btn_run.setMinimumHeight(34)
        self.btn_run.setStyleSheet("font-weight: bold;")
        self.btn_run.clicked.connect(self.run)
        _hr = QHBoxLayout()
        _hr.addWidget(help_button("classify", self))
        _hr.addWidget(self.btn_run, 1)
        lay.addLayout(_hr)

        self.txt = QTextEdit(); self.txt.setReadOnly(True)
        self.txt.setFontFamily("Consolas")
        self.txt.setMinimumHeight(320)
        self.txt.setStyleSheet("font-size: 13px;")
        lay.addWidget(self.txt, 1)

        scroll.setWidget(content)
        root.addWidget(scroll, 1)

        h = QHBoxLayout()
        self.btn_save = QPushButton("Save report (.txt)")
        self.btn_save.clicked.connect(self.save_report)
        self.btn_flag = QPushButton("Flag misclassified points")
        self.btn_flag.clicked.connect(self.flag_misclassified)
        self.btn_drive = QPushButton("Export classified map to Google Drive")
        self.btn_drive.clicked.connect(self.export_drive)
        self.btn_reset = QPushButton("Reset")
        self.btn_reset.clicked.connect(self.reset_defaults)
        self.btn_close = QPushButton("Close")
        self.btn_close.clicked.connect(self.close)
        h.addWidget(self.btn_save); h.addWidget(self.btn_flag)
        h.addWidget(self.btn_drive)
        h.addStretch()
        h.addWidget(self.btn_reset); h.addWidget(self.btn_close)
        root.addLayout(h)

        self.lbl_note = QLabel(
            "Note: the image stack must be built in this QGIS session "
            "(run Tool 1 first).")
        self.lbl_note.setWordWrap(True)
        root.addWidget(self.lbl_note)

    # ------------------------------------------------------------ helpers
    def _class_field(self):
        # LU2003 Level-2 codes only exist for the LULC scheme
        if self._scheme() == "lulc" and self.cb_level.currentIndex() == 1:
            return "lu_code"
        return "class_code"

    def _scheme(self):
        return (self.state.get("scheme") or "lulc")

    def showEvent(self, e):
        super().showEvent(e)
        self._sync_scheme()

    def _sync_scheme(self):
        """Reflect the scheme chosen in Tool 3 in the level dropdown.

        - LULC: offer Level 1 and Level 2 (LU2003).
        - WorldCover / Custom: there is no sub-level, so the dropdown shows
          just that scheme and is locked to it.
        """
        scheme = self._scheme()
        names = {"lulc": "LULC (7 broad classes)",
                 "worldcover": "ESA WorldCover 2021 (11 classes)",
                 "custom": "Custom classes"}
        self.lbl_scheme.setText(names.get(scheme, scheme))
        cur = self.cb_level.currentIndex()
        self.cb_level.blockSignals(True)
        self.cb_level.clear()
        if scheme == "lulc":
            self.cb_level.addItems([
                "Level 1 (recommended)",
                "Level 2 (LU2003 detail, needs many labeled points)",
            ])
            self.cb_level.setEnabled(True)
            self.cb_level.setCurrentIndex(0 if cur not in (0, 1) else cur)
        else:
            label = names.get(scheme, scheme)
            self.cb_level.addItem(label)
            self.cb_level.setEnabled(False)      # no sub-levels
            self.cb_level.setCurrentIndex(0)
        self.cb_level.blockSignals(False)

    def _scheme_class_list(self):
        return gu.scheme_classes(self._scheme())

    def _class_names(self):
        # Level 2 detail only applies to the LULC scheme
        if self._scheme() == "lulc" and self.cb_level.currentIndex() == 1:
            return {k: v[2] for k, v in gu.LU2003.items()}
        return {cc: n for cc, n, _ in self._scheme_class_list()}

    def _scheme_vis(self):
        mn, mx, pal = gu.scheme_palette(self._scheme())
        return {"min": mn, "max": mx, "palette": pal}

    def _points_to_fc(self, ee, layer, use_value):
        field = self._class_field()
        names = [fl.name() for fl in layer.fields()]
        if field not in names:
            raise RuntimeError(
                "The points layer has no '{}' field. Label the points "
                "with Tool 3 first (Level 1 + Level 2 mode for LU2003)."
                .format(field))
        ct = QgsCoordinateTransform(
            layer.crs(), gu.WGS84, QgsProject.instance())
        feats = []
        for f in layer.getFeatures():
            code = int(f[field] or 0)
            if code <= 0:
                continue
            if str(f["use"] or "").lower() != use_value:
                continue
            pt = ct.transform(f.geometry().asPoint())
            pid = int(f["point_id"]) if "point_id" in names \
                and f["point_id"] not in (None, "") else -1
            feats.append(ee.Feature(ee.Geometry.Point([pt.x(), pt.y()]),
                                    {"class_code": code, "point_id": pid}))
        return ee.FeatureCollection(feats), len(feats)

    def _count_labeled(self, layer):
        field = self._class_field()
        names = [fl.name() for fl in layer.fields()]
        if field not in names:
            return 0
        n = 0
        for f in layer.getFeatures():
            try:
                if int(f[field] or 0) > 0:
                    n += 1
            except (AttributeError, TypeError, RuntimeError):  # Safe fallback
                pass
        return n

    # ------------------------------------------------------------ run
    def run(self):
        stack = self.state.get("stack")
        aoi = self.state.get("aoi")
        if stack is None or aoi is None:
            QMessageBox.warning(
                self, "No stack",
                "The GEE stack is not in memory.\n"
                "Run Tool 1 (Load GEE data) first in this session.")
            return
        if self.cb_mode.currentIndex() == 1:
            try:
                QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
                ee = gu.init_ee()
                return self._run_unsupervised(ee, stack)
            except Exception as e:
                QMessageBox.critical(self, "Clustering failed", str(e))
                return
            finally:
                QApplication.restoreOverrideCursor()
        pts = self.cb_pts.currentLayer()
        if pts is None:
            QMessageBox.warning(self, "Points", "Choose the labeled points layer.")
            return
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            self.txt.setPlainText("Training Random Forest on GEE …")
            QApplication.processEvents()
            ee = gu.init_ee()
            scale = self.sp_scale.value()

            fc_train, n_train = self._points_to_fc(ee, pts, "train")
            fc_valid, n_valid = self._points_to_fc(ee, pts, "valid")
            # count all labeled points (any 'use') to give a precise message
            n_labeled = self._count_labeled(pts)
            if n_labeled < 20:
                raise RuntimeError(
                    "Only {} labeled points found. Label at least ~20 "
                    "(more is better) across your classes in Tool 3, then "
                    "classify.".format(n_labeled))
            if n_train < 10 or n_valid < 10:
                raise RuntimeError(
                    "The train/validation split is uneven "
                    "(train={}, valid={}). Every point needs a 'use' value "
                    "of 'train' or 'valid'. Re-create the points in Tool 2 "
                    "(it sets a 70/30 split automatically) or check the "
                    "'use' field.".format(n_train, n_valid))

            bands = stack.bandNames()
            train_samples = stack.sampleRegions(
                collection=fc_train, properties=["class_code"],
                scale=scale, tileScale=4, geometries=False)
            valid_raw = stack.sampleRegions(
                collection=fc_valid, properties=["class_code"],
                scale=scale, tileScale=4, geometries=False)

            # Verify the sampling actually landed on image pixels. If the
            # points sit outside the stack footprint (e.g. the points were
            # made for a different AOI than the stack now in memory), or on
            # masked/no-data pixels, sampleRegions returns an empty table and
            # GEE later reports "No valid training data were found."
            try:
                n_samp = gu._retry(lambda: train_samples.size().getInfo())
            except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
                n_samp = None
            if n_samp == 0:
                raise RuntimeError(
                    "None of the {} training points returned band values.\n\n"
                    "This means the sample points do not overlap the image "
                    "stack that is loaded. Common causes:\n"
                    "  - The points were created for a different area than "
                    "the stack currently in memory. Re-run Tool 1 for the "
                    "same AOI, then classify.\n"
                    "  - The points fall on masked / cloud / no-data pixels. "
                    "Widen the date range or raise the cloud threshold in "
                    "Tool 1 and rebuild the stack.\n"
                    "  - The points and the AOI use different areas. Make "
                    "the sample points inside the same AOI polygon."
                    .format(n_train))

            clf_name = self.cb_clf.currentText()
            trees = self.sp_trees.value()

            comparison = None
            if self.chk_compare.isChecked():
                comp = {}
                for cname in gu.CLASSIFIERS:
                    c = gu.make_classifier(cname, trees).train(
                        train_samples, "class_code", bands)
                    e = valid_raw.classify(c).errorMatrix(
                        "class_code", "classification")
                    comp[cname] = ee.Dictionary(
                        {"oa": e.accuracy(), "kappa": e.kappa()})
                comparison = ee.Dictionary(comp).getInfo()
                # note: comparison OA/kappa are raw classifier outputs
                # (no post-processing), used for ranking only

            clf = gu.make_classifier(clf_name, trees).train(
                train_samples, "class_code", bands)
            raw_classified = stack.classify(clf).clip(aoi).toByte()
            classified, post_notes = self._post_process(
                raw_classified, aoi, scale)
            self.classified = classified
            self.state["classified"] = classified

            # accuracy is assessed on the FINAL (post-processed) map,
            # so the report describes the actual product
            valid_map = (classified.rename("classification")
                         .sampleRegions(
                             collection=fc_valid,
                             properties=["class_code", "point_id"],
                             scale=scale, tileScale=4, geometries=False))
            em = valid_map.errorMatrix("class_code", "classification")
            # keep the mismatches (reference != predicted) for relabeling
            mismatches = valid_map.filter(
                ee.Filter.notEquals(
                    leftField="class_code", rightField="classification"))
            self._mismatch_fc = mismatches

            areas = (ee.Image.pixelArea().divide(10000)
                     .addBands(classified)
                     .reduceRegion(
                         reducer=ee.Reducer.sum().group(1, "class_code"),
                         geometry=aoi, scale=scale, maxPixels=1e13,
                         bestEffort=True))

            result = ee.Dictionary({
                "matrix": em.array(),
                "oa": em.accuracy(),
                "kappa": em.kappa(),
                "pa": em.producersAccuracy(),
                "ua": em.consumersAccuracy(),
                "areas": areas,
                "importance": ee.Dictionary(clf.explain()).get(
                    "importance", ee.Dictionary({})),
            }).getInfo()
            try:
                mm = gu._retry(lambda: self._mismatch_fc.getInfo())
                self.mismatch_points = [
                    (int(ft["properties"].get("point_id", -1)),
                     int(ft["properties"].get("class_code", 0)),
                     int(ft["properties"].get("classification", 0)))
                    for ft in mm.get("features", [])]
            except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
                self.mismatch_points = []
            result["comparison"] = comparison
            result["clf_name"] = clf_name
            result["post"] = ", ".join(post_notes) if post_notes else "none"

            self._show_report(result, n_train, n_valid, scale)

            if self.cb_level.currentIndex() == 1:
                vis = {"min": 1, "max": 40, "palette": gu.lu2003_palette()}
                lvl = "L2"
            else:
                vis = self._scheme_vis()
                lvl = "L1"
            # keep everything needed to re-apply filters without retraining
            self._last_run = {
                "raw": raw_classified, "aoi": aoi, "scale": scale,
                "fc_valid": fc_valid, "clf_name": clf_name, "lvl": lvl,
                "n_train": n_train, "n_valid": n_valid}
            self.btn_reapply.setEnabled(True)

            prev = gu.add_ee_image_layer(
                classified, vis,
                "LULC {} - {} (preview tiles)".format(
                    lvl, self.state.get("aoi_name", "")))
            # store the preview layer id immediately so the Map Layout tab
            # can find the classified layer even before the local download
            # (below) finishes. The download later overwrites this id with
            # the faster local GeoTIFF.
            if prev is not None:
                self.state["classified_layer_id"] = prev.id()
            # tiles re-classify on the fly and are slow: download the
            # final map locally in the background for fast display
            self._start_classified_download(classified, lvl, scale)
        except Exception as e:
            self.txt.setPlainText("Error: {}\n\n{}".format(
                e, traceback.format_exc()))
        finally:
            QApplication.restoreOverrideCursor()

    def _run_unsupervised(self, ee, stack):
        aoi = self.state.get("aoi")
        scale = int(self.sp_scale.value())
        n = self.sp_nclusters.value()
        algo = self.cb_cluster.currentText()
        self.lbl_note.setText("Clustering into {} groups ...".format(n))
        QApplication.processEvents()
        clustered = gu.unsupervised_classify(
            stack, aoi, n_clusters=n, scale=scale, algorithm=algo)
        self._cluster_img = clustered
        self._cluster_aoi = aoi
        self._cluster_scale = scale
        pal = ["#e6194b", "#3cb44b", "#ffe119", "#4363d8", "#f58231",
               "#911eb4", "#46f0f0", "#f032e6", "#bcf60c", "#fabebe",
               "#008080", "#e6beff", "#9a6324", "#fffac8", "#800000",
               "#aaffc3", "#808000", "#ffd8b1", "#000075", "#808080"]
        vis = {"min": 0, "max": n - 1, "palette": pal[:n]}
        gu.add_ee_image_layer(clustered, vis,
                              "Clusters ({}, {})".format(
                                  algo.split(" ")[0], n))
        self._start_classified_download(clustered, "clusters", scale)
        self.btn_reclass.setEnabled(True)
        self.txt.setPlainText(
            "UNSUPERVISED CLUSTERING\n" + "=" * 40 +
            "\nAlgorithm: {}\nClusters: {}\nScale: {} m\n\n"
            "Each colour is a spectral cluster, not yet a land-cover "
            "class. Inspect the clusters against the imagery, then click "
            "'Reclassify clusters ...' to assign each one to a class "
            "(several clusters can map to one class).".format(
                algo, n, scale))
        self.lbl_note.setText(
            "Clustering done. Next: 'Reclassify clusters ...' to turn "
            "clusters into a land-cover map.")

    # ------------------------------------------------------- row visibility
    def _set_row_visible(self, widget, visible):
        """Show/hide a QFormLayout row *including its label*.

        setVisible() on the field widget alone leaves the label stranded
        (the 'Cluster algorithm:' text with nothing beside it). Qt >= 6.4
        has setRowVisible(); on older builds we hide the label ourselves.
        """
        form = getattr(self, "_form_inputs", None)
        if form is None:
            widget.setVisible(visible)
            return
        try:
            form.setRowVisible(widget, visible)
            return
        except (AttributeError, TypeError):  # Qt < 6.4
            pass
        try:
            lbl = form.labelForField(widget)
            if lbl is not None:
                lbl.setVisible(visible)
        except (AttributeError, TypeError, RuntimeError):  # Qt/QGIS API
            pass
        widget.setVisible(visible)

    def _algo_changed(self, *_):
        """Only tree-based classifiers have a 'number of trees'."""
        unsup = self.cb_mode.currentIndex() == 1
        name = self.cb_clf.currentText()
        tree_based = (not unsup) and (
            "Forest" in name or "Tree Boost" in name)
        self._set_row_visible(self.sp_trees, tree_based)

    def _mode_changed(self, idx):
        unsup = idx == 1

        # supervised-only rows. 'Active scheme' stays visible in BOTH modes:
        # unsupervised still needs it, because 'Reclassify clusters' offers
        # exactly the classes of the active scheme.
        for w in (self.cb_pts, self.cb_level,
                  self.cb_clf, self.chk_compare):
            self._set_row_visible(w, not unsup)
        # unsupervised-only rows
        for w in (self.cb_cluster, self.sp_nclusters):
            self._set_row_visible(w, unsup)
        # 'number of trees' depends on the chosen classifier as well
        self._algo_changed()

        # post-classification buttons follow the same rule
        self.btn_reclass.setVisible(unsup)
        self.btn_edit_pts.setVisible(not unsup)

        if unsup:
            self.btn_run.setText("Run clustering")
            self.lbl_note.setText(
                "Unsupervised: pixels are grouped by spectral similarity. "
                "No labels needed. After clustering, use 'Reclassify "
                "clusters' to assign each cluster to a land-cover class.")
        else:
            self.btn_run.setText(
                "Run classification + accuracy assessment")
            self.lbl_note.setText("")

    def reclassify_clusters(self):
        """Map cluster IDs to LULC classes (turns an unsupervised result
        into a labelled map). Opens a small table: cluster -> class."""
        if getattr(self, "_cluster_img", None) is None:
            QMessageBox.information(
                self, "Cluster first",
                "Run unsupervised clustering first, then reclassify.")
            return
        from qgis.PyQt.QtWidgets import (QDialog as _D, QVBoxLayout as _V,
            QFormLayout as _F, QComboBox as _C, QDialogButtonBox as _B,
            QLabel as _L)
        dlg = _D(self)
        dlg.setWindowTitle("Reclassify clusters -> LULC classes")
        v = _V(dlg)
        v.addWidget(_L("Assign each spectral cluster to a land-cover class. "
                       "Use the clustered map on the canvas as a guide."))
        form = _F()
        combos = {}
        n = self.sp_nclusters.value()
        class_items = ["(ignore)"] + ["{} {}".format(c, nm)
                                      for c, nm, _ in self._scheme_class_list()]
        for cid in range(n):
            cb = _C(); cb.addItems(class_items)
            form.addRow("Cluster {}".format(cid), cb)
            combos[cid] = cb
        v.addLayout(form)
        bb = _B(_B.Ok | _B.Cancel)
        bb.accepted.connect(dlg.accept); bb.rejected.connect(dlg.reject)
        v.addWidget(bb)
        if not dlg.exec():
            return
        # build the remap
        froms, tos = [], []
        for cid, cb in combos.items():
            idx = cb.currentIndex()
            if idx > 0:                       # 0 = ignore
                froms.append(cid)
                tos.append(self._scheme_class_list()[idx - 1][0])
        if not froms:
            return
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            gu.init_ee()
            remapped = (self._cluster_img.remap(froms, tos, 0)
                        .clip(self._cluster_aoi).toByte())
            self.classified = remapped
            self.state["classified"] = remapped
            vis = self._scheme_vis()
            gu.add_ee_image_layer(remapped, vis,
                                  "LULC (reclassified from clusters)")
            self._start_classified_download(remapped, "L1",
                                            self._cluster_scale)
            self.lbl_note.setText(
                "Reclassified {} clusters into LULC classes. The labelled "
                "map is now the active classification - you can run Tool 5 "
                "or add validation points and check accuracy.".format(
                    len(froms)))
        except Exception as e:
            QMessageBox.critical(self, "Reclassify failed", str(e))
        finally:
            QApplication.restoreOverrideCursor()

    # ------------------------------------------------------------ post-proc
    def _post_process(self, classified, aoi, scale):
        """Apply the cartographic cleaning options. Returns (image, notes)."""
        notes = []
        if self.chk_majority.isChecked():
            classified = classified.focal_mode(1, "square", "pixels")
            notes.append("3x3 majority filter")
        mmu_ha = self.sp_mmu.value()
        if mmu_ha > 0:
            px = int(round(mmu_ha * 10000.0 / (scale * scale)))
            if px > 1:
                patch = classified.connectedPixelCount(px + 1, True)
                mode2 = classified.focal_mode(2, "square", "pixels")
                classified = classified.where(patch.lt(px), mode2)
                notes.append("MMU {} ha (< {} px replaced by mode)".format(
                    mmu_ha, px))
        if self.chk_fill.isChecked():
            # a lone pixel differing from all neighbours is filled by the mode
            single = classified.connectedPixelCount(2, True).eq(1)
            classified = classified.where(
                single, classified.focal_mode(1, "square", "pixels"))
            notes.append("single-pixel holes filled")
        for _ in range(self.sp_smooth.value()):
            classified = classified.focal_mode(1, "circle", "pixels")
        if self.sp_smooth.value():
            notes.append("{} boundary smoothing pass(es)".format(
                self.sp_smooth.value()))
        return classified.clip(aoi).toByte(), notes

    def open_labeling(self):
        """Jump back to Tool 3 to fix labels, then re-run classification."""
        # inside the combined window: just switch to the Label tab
        cb = getattr(self, "switch_to_labeling", None)
        if callable(cb):
            cb()
            self.lbl_note.setText(
                "Switched to the Label points tab. Fix labels, then return "
                "here and run the classification again.")
            return
        try:
            from qgis.utils import plugins
            for p in plugins.values():
                if hasattr(p, "show_labeling"):
                    p.show_labeling()
                    self.lbl_note.setText(
                        "Labeling panel opened. Fix labels (tip: use "
                        "'Flag misclassified points' first), then run the "
                        "classification again to update the output.")
                    return
        except (AttributeError, TypeError):  # Qt/QGIS API compatibility
            pass
        QMessageBox.information(
            self, "Tool 3",
            "Open '3. Label sample points' from the plugin menu, fix the "
            "labels, then run the classification again.")

    def reapply_post(self):
        """Re-run filters + accuracy on the SAME trained model (no retrain)."""
        if getattr(self, "_last_run", None) is None:
            QMessageBox.information(self, "Run first",
                                    "Run a classification first.")
            return
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            ee = gu.init_ee()
            r = self._last_run
            classified, notes = self._post_process(
                r["raw"], r["aoi"], r["scale"])
            self.classified = classified
            self.state["classified"] = classified
            valid_map = (classified.rename("classification")
                         .sampleRegions(
                             collection=r["fc_valid"],
                             properties=["class_code", "point_id"],
                             scale=r["scale"], tileScale=4,
                             geometries=False))
            em = valid_map.errorMatrix("class_code", "classification")
            areas = (ee.Image.pixelArea().divide(10000)
                     .addBands(classified)
                     .reduceRegion(
                         reducer=ee.Reducer.sum().group(1, "class_code"),
                         geometry=r["aoi"], scale=r["scale"],
                         maxPixels=1e13, bestEffort=True))
            res = ee.Dictionary({
                "matrix": em.array(), "oa": em.accuracy(),
                "kappa": em.kappa(), "pa": em.producersAccuracy(),
                "ua": em.consumersAccuracy(), "areas": areas,
                "importance": ee.Dictionary({}),
            }).getInfo()
            res["comparison"] = None
            res["clf_name"] = r["clf_name"] + " (re-applied)"
            res["post"] = ", ".join(notes) if notes else "none"
            self._show_report(res, r["n_train"], r["n_valid"], r["scale"])
            vis = ({"min": 1, "max": 40, "palette": gu.lu2003_palette()}
                   if r["lvl"] == "L2" else
                   self._scheme_vis())
            gu.add_ee_image_layer(
                classified, vis, "LULC {} - {} (re-processed)".format(
                    r["lvl"], self.state.get("aoi_name", "")))
            self._start_classified_download(classified, r["lvl"], r["scale"])
        except Exception as e:
            QMessageBox.critical(self, "Re-apply failed", str(e))
        finally:
            QApplication.restoreOverrideCursor()

    # ------------------------------------------------------------ local map
    def _start_classified_download(self, classified, lvl, scale):
        aoi = self.state.get("aoi")
        aoi_name = self.state.get("aoi_name") or "aoi"
        safe = re.sub(r"[^A-Za-z0-9_-]+", "_", aoi_name)[:40]
        path = os.path.join(tempfile.gettempdir(),
                            "lulc_{}_{}.tif".format(lvl, safe))
        dlg = self

        class _Task(QgsTask):
            def __init__(t):
                super().__init__("GEE: LULC map download ({})"
                                 .format(aoi_name), QgsTask.Flag.CanCancel)
                t.error = None
                t.msg = "starting"

            def run(t):
                try:
                    def prog(frac, msg):
                        t.msg = msg
                        t.setProgress(min(99.0, frac * 100.0))
                        if t.isCanceled():
                            raise RuntimeError("canceled by user")
                    gu.download_stack_smart(classified, aoi, scale, path,
                                            1, progress=prog)
                    return True
                except Exception as e:
                    t.error = str(e)
                    return False

            def finished(t, ok):
                dlg._classified_done(ok, t, path, lvl, aoi_name)

        self._map_task = _Task()
        self._map_task.progressChanged.connect(
            lambda p: self.lbl_note.setText(
                "Downloading LULC map for fast display: {} ({} %)".format(
                    self._map_task.msg, int(p))))
        QgsApplication.taskManager().addTask(self._map_task)
        self.lbl_note.setText(
            "LULC map downloading in the background for fast display - "
            "the tile preview is already on the canvas.")

    def _classified_done(self, ok, task, path, lvl, aoi_name):
        if not ok:
            self.lbl_note.setText(
                "Local LULC map failed ({}). The tile preview still "
                "works; you can also use Drive export.".format(task.error))
            return
        lyr = QgsRasterLayer(path, "LULC {} - {} (local)".format(
            lvl, aoi_name))
        if not lyr.isValid():
            self.lbl_note.setText("Local LULC map could not be loaded.")
            return
        QgsProject.instance().addMapLayer(lyr)
        if lvl == "L2":
            gu.apply_lu2003_style(lyr)
        else:
            gu.apply_lulc_style(lyr)
        self.state["classified_layer_id"] = lyr.id()
        self.lbl_note.setText(
            "Local LULC map loaded (fast display): {}\n"
            "Tool 5 will pick this layer automatically.".format(path))

    # ------------------------------------------------------------ Olofsson
    @staticmethod
    def _olofsson_lines(r, names):
        """Area-adjusted accuracy and error-adjusted areas with 95% CIs,
        following Olofsson et al. (2014), RSE 148:42-57. Rows of the GEE
        matrix are reference, columns are map; Olofsson works with
        map-rows, so the matrix is transposed here."""
        import math
        M = r.get("matrix")
        groups = (r.get("areas") or {}).get("groups", [])
        if not M or not groups:
            return []
        area = {int(g["class_code"]): float(g["sum"]) for g in groups}
        codes = [cc for cc in sorted(area) if 0 < cc < len(M)
                 and area[cc] > 0]
        if len(codes) < 2:
            return []
        A_tot = sum(area[cc] for cc in codes)
        # n[i][j]: i = map class, j = reference class (transpose of GEE)
        n = {i: {j: float(M[j][i]) for j in codes} for i in codes}
        n_row = {i: sum(n[i].values()) for i in codes}
        if any(n_row[i] < 2 for i in codes):
            return ["Area-adjusted accuracy (Olofsson 2014): skipped - a",
                    "mapped class has < 2 validation points."]
        W = {i: area[i] / A_tot for i in codes}
        p = {i: {j: W[i] * n[i][j] / n_row[i] for j in codes}
             for i in codes}
        p_col = {j: sum(p[i][j] for i in codes) for j in codes}
        p_row = {i: sum(p[i][j] for j in codes) for i in codes}
        oa = sum(p[i][i] for i in codes)

        lines = []
        lines.append("Area-adjusted accuracy (Olofsson et al. 2014):")
        lines.append("Adjusted OA: {:.4f}".format(oa))
        lines.append("{:>13} {:>9} {:>9} {:>15} {:>13}".format(
            "Class", "adj PA", "adj UA", "Adj. area (ha)", "+/-95% CI"))
        for j in codes:
            pa = p[j][j] / p_col[j] if p_col[j] > 0 else 0.0
            ua = p[j][j] / p_row[j] if p_row[j] > 0 else 0.0
            a_hat = A_tot * p_col[j]
            se = math.sqrt(sum(
                (W[i] ** 2) * (n[i][j] / n_row[i])
                * (1.0 - n[i][j] / n_row[i]) / (n_row[i] - 1.0)
                for i in codes))
            ci = 1.96 * se * A_tot
            lines.append("{:>13} {:>9.3f} {:>9.3f} {:>15,.1f} {:>13,.1f}"
                         .format(str(names.get(j, j))[:13], pa, ua,
                                 a_hat, ci))
        return lines

    # ------------------------------------------------------------ report
    def _show_report(self, r, n_train, n_valid, scale):
        names = self._class_names()
        lines = []
        lines.append("RANDOM FOREST LULC CLASSIFICATION REPORT")
        lines.append("=" * 56)
        lines.append("AOI: {}".format(self.state.get("aoi_name", "-")))
        lines.append("Bands: {}".format(
            ", ".join(self.state.get("band_names", []))))
        lines.append("Classifier: {}   Trees: {}   Scale: {} m".format(
            r.get("clf_name", "Random Forest"), self.sp_trees.value(), scale))
        lines.append("Post-processing: {}   (accuracy assessed on the "
                     "final map)".format(r.get("post", "none")))
        lines.append("Training points: {}   Validation points: {}".format(
            n_train, n_valid))
        lines.append("")
        lines.append("Overall Accuracy: {:.4f}".format(r["oa"]))
        lines.append("Kappa:            {:.4f}".format(r["kappa"]))
        lines.append("")
        if r.get("comparison"):
            lines.append("Classifier comparison (same train/valid split):")
            for cname in sorted(r["comparison"],
                                key=lambda k: -r["comparison"][k]["oa"]):
                d = r["comparison"][cname]
                lines.append("{:>20}:  OA {:.4f}   Kappa {:.4f}".format(
                    cname, d["oa"], d["kappa"]))
            lines.append("")

        m = r["matrix"]
        codes = list(range(len(m)))
        used = [c for c in codes if any(m[c]) or any(row[c] for row in m)]
        lines.append("Confusion matrix (rows = reference, cols = predicted):")
        header = "{:>10}".format("") + "".join(
            "{:>10}".format(names.get(c, str(c))[:9]) for c in used)
        lines.append(header)
        for c in used:
            row = "{:>10}".format(names.get(c, str(c))[:9])
            row += "".join("{:>10}".format(m[c][p]) for p in used)
            lines.append(row)
        lines.append("")

        pa = r["pa"]; ua = r["ua"]
        lines.append("{:>12} {:>12} {:>12}".format(
            "Class", "Producer's", "User's"))
        for c in used:
            if c == 0:
                continue
            pav = pa[c][0] if c < len(pa) else 0
            uav = ua[0][c] if c < len(ua[0]) else 0
            lines.append("{:>12} {:>12.4f} {:>12.4f}".format(
                names.get(c, str(c)), pav, uav))
        lines.append("")

        groups = (r.get("areas") or {}).get("groups", [])
        if groups:
            lines.append("Class areas:")
            total = sum(g["sum"] for g in groups)
            for g in sorted(groups, key=lambda x: x["class_code"]):
                c = int(g["class_code"])
                lines.append("{:>12}: {:>14,.1f} ha  ({:.1f} %)".format(
                    names.get(c, str(c)), g["sum"],
                    100 * g["sum"] / total if total else 0))
            lines.append("{:>12}: {:>14,.1f} ha".format("TOTAL", total))
        lines.append("")

        try:
            oloff = self._olofsson_lines(r, names)
            if oloff:
                lines.extend(oloff)
                lines.append("")
        except Exception as e:
            lines.append("(Olofsson section skipped: {})".format(e))
            lines.append("")

        imp = r.get("importance") or {}
        if imp:
            lines.append("Band importance (relative):")
            mx = max(imp.values()) or 1
            for b, v in sorted(imp.items(), key=lambda x: -x[1]):
                lines.append("{:>12}: {:>7.3f}  {}".format(
                    b, v / mx, "#" * int(20 * v / mx)))

        self.report_text = "\n".join(lines)
        self.txt.setPlainText(self.report_text)

        # compact summary for the LULC Map Layout (below the legend)
        s = ["{} | OA: {:.1%} | Kappa: {:.3f}".format(
            r.get("clf_name", "RF"), r["oa"], r["kappa"])]
        s.append("{:<14}{:>7}{:>9}".format("Class", "PA", "UA"))
        for c in used:
            if c == 0:
                continue
            pav = pa[c][0] if c < len(pa) else 0
            uav = ua[0][c] if c < len(ua[0]) else 0
            s.append("{:<14}{:>7.2f}{:>9.2f}".format(
                names.get(c, str(c))[:13], pav, uav))
        s.append("Valid. points: n = {}".format(n_valid))
        try:
            ol = self._olofsson_lines(r, names)
            for ln in ol:
                if ln.startswith("Adjusted OA"):
                    s.insert(1, ln + " (Olofsson 2014)")
                    break
        except (AttributeError, TypeError, RuntimeError):  # Safe fallback
            pass
        self.state["report_summary"] = "\n".join(s)

    # ------------------------------------------------------------ outputs
    def save_report(self):
        if not self.report_text:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Save report", "rf_accuracy_report.txt", "Text (*.txt)")
        if path:
            with open(path, "w", encoding="utf-8") as f:
                f.write(self.report_text)

    def flag_misclassified(self):
        """Mark validation points the classifier disagrees with, add a
        'check' field, and select them so you can review/relabel in Tool 3.
        These are the points most likely to be labeling errors."""
        pts = self.cb_pts.currentLayer()
        if pts is None or not getattr(self, "mismatch_points", None):
            QMessageBox.information(
                self, "No mismatches",
                "Run a classification first. If there are still no "
                "mismatches, your validation points all agree with the "
                "map (good!).")
            return
        from qgis.PyQt.QtCore import QVariant
        from qgis.core import QgsField
        names = [f.name() for f in pts.fields()]
        if "check" not in names:
            pts.dataProvider().addAttributes(
                [QgsField("check", QVariant.String)])
            pts.updateFields()
        idx_check = pts.fields().indexOf("check")
        idx_pid = pts.fields().indexOf("point_id")
        cls_names = self._class_names()
        by_pid = {pid: (ref, pred) for pid, ref, pred
                  in self.mismatch_points if pid > 0}
        sel_ids = []
        changes = {}
        for f in pts.getFeatures():
            pid = int(f["point_id"]) if idx_pid >= 0 \
                and f["point_id"] not in (None, "") else -1
            if pid in by_pid:
                ref, pred = by_pid[pid]
                changes[f.id()] = {idx_check: "labeled={} / map={}".format(
                    cls_names.get(ref, ref), cls_names.get(pred, pred))}
                sel_ids.append(f.id())
        pts.dataProvider().changeAttributeValues(changes)
        pts.selectByIds(sel_ids)
        try:
            self.iface.setActiveLayer(pts)
            self.iface.actionZoomToSelected().trigger()
        except (ImportError, ModuleNotFoundError, RuntimeError):  # GEE/Import
            pass
        self.lbl_note.setText(
            "Flagged {} disagreeing validation points (selected, with a "
            "'check' note). Open Tool 3, use 'Next unlabeled' or step "
            "through the selected points, and fix any wrong labels - then "
            "re-run classification.".format(len(sel_ids)))

    def export_drive(self):
        if self.classified is None:
            QMessageBox.information(self, "Classify first",
                                    "Run the classification first.")
            return
        try:
            QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
            name = gu.export_to_drive(
                self.classified, self.state["aoi"], self.sp_scale.value(),
                "lulc_" + (self.state.get("aoi_name") or "map"))
            QMessageBox.information(
                self, "Drive export started",
                "Export task '{}' started.\nThe classified GeoTIFF will "
                "appear in your Google Drive folder '{}'. Watch progress "
                "in the Tasks tab of the GEE Code Editor.".format(
                    name, gu.DRIVE_FOLDER))
        except Exception as e:
            QMessageBox.critical(self, "Drive export failed", str(e))
        finally:
            QApplication.restoreOverrideCursor()

    def reset_defaults(self):
        self.cb_mode.setCurrentIndex(0)
        self.cb_clf.setCurrentIndex(0)
        self.cb_cluster.setCurrentIndex(0)
        self.sp_nclusters.setValue(8)
        self._cluster_img = None
        self.btn_reclass.setEnabled(False)
        self._mode_changed(0)
        self.chk_compare.setChecked(False)
        self.sp_trees.setValue(300)
        self.sp_scale.setValue(int(self.state.get("scale") or 10))
        self.chk_majority.setChecked(True)
        self.sp_mmu.setValue(0.5)
        self.chk_fill.setChecked(True)
        self.sp_smooth.setValue(0)
        self._last_run = None
        self.btn_reapply.setEnabled(False)
        self.txt.clear()
        self.report_text = ""
