# -*- coding: utf-8 -*-
"""📦 Create Project Panel module.

This module contains functionality for create project panel.
"""

import json
import os
import shutil
import sqlite3
import time
import traceback

from qgis.core import (
    Qgis,
    QgsCoordinateReferenceSystem,
    QgsDistanceArea,
    QgsFeedback,
    QgsFieldProxyModel,
    QgsLayerTreeGroup,
    QgsMapLayerProxyModel,
    QgsProject,
    QgsUnitTypes,
    QgsVectorLayer,
)
from qgis.PyQt.QtCore import QSettings, pyqtSignal
from qgis.PyQt.QtGui import QFont, QPixmap
from qgis.PyQt.QtWidgets import QComboBox, QFileDialog, QMessageBox, QWidget

from geest.core import WorkflowQueueManager
from geest.core.h3_utils import (
    estimate_h3_cells_for_area,
    h3_cell_area_km2,
    suggest_coarser_resolution,
    suggest_finer_resolution,
)
from geest.core.tasks import StudyAreaProcessingTask, StudyAreaReportTask
from geest.gui.widgets import CustomBannerLabel
from geest.utilities import (
    calculate_utm_zone_from_layer,
    get_ui_class,
    linear_interpolation,
    log_message,
    resources_path,
)

FORM_CLASS = get_ui_class("create_project_panel_base.ui")


class CreateProjectPanel(FORM_CLASS, QWidget):
    """🎯 Create Project Panel.

    Attributes:
        queue_manager: Queue manager.
        settings: Settings.
        working_dir: Working dir.
    """

    switch_to_next_tab = pyqtSignal()  # Signal to notify the parent to switch tabs
    # Signal to notify the parent to switch tabs
    switch_to_previous_tab = pyqtSignal()
    # Signal to set the working directory
    working_directory_changed = pyqtSignal(str)

    MIN_H3_ESTIMATED_CELLS = 3
    MAX_H3_ESTIMATED_CELLS = 200000

    def __init__(self):
        """🏗️ Initialize the instance."""
        super().__init__()
        self.setWindowTitle("GeoE3")
        # For running study area processing in a separate thread
        self.queue_manager = WorkflowQueueManager(pool_size=1)

        self.working_dir = ""
        self.study_area_task = None
        self.settings = QSettings()  # Initialize QSettings to store and retrieve settings
        self._last_map_refresh_ts = 0.0
        self._incomplete_setup_warned = False
        # Dynamically load the .ui file
        self.setupUi(self)
        log_message("Loading setup panel")
        self.initUI()

    def _should_skip_map_refresh(self) -> bool:
        """Rate-limit map refresh attempts while study area is writing.

        While the study area task's unified writer is active every map
        reload opens fresh read connections against a GeoPackage under
        heavy write load, so refreshes are spaced well apart; once the
        task has finished the usual snappy interval applies.
        """
        interval = 5.0 if getattr(self, "study_area_task", None) is not None else 0.5
        now = time.monotonic()
        if now - self._last_map_refresh_ts < interval:
            return True
        self._last_map_refresh_ts = now
        return False

    @staticmethod
    def _gpkg_metadata_ready(gpkg_path: str) -> bool:
        """Return True when required GeoPackage metadata tables exist."""
        try:
            conn = sqlite3.connect(gpkg_path, timeout=0.2)
            try:
                cursor = conn.cursor()
                cursor.execute(
                    "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('gpkg_spatial_ref_sys','gpkg_contents')"
                )
                names = {row[0] for row in cursor.fetchall()}
                return "gpkg_spatial_ref_sys" in names and "gpkg_contents" in names
            finally:
                conn.close()
        except Exception:
            return False

    def _study_area_outputs_ready(self, working_dir: str) -> bool:
        """Return True when step-1 study area outputs are complete and usable."""
        if not working_dir:
            return False

        gpkg_path = os.path.join(working_dir, "study_area", "study_area.gpkg")
        if not os.path.exists(gpkg_path):
            return False

        if not self._gpkg_metadata_ready(gpkg_path):
            return False

        layer = QgsVectorLayer(f"{gpkg_path}|layername=study_area_polygons", "study_area", "ogr")
        if not layer.isValid():
            return False

        try:
            return layer.featureCount() > 0
        except Exception:
            return False

    def initUI(self):
        """⚙️ Initui."""
        self.enable_widgets()  # Re-enable widgets in case they were disabled
        self.custom_label = CustomBannerLabel(
            "The Geospatial Enabling Environments for Employment Spatial Tool",
            resources_path("resources", "geoe3-banner.png"),
        )
        parent_layout = self.banner_label.parent().layout()
        parent_layout.replaceWidget(self.banner_label, self.custom_label)
        self.banner_label.deleteLater()
        parent_layout.update()

        self.folder_status_label.setPixmap(QPixmap(resources_path("resources", "icons", "failed.svg")))
        self.regional_scale.clicked.connect(lambda: self.spatial_scale_changed("regional"))
        self.national_scale.clicked.connect(lambda: self.spatial_scale_changed("national"))
        self.local_scale.clicked.connect(lambda: self.spatial_scale_changed("local"))
        if self.regional_scale.isChecked():
            self.spatial_scale_changed("regional")
        elif self.local_scale.isChecked():
            self.spatial_scale_changed("local")
        else:
            self.spatial_scale_changed("national")
        self.layer_combo.setFilters(QgsMapLayerProxyModel.Filter.PolygonLayer)
        if hasattr(self.layer_combo, "setAllowEmptyLayer"):
            self.layer_combo.setAllowEmptyLayer(True)
        # Regional scale uses H3 hexagonal grids (L6 resolution)
        # National and Local scales use square grids
        # Local mode enabled for National vs Local analysis implementation
        # self.local_scale.setEnabled(False)
        # self.regional_scale.setEnabled(False)
        self.regional_scale.setStyleSheet("QRadioButton:disabled { color: grey; }")

        self.h3_resolution_label = self.label
        self.h3_resolution_label.setText("H3 grid resolution")
        self.h3_resolution_combo = QComboBox()
        for resolution in range(16):
            if resolution == 6:
                self.h3_resolution_combo.addItem(f"{resolution} (recommended)", resolution)
            else:
                self.h3_resolution_combo.addItem(str(resolution), resolution)
        self.h3_resolution_combo.setCurrentIndex(6)
        self.h3_resolution_combo.setToolTip("Higher H3 resolutions are finer and may require significantly more time.")

        scale_layout = self.groupBox.layout()
        scale_layout.addWidget(self.h3_resolution_combo, 3, 1, 1, 2)
        if self.regional_scale.isChecked():
            self.spatial_scale_changed("regional")
        elif self.local_scale.isChecked():
            self.spatial_scale_changed("local")
        else:
            self.spatial_scale_changed("national")

        # Women Considerations toggle
        self.women_considerations_checkbox.stateChanged.connect(self.women_considerations_changed)
        # Initialize EPLEX widgets visibility based on checkbox state
        self.women_considerations_changed()

        # self.field_combo = QgsFieldComboBox()  # QgsFieldComboBox for selecting fields
        self.field_combo.setFilters(QgsFieldProxyModel.String)

        # Link the map layer combo box with the field combo box
        self.layer_combo.layerChanged.connect(self.layer_changed)
        self.field_combo.setLayer(self.layer_combo.currentLayer())

        self.create_project_directory_button.clicked.connect(self.create_new_project_folder)
        # We only allow the user to select a CRS based on the admin layer
        # if the admin CRS is not WGS84
        self.use_boundary_crs.setChecked(False)
        self.use_boundary_crs.setEnabled(False)
        self.use_boundary_crs.toggled.connect(self.update_crs)  # Update the CRS label when the checkbox is toggled

        self.next_button.clicked.connect(self.create_project)
        self.previous_button.clicked.connect(self.on_previous_button_clicked)

        self.load_boundary_button.clicked.connect(self.load_boundary)

        self.progress_bar.setVisible(False)
        self.child_progress_bar.setVisible(False)
        self.processing_info_label.setVisible(False)
        self.processing_info_label.setText("")
        # Ensure crs is set on first load
        self.layer_changed(self.layer_combo.currentLayer())

    def _refresh_boundary_crs_checkbox(self, layer: QgsVectorLayer) -> None:
        """Refresh boundary CRS checkbox state for selected layer."""
        if not layer:
            self.use_boundary_crs.setChecked(False)
            self.use_boundary_crs.setEnabled(False)
            return

        if layer.crs().authid() == "EPSG:4326":
            self.use_boundary_crs.setChecked(False)
            self.use_boundary_crs.setEnabled(False)
            return

        self.use_boundary_crs.setEnabled(True)

    def layer_changed(self, layer):
        """Slot to be called when the layer in the combo box changes.

        Args:
            layer: The new layer selected in the combo box.
        """
        _ = layer
        current_layer = self.layer_combo.currentLayer()
        log_message(f"Layer changed: {current_layer.name() if current_layer else 'None'}")

        log_message(f"Layer crs: {current_layer.crs().authid() if current_layer else 'None'}")
        if current_layer:
            self.field_combo.setLayer(current_layer)
        else:
            self.field_combo.clear()
        self._refresh_boundary_crs_checkbox(current_layer)

        try:
            current_crs = self.crs()
            if current_crs is not None and current_crs.authid():
                self.crs_label.setText(f"CRS: {current_crs.authid()}")
            else:
                self.crs_label.setText("CRS: Not set")
        except Exception as error:
            log_message(f"Could not resolve CRS after layer change: {error}", tag="GeoE3", level=Qgis.Warning)
            self.crs_label.setText("CRS: Not set")

    def spatial_scale_changed(self, value: str):
        """Slot to be called when the spatial scale changes.

        Args:
            value (str): The new spatial scale value ("regional", "national", or "local").
        """
        log_message(f"Spatial scale changed: {value}")
        if value == "regional":
            self.description2.setText(
                "Regional scale uses H3 hexagonal grids and global datasets to provide broad strategic coverage "
                "across large areas with manageable processing times.\n\n"
                "Choose an H3 resolution for analysis detail. Lower resolutions run faster and summarize patterns "
                "over larger hexagons, while higher resolutions are finer but can be much more computationally expensive."
            )
            self.description2.show()
            # Regional scale uses H3 hexagonal grids (resolution L6) - fixed size
            self.cell_size_spinbox.hide()
            if hasattr(self, "h3_resolution_label"):
                self.h3_resolution_label.show()
            if hasattr(self, "h3_resolution_combo"):
                self.h3_resolution_combo.show()
        elif value == "national":
            self.description2.setText(
                "National scale uses square grid cells and is designed for country-wide analysis with a balance "
                "between spatial detail and runtime.\n\n"
                "Set the analysis grid size (m). Smaller sizes require longer processing times but produce more "
                "detailed analysis results. Recommended grid sizes are available by default."
            )
            self.cell_size_spinbox.show()
            self.description2.show()
            self.cell_size_spinbox.setValue(1000)
            self.cell_size_spinbox.setSingleStep(100)
            self.cell_size_spinbox.setSuffix(" m")
            if hasattr(self, "h3_resolution_label"):
                self.h3_resolution_label.hide()
            if hasattr(self, "h3_resolution_combo"):
                self.h3_resolution_combo.hide()
        elif value == "local":
            self.description2.setText(
                "Local scale uses square grid cells for subnational or city-level analysis where fine detail is most important.\n\n"
                "Set a small analysis grid size (m) for higher spatial precision. Smaller sizes significantly increase "
                "processing time, so start with recommended values and refine as needed."
            )
            self.cell_size_spinbox.show()
            self.description2.show()
            self.cell_size_spinbox.setValue(100)
            self.cell_size_spinbox.setSingleStep(10)
            self.cell_size_spinbox.setSuffix(" m")
            if hasattr(self, "h3_resolution_label"):
                self.h3_resolution_label.hide()
            if hasattr(self, "h3_resolution_combo"):
                self.h3_resolution_combo.hide()

    def women_considerations_changed(self):
        """Slot to be called when the women considerations checkbox changes."""
        is_checked = self.women_considerations_checkbox.isChecked()
        log_message(f"Women considerations changed: {is_checked}")

    def update_crs(self):
        """Update the CRS label based on the checkbox state."""
        if self.use_boundary_crs.isChecked():
            self.crs_label.setText(f"CRS: {self.layer_combo.currentLayer().crs().authid()}")
        else:
            epsg = calculate_utm_zone_from_layer(self.layer_combo.currentLayer())
            self.crs_label.setText(f"CRS: EPSG:{epsg}")  # noqa E231

    def on_previous_button_clicked(self):
        """⚙️ On previous button clicked."""
        self.switch_to_previous_tab.emit()

    def load_boundary(self):
        """Load a boundary layer from a file."""
        file_dialog = QFileDialog()
        file_dialog.setFileMode(QFileDialog.FileMode.ExistingFile)
        file_dialog.setNameFilter("Shapefile (*.shp);;GeoPackage (*.gpkg)")
        if file_dialog.exec():
            file_path = file_dialog.selectedFiles()[0]
            layer = QgsVectorLayer(file_path, "Boundary", "ogr")
            if not layer.isValid():
                QMessageBox.critical(self, "Error", "Could not load the boundary layer.")
                return
            # Load the layer in QGIS
            QgsProject.instance().addMapLayer(layer)
            self.layer_combo.setLayer(layer)
            self.field_combo.setLayer(layer)
            self._refresh_boundary_crs_checkbox(self.layer_combo.currentLayer())

    def create_new_project_folder(self):
        """⚙️ Create new project folder."""
        directory = QFileDialog.getExistingDirectory(self, "Create New Project Folder", self.working_dir)
        if directory:
            self.working_dir = directory
            self.update_recent_projects(directory)  # Update recent projects
            self.settings.setValue("last_working_directory", directory)  # Update last used project
            self.project_path_label.setText(directory)
            self.create_project_directory_button.setText("📂 Change Project Folder")
            self.folder_status_label.setPixmap(QPixmap(resources_path("resources", "icons", "completed-success.svg")))

    def create_project(self):
        """Triggered when the Continue button is pressed."""
        self.disable_widgets()
        if self.use_boundary_crs.isChecked():
            crs = self.layer_combo.currentLayer().crs()
        else:
            crs = None  # will be calculated from UTM zone

        model_path = os.path.join(self.working_dir, "model.json")
        model_exists = os.path.exists(model_path)
        study_area_ready = self._study_area_outputs_ready(self.working_dir)

        if model_exists and study_area_ready:
            self.settings.setValue("last_working_directory", self.working_dir)  # Update last used project
            self.enable_widgets()
            # Switch to the next tab if an existing project is found
            self.switch_to_next_tab.emit()
            return

        if model_exists and not study_area_ready and not self._incomplete_setup_warned:
            QMessageBox.information(
                self,
                "Project Setup Incomplete",
                "This project has a model configuration but no valid study area outputs yet. "
                "Area generation will run again now.",
            )
            self._incomplete_setup_warned = True

        # Process the study area when model.json is missing or outputs are incomplete
        layer = self.layer_combo.currentLayer()
        if not layer:
            QMessageBox.critical(self, "Error", "Please select a study area layer.")
            self.enable_widgets()
            return

        if not self.working_dir:
            QMessageBox.critical(self, "Error", "Please select a working directory.")
            self.enable_widgets()
            return

        field_name = self.field_combo.currentField()
        if not field_name or field_name not in layer.fields().names():
            QMessageBox.critical(self, "Error", f"Invalid area name field '{field_name}'.")
            self.enable_widgets()
            return

        if self.regional_scale.isChecked():
            if not self._validate_h3_preflight(layer, self.selected_h3_resolution()):
                self.enable_widgets()
                return

        # Copy default model.json only if not present
        if not model_exists:
            default_model_path = resources_path("resources", "model.json")
            try:
                shutil.copy(default_model_path, model_path)
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to copy model.json: {e}")
                self.enable_widgets()
                return

        # open the model.json to set the analysis cell size and the network layer path, then close it again
        with open(model_path, "r") as f:
            model = json.load(f)
            model["analysis_cell_size_m"] = self.cell_size_spinbox.value()
            if self.regional_scale.isChecked():
                model["analysis_scale"] = "regional"
                model["analysis_h3_resolution"] = self.selected_h3_resolution()
            elif self.local_scale.isChecked():
                model["analysis_scale"] = "local"
            else:
                model["analysis_scale"] = "national"
            # Save women considerations settings
            model["women_considerations_enabled"] = self.women_considerations_checkbox.isChecked()
            # Save reference layer source path
            ref_layer = self.reference_layer()
            if ref_layer and ref_layer.source():
                model["admin_boundary_layer_source"] = ref_layer.source()
        with open(model_path, "w") as f:
            json.dump(model, f, indent=2)

        # Create the processor instance and process the features
        debug_env = int(os.getenv("GEOE3_DEBUG") or os.getenv("GEEST_DEBUG", 0))
        feedback = QgsFeedback()  # Used to cancel tasks and measure subtask progress
        try:

            # Determine analysis scale
            if self.regional_scale.isChecked():
                analysis_scale = "regional"
            elif self.local_scale.isChecked():
                analysis_scale = "local"
            else:
                analysis_scale = "national"

            h3_resolution = self.selected_h3_resolution() if analysis_scale == "regional" else None

            if analysis_scale == "regional" and h3_resolution is not None and h3_resolution >= 9:
                reply = QMessageBox.question(
                    self,
                    "High H3 Resolution",
                    (
                        f"H3 resolution {h3_resolution} can be very computationally expensive and may take "
                        "a long time to process.\n\nDo you want to continue?"
                    ),
                    QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                    QMessageBox.StandardButton.No,
                )
                if reply != QMessageBox.StandardButton.Yes:
                    self.enable_widgets()
                    return

            processor = StudyAreaProcessingTask(
                layer=layer,
                field_name=field_name,
                cell_size_m=self.cell_size_spinbox.value(),
                crs=crs,
                working_dir=self.working_dir,
                feedback=feedback,
                analysis_scale=analysis_scale,
                h3_resolution=h3_resolution,
            )
            self.study_area_task = processor
            # Hook up the QTask feedback signal to the progress bar
            # Measure overall task progress from the task object itself
            processor.progressChanged.connect(self.progress_updated)
            processor.taskCompleted.connect(self.on_task_completed)
            processor.taskTerminated.connect(self.on_task_terminated)
            # Connect GHSL download failure signal to prompt user
            processor.ghsl_download_failed.connect(lambda msg, p=processor: self.on_ghsl_download_failed(msg, p))
            # Measure subtask progress from the feedback object
            feedback.progressChanged.connect(self.subtask_progress_updated)
            self.disable_widgets()
            if debug_env:
                processor.run()
            else:
                self.queue_manager.add_task(processor)
                self.queue_manager.start_processing()
        except Exception as e:
            trace = traceback.format_exc()
            QMessageBox.critical(self, "Error", f"Error processing study area: {e}\n{trace}")
            self.enable_widgets()
            return
        self.settings.setValue("last_working_directory", self.working_dir)
        self.working_directory_changed.emit(self.working_dir)
        self.enable_widgets()

    def disable_widgets(self):
        """Disable all widgets in the panel."""
        for widget in self.findChildren(QWidget):
            widget.setEnabled(False)

    def enable_widgets(self):
        """Enable all widgets in the panel."""
        for widget in self.findChildren(QWidget):
            # Regional scale is now enabled with H3 hexagonal grids
            widget.setEnabled(True)
        self.processing_info_label.setVisible(False)
        self.processing_info_label.setText("")

    def reset_for_new_project_flow(self):
        """Fully reset panel state for a new project entry from setup panel."""
        self.enable_widgets()
        self._incomplete_setup_warned = False
        self.working_dir = ""

        self.project_path_label.setText("Project path not set.")
        self.create_project_directory_button.setText("📂 Create or select a project directory")
        self.folder_status_label.setPixmap(QPixmap(resources_path("resources", "icons", "failed.svg")))

        self.national_scale.setChecked(True)
        self.spatial_scale_changed("national")
        self.h3_resolution_combo.setCurrentIndex(6)
        self.women_considerations_checkbox.setChecked(True)

        if hasattr(self.layer_combo, "setAllowEmptyLayer"):
            self.layer_combo.setAllowEmptyLayer(True)
        self.layer_combo.setLayer(None)
        self.field_combo.setLayer(None)
        self.field_combo.clear()

        self.use_boundary_crs.setChecked(False)
        self.use_boundary_crs.setEnabled(False)
        self.crs_label.setText("CRS: Not set")

        self.progress_bar.setMinimum(0)
        self.progress_bar.setMaximum(100)
        self.progress_bar.setValue(0)
        self.progress_bar.setFormat("%p%")
        self.progress_bar.setVisible(False)
        self.progress_bar.setEnabled(False)

        self.child_progress_bar.setMinimum(0)
        self.child_progress_bar.setMaximum(100)
        self.child_progress_bar.setValue(0)
        self.child_progress_bar.setFormat("%p%")
        self.child_progress_bar.setVisible(False)
        self.child_progress_bar.setEnabled(False)

        self.processing_info_label.setText("")
        self.processing_info_label.setVisible(False)
        self.processing_info_label.setEnabled(False)

        # Refresh layer-dependent state after clearing boundary selection.
        self.layer_changed(self.layer_combo.currentLayer())

    def reference_layer(self):
        """Get the admin boundary reference layer.

        Returns:
            QgsVectorLayer: The currently selected admin boundary layer.
        """
        return self.layer_combo.currentLayer()

    def crs(self, working_directory=None):
        """Get the CRS for the GEOE3 study area (NOT QGIS project CRS).

        If study area already exists, return its CRS from study_area.gpkg.
        Otherwise, calculate CRS from the boundary layer (UTM zone or boundary layer CRS).

        IMPORTANT: This returns the study area CRS, not QgsProject.instance().crs()

        Args:
            working_directory: Optional working directory path. If not provided, uses self.working_dir

        Returns:
            QgsCoordinateReferenceSystem: The study area CRS, or None if unavailable.
        """
        # Use provided working_directory, otherwise fall back to self.working_dir
        work_dir = working_directory or self.working_dir

        # First, try to get CRS from existing study area
        if work_dir and os.path.exists(work_dir):
            study_area_gpkg = os.path.join(work_dir, "study_area", "study_area.gpkg")
            if os.path.exists(study_area_gpkg):
                try:
                    study_area_layer = QgsVectorLayer(
                        f"{study_area_gpkg}|layername=study_area_polygons", "study_area", "ogr"
                    )
                    if study_area_layer.isValid():
                        crs = study_area_layer.crs()
                        self.crs_label.setText(f"CRS: {crs.authid()}")
                        return crs
                except Exception as e:
                    log_message(f"Could not load study area CRS: {e}", level=Qgis.Warning)

        # If no study area exists yet, calculate CRS from boundary layer
        crs = None
        if self.use_boundary_crs.isChecked():
            log_message("Using boundary CRS")
            crs = self.layer_combo.currentLayer().crs()
        else:
            log_message("Using UTM zone CRS")
            try:
                epsg = calculate_utm_zone_from_layer(self.layer_combo.currentLayer())
            except Exception as e:
                log_message(
                    f"Error calculating UTM zone from layer: {e}",
                    tag="GeoE3",
                    level=Qgis.Critical,
                )
                return None
            crs = QgsCoordinateReferenceSystem(f"EPSG:{epsg}")  # noqa E231
        self.crs_label.setText(f"CRS: {crs.authid()}" if crs else "CRS: Not set")
        return crs

    # Slot that listens for changes in the study_area task object which is used to measure overall task progress
    def progress_updated(self, progress: float):
        """Slot to be called when the task progress is updated.

        Args:
            progress: The current progress value (0-100).
        """
        log_message(f"\n\n\n\n\n\nProgress: {progress}\n\n\n\n\n\n\n\n")
        self.progress_bar.setVisible(True)
        self.progress_bar.setEnabled(True)
        self.progress_bar.setValue(int(progress))
        if progress == 0:
            self.progress_bar.setFormat("Downloading GHSL data and building analysis grid...")
            self.progress_bar.setMinimum(0)
            self.progress_bar.setMaximum(0)  # makes it bounce indefinitely
            self.processing_info_label.setText("Downloading GHSL data and building analysis grid. Do not close QGIS.")
            self.processing_info_label.setVisible(True)
            self.processing_info_label.setEnabled(True)
        else:
            # This is a sneaky hack to show the exact progress in the label
            # since QProgressBar only takes ints. See Qt docs for more info.
            # Use the 'setFormat' method to display the exact float:
            float_value_as_string = f"Processing study area — {progress}%"
            self.progress_bar.setFormat(float_value_as_string)
            self.progress_bar.setMinimum(0)
            self.progress_bar.setMaximum(100)
            self.processing_info_label.setText(
                "Processing study area. Bounding boxes will appear on the map as each area completes."
            )
            self.processing_info_label.setVisible(True)
            self.processing_info_label.setEnabled(True)
        self.add_bboxes_to_map()  # will just refresh them if already there

    # Slot that listens for changes in the progress object which is used to measure subtask progress
    def subtask_progress_updated(self, progress: float):
        """⚙️ Subtask progress updated.

        Args:
            progress: Progress.
        """
        self.child_progress_bar.setVisible(True)
        self.child_progress_bar.setEnabled(True)
        self.child_progress_bar.setValue(int(progress))
        # This is a sneaky hack to show the exact progress in the label
        # since QProgressBar only takes ints. See Qt docs for more info.
        # Use the 'setFormat' method to display the exact float:
        float_value_as_string = f"Current area: {progress}%"
        self.child_progress_bar.setFormat(float_value_as_string)

    def on_task_terminated(self):
        """Slot to be called when the study area processing task is terminated (aborted or failed)."""
        task = self.study_area_task
        reason = "Task was interrupted or canceled."
        if task is not None and getattr(task, "termination_detail", ""):
            reason = task.termination_detail

        log_message(
            f"Study area processing was terminated. Reason: {reason}",
            tag="GeoE3",
            level=Qgis.Warning,
        )

        self.enable_widgets()

        self.progress_bar.setMinimum(0)
        self.progress_bar.setMaximum(100)
        self.progress_bar.setValue(0)
        self.progress_bar.setFormat("Aborted - Could not prepare study area.")
        self.processing_info_label.setText(f"Main error: {reason} Check settings, then click > to retry.")
        self.processing_info_label.setVisible(True)
        self.processing_info_label.setEnabled(True)
        self.child_progress_bar.setVisible(False)
        self.study_area_task = None

    def on_task_completed(self):
        """Slot to be called when the task completes successfully."""
        self.study_area_task = None
        log_message(
            "*** Study area processing completed successfully. ***",
            tag="GeoE3",
            level=Qgis.Info,
        )
        # Final refresh now that the unified writer has stopped — during the
        # run refreshes are heavily rate-limited (_should_skip_map_refresh).
        self._last_map_refresh_ts = 0.0
        self.add_bboxes_to_map()

        # Use child progress bar for report generation (main bar stays at 100%)
        self.child_progress_bar.setMinimum(0)
        self.child_progress_bar.setMaximum(0)  # Indeterminate/bouncing
        self.child_progress_bar.setFormat("Generating study area report...")
        self.child_progress_bar.setVisible(True)

        self.processing_info_label.setText("Generating study area report — will open automatically when ready.")
        self.processing_info_label.setVisible(True)
        self.processing_info_label.setEnabled(True)

        # Start report generation in background
        gpkg_path = os.path.join(self.working_dir, "study_area", "study_area.gpkg")
        self.report_task = StudyAreaReportTask(self.working_dir, gpkg_path)
        self.report_task.taskCompleted.connect(self.on_report_completed)
        self.report_task.taskTerminated.connect(self.on_report_failed)

        from qgis.core import QgsApplication

        QgsApplication.taskManager().addTask(self.report_task)

    def on_report_completed(self):
        """Slot called when report generation completes successfully."""
        log_message("Study area report generated successfully.", tag="GeoE3", level=Qgis.Info)

        self.child_progress_bar.setMinimum(0)
        self.child_progress_bar.setMaximum(100)
        self.child_progress_bar.setValue(100)
        self.child_progress_bar.setFormat("Complete")
        self.enable_widgets()
        self.switch_to_next_tab.emit()

    def on_report_failed(self):
        """Slot called when report generation fails."""
        error_msg = str(self.report_task.exception) if hasattr(self.report_task, "exception") else "Unknown error"

        log_message(f"Study area report generation failed: {error_msg}", tag="GeoE3", level=Qgis.Critical)

        self.child_progress_bar.setMinimum(0)
        self.child_progress_bar.setMaximum(100)
        self.child_progress_bar.setValue(0)
        self.child_progress_bar.setFormat("Report failed — continuing")
        self.enable_widgets()
        self.switch_to_next_tab.emit()

    def on_ghsl_download_failed(self, error_message, processor):
        """Slot called when GHSL download fails during study area processing.

        Prompts user to continue without GHSL data or abort the task.

        Args:
            error_message: Description of what failed and consequences.
            processor: The StudyAreaProcessingTask instance to send response to.
        """
        log_message("GHSL download failed, prompting user for response", tag="GeoE3", level=Qgis.Warning)

        reply = QMessageBox.warning(
            self,
            "GHSL Download Failed",
            error_message,
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No,
        )

        if reply == QMessageBox.StandardButton.Yes:
            log_message("User chose to continue without GHSL data", tag="GeoE3", level=Qgis.Info)
            processor.set_ghsl_user_response(continue_without=True)
        else:
            log_message("User chose to abort due to GHSL failure", tag="GeoE3", level=Qgis.Info)
            processor.set_ghsl_user_response(continue_without=False)
            # Update progress bar to show abort
            self.progress_bar.setFormat("Aborted — GHSL download failed")
            self.enable_widgets()

    def update_recent_projects(self, directory):
        """Updates the recent projects list with the new directory.

        Args:
            directory: The directory path to add to the recent projects list.
        """
        recent_projects = self.settings.value("recent_projects", [])

        if directory in recent_projects:
            recent_projects.remove(directory)  # Remove if already in the list (to reorder)

        recent_projects.insert(0, directory)  # Add to the top of the list

        # Limit the list to a certain number of recent projects (e.g., 15)
        if len(recent_projects) > 15:
            recent_projects = recent_projects[:15]

        # Save back to QSettings
        self.settings.setValue("recent_projects", recent_projects)

    def resizeEvent(self, event):
        """⚙️ Resizeevent.

        Args:
            event: Event.
        """
        self.set_font_size()
        super().resizeEvent(event)

    def set_font_size(self):
        """⚙️ Set font size."""
        panel_width = self.description.rect().width()

        title_size = int(linear_interpolation(panel_width, 16, 20, 400, 800))
        subtitle_size = int(linear_interpolation(panel_width, 12, 16, 400, 800))
        content_size = int(linear_interpolation(panel_width, 12, 16, 400, 800))
        control_size = int(linear_interpolation(panel_width, 12, 16, 400, 800))

        self.label_2.setFont(QFont("Arial", title_size))

        self.groupBox.setFont(QFont("Arial", subtitle_size))
        self.groupBox_2.setFont(QFont("Arial", subtitle_size))
        self.groupBox_3.setFont(QFont("Arial", subtitle_size))
        self.regional_scale.setFont(QFont("Arial", subtitle_size))
        self.national_scale.setFont(QFont("Arial", subtitle_size))
        self.local_scale.setFont(QFont("Arial", subtitle_size))
        self.women_considerations_checkbox.setFont(QFont("Arial", subtitle_size))

        self.description.setFont(QFont("Arial", content_size))
        self.description2.setFont(QFont("Arial", content_size))
        self.description3.setFont(QFont("Arial", content_size))
        self.women_considerations_description.setFont(QFont("Arial", content_size))
        self.processing_info_label.setFont(QFont("Arial", content_size))
        self.project_path_label.setFont(QFont("Arial", content_size))

        self.create_project_directory_button.setFont(QFont("Arial", control_size))
        self.load_boundary_button.setFont(QFont("Arial", control_size))
        self.cell_size_spinbox.setFont(QFont("Arial", control_size))
        self.h3_resolution_label.setFont(QFont("Arial", control_size))
        self.h3_resolution_combo.setFont(QFont("Arial", control_size))
        self.layer_combo.setFont(QFont("Arial", control_size))
        self.field_combo.setFont(QFont("Arial", control_size))
        self.use_boundary_crs.setFont(QFont("Arial", content_size))
        self.crs_label.setFont(QFont("Arial", content_size))
        self.previous_button.setFont(QFont("Arial", control_size))
        self.next_button.setFont(QFont("Arial", control_size))

        # Progress bars use a fixed small font so text never wraps inside the bar
        self.progress_bar.setFont(QFont("Arial", 9))
        self.child_progress_bar.setFont(QFont("Arial", 9))

    def selected_h3_resolution(self) -> int:
        """Return selected H3 resolution from regional dropdown."""
        selected_value = self.h3_resolution_combo.currentData()
        if selected_value is None:
            selected_value = self.h3_resolution_combo.currentText().split(" ")[0]
        return int(selected_value)

    def _estimate_layer_area_km2(self, layer: QgsVectorLayer) -> float:
        """Estimate total polygon area in square kilometers for selected input features."""
        if not layer:
            return 0.0

        distance_area = QgsDistanceArea()
        distance_area.setSourceCrs(layer.crs(), QgsProject.instance().transformContext())
        if layer.crs().isGeographic():
            distance_area.setEllipsoid("WGS84")
        else:
            distance_area.setEllipsoid(layer.crs().ellipsoidAcronym() or "WGS84")

        area_m2 = 0.0
        if layer.selectedFeatureCount() > 0:
            features = layer.getSelectedFeatures()
        else:
            features = layer.getFeatures()

        for feature in features:
            geometry = feature.geometry()
            if geometry is None or geometry.isEmpty():
                continue
            area_m2 += abs(distance_area.measureArea(geometry))

        return distance_area.convertAreaMeasurement(area_m2, QgsUnitTypes.AreaSquareKilometers)

    def _validate_h3_preflight(self, layer: QgsVectorLayer, h3_resolution: int) -> bool:
        """Validate H3 configuration and block unsafe runs before processing."""
        area_km2 = self._estimate_layer_area_km2(layer)
        if area_km2 <= 0:
            QMessageBox.critical(
                self,
                "Invalid Study Area",
                "Could not estimate study area size. Please verify polygon geometry and try again.",
            )
            return False

        estimated_cells = estimate_h3_cells_for_area(area_km2, h3_resolution)
        cell_area_km2 = h3_cell_area_km2(h3_resolution)

        if estimated_cells > self.MAX_H3_ESTIMATED_CELLS:
            suggested = suggest_coarser_resolution(area_km2, self.MAX_H3_ESTIMATED_CELLS, h3_resolution)
            QMessageBox.critical(
                self,
                "H3 Resolution Too Fine",
                (
                    f"Selected H3 resolution {h3_resolution} is too fine for this study area.\n\n"
                    f"Estimated area: {area_km2:,.2f} km²\n"
                    f"Approximate cell area: {cell_area_km2:,.6f} km²\n"
                    f"Estimated cells: {estimated_cells:,} (max allowed: {self.MAX_H3_ESTIMATED_CELLS:,})\n\n"
                    f"Please choose a coarser resolution, e.g. {suggested}."
                ),
            )
            return False

        if estimated_cells < self.MIN_H3_ESTIMATED_CELLS:
            suggested = suggest_finer_resolution(area_km2, self.MIN_H3_ESTIMATED_CELLS, h3_resolution)
            QMessageBox.critical(
                self,
                "H3 Resolution Too Coarse",
                (
                    f"Selected H3 resolution {h3_resolution} is too coarse for this study area.\n\n"
                    f"Estimated area: {area_km2:,.2f} km²\n"
                    f"Approximate cell area: {cell_area_km2:,.2f} km²\n"
                    f"Estimated cells: {estimated_cells} (minimum required: {self.MIN_H3_ESTIMATED_CELLS})\n\n"
                    f"Please choose a finer resolution, e.g. {suggested}."
                ),
            )
            return False

        return True

    def add_bboxes_to_map(self):
        """Add the study area layers to the map.

        If it is already there we will just refresh it.

        This provides the user with visual feedback as each geometry gets processed.

        This method is a cut and paste of the same method in tree_panel.py
        but only adds two layers (well one layer and one table) to the map.

        Raises:
            RuntimeError: If the GeoPackage cannot be opened for an unexpected reason.
        """
        gpkg_path = os.path.join(self.working_dir, "study_area", "study_area.gpkg")
        if self._should_skip_map_refresh():
            return

        if not os.path.exists(gpkg_path):
            log_message(
                f"GeoPackage not yet created: {gpkg_path}",
                tag="GeoE3",
                level=Qgis.Info,
            )
            return

        try:
            file_size = os.path.getsize(gpkg_path)
            if file_size < 1024:  # Less than 1KB suggests still initializing
                log_message(
                    f"GeoPackage still initializing (size: {file_size} bytes)",
                    tag="GeoE3",
                    level=Qgis.Info,
                )
                return
        except OSError:
            return

        if not self._gpkg_metadata_ready(gpkg_path):
            log_message(
                "GeoPackage metadata tables not ready yet; skipping map refresh.",
                tag="GeoE3",
                level=Qgis.Info,
            )
            return

        project = QgsProject.instance()

        # Check if 'GeoE3' group exists, otherwise create it
        root = project.layerTreeRoot()
        geoe3_group = root.findGroup("GeoE3 Study Area")
        if geoe3_group is None:
            geoe3_group = root.insertGroup(0, "GeoE3 Study Area")  # Insert at the top of the layers panel

        layers = [
            "study_area_bboxes",
            "study_area_creation_status",
        ]
        for layer_name in layers:
            # Check if layer exists in GeoPackage
            from osgeo import ogr

            try:
                ds = ogr.Open(gpkg_path, 0)
                if ds is None:
                    log_message(f"Could not open GeoPackage: {gpkg_path}", tag="GeoE3", level=Qgis.Warning)
                    continue

                layer_exists = ds.GetLayerByName(layer_name) is not None
                ds = None
            except RuntimeError as e:
                error_str = str(e).lower()
                # Skip if database is busy, temporarily corrupted, or still initializing
                if (
                    "database is locked" in error_str
                    or "malformed" in error_str
                    or "gpkg_spatial_ref_sys" in error_str
                    or "gpkg_contents" in error_str
                    or "not recognized as being in a supported file format" in error_str
                    or "unable to open database file" in error_str
                    or "readonly database" in error_str
                    or "required geopackage tables" in error_str
                ):
                    log_message(
                        f"Database busy or still initializing, skipping map refresh for {layer_name}",
                        tag="GeoE3",
                        level=Qgis.Info,
                    )
                    continue
                raise

            if not layer_exists:
                log_message(
                    f"Layer '{layer_name}' does not exist in GeoPackage yet (will be created during processing).",
                    tag="GeoE3",
                    level=Qgis.Info,
                )
                continue

            gpkg_layer_path = f"{gpkg_path}|layername={layer_name}"
            layer = QgsVectorLayer(gpkg_layer_path, layer_name, "ogr")

            # On Windows, the GeoPackage may be temporarily locked right after
            # study area processing completes. Retry a few times before giving up.
            if not layer.isValid():
                import time as _time

                for attempt in range(3):
                    log_message(
                        f"Layer '{layer_name}' not valid (attempt {attempt + 1}/3), retrying...",
                        tag="GeoE3",
                        level=Qgis.Info,
                    )
                    _time.sleep(1)
                    layer = QgsVectorLayer(gpkg_layer_path, layer_name, "ogr")
                    if layer.isValid():
                        break

            if not layer.isValid():
                log_message(
                    f"Failed to load '{layer_name}' layer from GeoPackage.",
                    tag="GeoE3",
                    level=Qgis.Critical,
                )
                continue

            source_qml = resources_path("resources", "qml", f"{layer_name}.qml")
            result = layer.loadNamedStyle(source_qml)
            if result[0]:  # loadNamedStyle returns (success, error_message)
                log_message(f"Successfully applied QML style to layer '{layer_name}'")
            else:
                log_message(f"Failed to apply QML style: {result[1]}")

            # Check if a layer with the same data source exists in the correct group
            existing_layer = None
            for child in geoe3_group.children():
                if isinstance(child, QgsLayerTreeGroup):
                    continue
                if child.layer().source() == gpkg_layer_path:
                    existing_layer = child.layer()
                    break

            # Refresh existing layer instead of removing and re-adding
            if existing_layer is not None:
                log_message(f"Refreshing existing layer: {existing_layer.name()}")
                try:
                    existing_layer.reload()
                    # Force the OGR provider to reload data and recompute extent.
                    # On Windows, gpkg_contents extent can be stale after study area
                    # creation because the WAL checkpoint may not propagate to QGIS's
                    # already-open provider connection.
                    existing_layer.dataProvider().reloadData()
                    existing_layer.updateExtents()
                except Exception as e:
                    # Skip refresh if layer is locked by background write operation
                    log_message(
                        f"Could not refresh layer {existing_layer.name()}: {e}. "
                        "Layer may be locked (this is normal during processing).",
                        tag="GeoE3",
                        level=Qgis.Info,
                    )
            else:
                # Add the new layer to the appropriate subgroup
                QgsProject.instance().addMapLayer(layer, False)
                _ = geoe3_group.addLayer(layer)
                log_message(f"Added layer: {layer.name()} to group: {geoe3_group.name()}")
