import functools
import os
import typing
import uuid
from pathlib import Path
from typing import TYPE_CHECKING

from qgis.core import QgsProject
from qgis.PyQt import QtCore, QtGui, QtWidgets, uic
from qgis.utils import iface
from te_schemas.jobs import JobStatus
from te_schemas.results import Band as JobBand
from te_schemas.results import RasterResults, TimeSeriesTableResult, VectorResults

from .. import layers, metadata, metadata_dialog, utils
from ..conf import Setting, settings_manager
from ..data_io import DlgDataIOAddLayersToMap
from ..datasets_dialog import DatasetDetailsDialogue
from ..plot import DlgPlotTimeries
from ..reports.mvc import DatasetReportHandler
from ..select_dataset import DlgSelectDataset
from ..utils import FileUtils
from . import manager
from .models import Job, SortField, TypeFilter

if TYPE_CHECKING:
    from ..main_widget import MainWidget

WidgetDatasetItemUi, _ = uic.loadUiType(
    str(Path(__file__).parents[1] / "gui/WidgetDatasetItem.ui")
)

ICON_PATH = os.path.join(os.path.dirname(__file__), os.path.pardir, "icons")


class JobsModel(QtCore.QAbstractItemModel):
    _relevant_jobs: typing.List[Job]

    def __init__(
        self,
        job_manager: manager.JobManager,
        parent=None,
        jobs_list: typing.Optional[typing.List[Job]] = None,
    ):
        super().__init__(parent)
        # Use provided jobs list if available, otherwise query job_manager.
        # This allows callers to reuse the same list for both model and filters.
        self._relevant_jobs = (
            jobs_list if jobs_list is not None else job_manager.relevant_jobs
        )

    def set_jobs(self, jobs_list: typing.List[Job]) -> None:
        """Update the model's job list in place.

        This is more efficient than creating a new model and calling setSourceModel(),
        because Qt doesn't need to disconnect/reconnect proxy model signals.
        The beginResetModel/endResetModel pair tells views to refresh.
        """
        self.beginResetModel()
        self._relevant_jobs = jobs_list
        self.endResetModel()

    def index(
        self, row: int, column: int, parent: QtCore.QModelIndex
    ) -> QtCore.QModelIndex:
        invalid_index = QtCore.QModelIndex()

        if self.hasIndex(row, column, parent):
            try:
                job = self._relevant_jobs[row]
                result = self.createIndex(row, column, job)
            except IndexError:
                result = invalid_index
        else:
            result = invalid_index

        return result

    def parent(self, index: QtCore.QModelIndex) -> QtCore.QModelIndex:
        return QtCore.QModelIndex()

    def rowCount(self, index: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
        return len(self._relevant_jobs)

    def columnCount(self, index: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
        return 1

    def data(
        self,
        index: QtCore.QModelIndex = QtCore.QModelIndex(),
        role: QtCore.Qt.ItemDataRole = QtCore.Qt.DisplayRole,
    ) -> typing.Optional[Job]:
        result = None

        if index.isValid():
            job = index.internalPointer()

            if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.ItemDataRole:
                result = job

        return result

    def flags(
        self, index: QtCore.QModelIndex = QtCore.QModelIndex()
    ) -> QtCore.Qt.ItemFlags:
        if index.isValid():
            flags = super().flags(index)
            result = QtCore.Qt.ItemIsEditable | flags
        else:
            result = QtCore.Qt.NoItemFlags

        return result


class JobsSortFilterProxyModel(QtCore.QSortFilterProxyModel):
    current_sort_field: typing.Optional[SortField]
    type_filter: TypeFilter

    def __init__(self, current_sort_field: SortField, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.current_sort_field = current_sort_field
        self.start_date = None
        self.end_date = None
        self.type_filter = TypeFilter.ALL  # Initialize with default value
        self.status_filter = None  # None means "show all"
        self.task_type_filter = None  # None means "show all"
        # CRITICAL: Disable dynamic sort/filter to prevent Qt from re-sorting
        # the entire model every time ANY data changes.
        self.setDynamicSortFilter(False)

    def set_date_filter(self, start_date, end_date, invalidate=True):
        self.start_date = start_date
        self.end_date = end_date
        if invalidate:
            self.invalidateFilter()

    def set_status_filter(self, status, invalidate=True):
        """Set the status filter. Pass None to show all statuses."""
        self.status_filter = status
        if invalidate:
            self.invalidateFilter()

    def set_task_type_filter(self, script_name, invalidate=True):
        """Set the task type filter. Pass None to show all task types."""
        self.task_type_filter = script_name
        if invalidate:
            self.invalidateFilter()

    def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex):
        jobs_model = self.sourceModel()
        index = jobs_model.index(source_row, 0, source_parent)
        job: Job = jobs_model.data(index)
        reg_exp = self.filterRegExp()

        matches_filter = (
            reg_exp.exactMatch(job.visible_name)
            or reg_exp.exactMatch(job.local_context.area_of_interest_name)
            or reg_exp.exactMatch(str(job.id))
        )

        matches_type = True
        if self.type_filter == TypeFilter.RASTER:
            matches_type = not job.is_vector()
        elif self.type_filter == TypeFilter.VECTOR:
            matches_type = job.is_vector()

        # Date filtering logic
        matches_date = True
        if self.start_date and self.end_date:
            try:
                if job.start_date is not None and hasattr(job.start_date, "strftime"):
                    job_start_date = QtCore.QDateTime.fromString(
                        job.start_date.strftime("%Y-%m-%d %H:%M:%S"),
                        "yyyy-MM-dd HH:mm:ss",
                    )
                    matches_date = (
                        job_start_date >= self.start_date
                        and job_start_date <= self.end_date
                    )
                else:
                    matches_date = False
            except (AttributeError, ValueError, TypeError):
                matches_date = False

        # Status filtering logic
        matches_status = True
        if self.status_filter is not None:
            matches_status = job.status == self.status_filter

        # Task type filtering logic
        matches_task_type = True
        if self.task_type_filter is not None:
            job_script_name = ""
            if job.script is not None and job.script.name:
                job_script_name = job.script.name
            matches_task_type = job_script_name == self.task_type_filter

        return (
            matches_filter
            and matches_type
            and matches_date
            and matches_status
            and matches_task_type
        )

    def lessThan(self, left: QtCore.QModelIndex, right: QtCore.QModelIndex) -> bool:
        # Source data is pre-sorted in Python. Just compare row indices.
        # This is O(1) - even if Qt calls lessThan 850k times, it's instant.
        return left.row() < right.row()

    def set_type_filter(self, filter_type):
        self.type_filter = filter_type


class JobItemDelegate(QtWidgets.QStyledItemDelegate):
    current_index: typing.Optional[QtCore.QModelIndex]
    main_dock: "MainWidget"

    # Class-level cached size - all job items are the same size since they use
    # the same widget template
    _uniform_item_size: typing.ClassVar[typing.Optional[QtCore.QSize]] = None

    def __init__(
        self,
        main_dock: "MainWidget",
        parent: QtCore.QObject = None,
    ):
        super().__init__(parent)
        self.parent = parent
        self.main_dock = main_dock
        self.current_index = None
        # Caches to avoid recreating full DatasetEditorWidget on every repaint.
        # Keyed by (job.id, job.status, job.progress, rect_width, rect_height).
        self._pixmap_cache: typing.Dict[typing.Tuple, QtGui.QPixmap] = {}
        self._size_cache: typing.Dict[typing.Tuple, QtCore.QSize] = {}

    def invalidate_caches(self):
        """Clear cached pixmaps and sizes.

        Should be called when the underlying model changes (e.g. after a
        cache refresh) so that stale renders are discarded.
        """
        self._pixmap_cache.clear()
        self._size_cache.clear()
        # Note: _uniform_item_size is intentionally NOT cleared here.
        # The widget template size doesn't change, and recomputing it
        # would require creating a widget which is expensive.

    @staticmethod
    def _cache_key(item: "Job", rect_w: int = 0, rect_h: int = 0) -> typing.Tuple:
        return (item.id, item.status, item.progress, rect_w, rect_h)

    @staticmethod
    def _apply_active_palette(widget: QtWidgets.QWidget):
        """Copy Active palette colours to the Inactive group so enabled
        buttons aren't greyed out."""
        pal = widget.palette()
        roles = [
            QtGui.QPalette.WindowText,
            QtGui.QPalette.Button,
            QtGui.QPalette.Light,
            QtGui.QPalette.Midlight,
            QtGui.QPalette.Dark,
            QtGui.QPalette.Mid,
            QtGui.QPalette.Text,
            QtGui.QPalette.BrightText,
            QtGui.QPalette.ButtonText,
            QtGui.QPalette.Base,
            QtGui.QPalette.Window,
            QtGui.QPalette.Shadow,
            QtGui.QPalette.Highlight,
            QtGui.QPalette.HighlightedText,
            QtGui.QPalette.Link,
            QtGui.QPalette.LinkVisited,
            QtGui.QPalette.AlternateBase,
            QtGui.QPalette.ToolTipBase,
            QtGui.QPalette.ToolTipText,
        ]
        try:
            roles.append(QtGui.QPalette.PlaceholderText)
        except AttributeError:
            pass  # Not available in older Qt versions
        for role in roles:
            pal.setColor(
                QtGui.QPalette.Inactive,
                role,
                pal.color(QtGui.QPalette.Active, role),
            )
        widget.setPalette(pal)

    def paint(
        self,
        painter: QtGui.QPainter,
        option: QtWidgets.QStyleOptionViewItem,
        index: QtCore.QModelIndex,
    ):
        # get item and manipulate painter basing on item data
        proxy_model: QtCore.QSortFilterProxyModel = index.model()
        source_index = proxy_model.mapToSource(index)
        source_model = source_index.model()
        item = source_model.data(source_index, QtCore.Qt.DisplayRole)

        # if a Dataset => show custom widget

        if isinstance(item, Job):
            key = self._cache_key(item, option.rect.width(), option.rect.height())
            pixmap = self._pixmap_cache.get(key)

            if pixmap is None:
                editor_widget = self.createEditor(self.parent, option, index)
                editor_widget.setGeometry(option.rect)
                self._apply_active_palette(editor_widget)
                pixmap = editor_widget.grab()
                del editor_widget
                self._pixmap_cache[key] = pixmap

            painter.drawPixmap(option.rect.x(), option.rect.y(), pixmap)
        else:
            super().paint(painter, option, index)

    def sizeHint(
        self, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex
    ):
        proxy_model: QtCore.QSortFilterProxyModel = index.model()
        source_index = proxy_model.mapToSource(index)
        source_model = source_index.model()
        item = source_model.data(source_index, QtCore.Qt.DisplayRole)

        if isinstance(item, Job):
            # All job items use the same widget template (WidgetDatasetItemUi),
            # so they all have the same size. Compute once and reuse for all items.
            # This avoids creating 278+ expensive widgets just to measure size,
            # which was causing multi-second UI freezes.
            if JobItemDelegate._uniform_item_size is None:
                # Create one widget to measure its size
                widget = self.createEditor(None, option, index)
                JobItemDelegate._uniform_item_size = widget.size()
                del widget

            return JobItemDelegate._uniform_item_size

        return super().sizeHint(option, index)

    def createEditor(
        self,
        parent: QtWidgets.QWidget,
        option: QtWidgets.QStyleOptionViewItem,
        index: QtCore.QModelIndex,
    ):
        # get item and manipulate painter basing on item data
        proxy_model: QtCore.QSortFilterProxyModel = index.model()
        source_index = proxy_model.mapToSource(index)
        source_model = source_index.model()
        item = source_model.data(source_index, QtCore.Qt.DisplayRole)

        # item = model.data(index, QtCore.Qt.DisplayRole)

        if isinstance(item, Job):
            return DatasetEditorWidget(item, self.main_dock, parent=parent)
        else:
            return super().createEditor(parent, option, index)

    def updateEditorGeometry(
        self,
        editor: QtWidgets.QWidget,
        option: QtWidgets.QStyleOptionViewItem,
        index: QtCore.QModelIndex,
    ):
        editor.setGeometry(option.rect)


class DatasetEditorWidget(QtWidgets.QWidget, WidgetDatasetItemUi):
    job: Job
    main_dock: "MainWidget"

    add_to_canvas_pb: QtWidgets.QToolButton
    notes_la: QtWidgets.QLabel
    delete_tb: QtWidgets.QToolButton
    download_tb: QtWidgets.QToolButton
    name_la: QtWidgets.QLabel
    open_details_tb: QtWidgets.QToolButton
    metadata_pb: QtWidgets.QPushButton
    view_logs_tb: QtWidgets.QToolButton
    plot_tb: QtWidgets.QToolButton
    load_tb: QtWidgets.QToolButton
    edit_tb: QtWidgets.QToolButton
    report_pb: QtWidgets.QPushButton
    status_frame: QtWidgets.QFrame
    status_label: QtWidgets.QLabel

    def __init__(self, job: Job, main_dock: "MainWidget", parent=None):
        super().__init__(parent)
        self.setupUi(self)
        self.job = job
        self.main_dock = main_dock
        # allows hiding background prerendered pixmap
        self.setAutoFillBackground(True)

        # Flag to track if widget is being destroyed
        self._is_being_destroyed = False

        self.load_data_menu_setup()
        self.add_to_canvas_pb.setMenu(self.load_data_menu)

        self.metadata_menu = QtWidgets.QMenu()
        self.metadata_menu.aboutToShow.connect(self.prepare_metadata_menu)
        self.metadata_pb.setMenu(self.metadata_menu)

        self.open_details_tb.clicked.connect(self.show_details)
        self.view_logs_tb.clicked.connect(self.view_execution_logs)
        self.delete_tb.clicked.connect(self.delete_dataset)
        self.load_tb.clicked.connect(self.load_layer)

        self.edit_tb.clicked.connect(self.edit_layer)

        self.delete_tb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "mActionDeleteSelected.svg"))
        )
        self.open_details_tb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "mActionPropertiesWidget.svg"))
        )
        self.view_logs_tb.setIcon(QtGui.QIcon(os.path.join(ICON_PATH, "document.svg")))
        self.metadata_pb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "editmetadata.svg"))
        )
        self.add_to_canvas_pb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "mActionAddRasterLayer.svg"))
        )
        self.download_tb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "cloud-download.svg"))
        )
        self.edit_tb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "mActionToggleEditing.svg"))
        )
        self.load_tb.setIcon(
            QtGui.QIcon(os.path.join(ICON_PATH, "mActionAddOgrLayer.svg"))
        )

        self.plot_tb.setIcon(FileUtils.get_icon("chart.svg"))
        self.plot_tb.clicked.connect(self.show_time_series_plot)
        self.plot_tb.setEnabled(False)
        self.plot_tb.hide()

        self.report_pb.setIcon(FileUtils.get_icon("report.svg"))

        # Initialize report handler with error handling
        try:
            self._report_handler = DatasetReportHandler(
                self.report_pb, self.job, self.main_dock.iface
            )
        except Exception as e:
            # If report handler fails to initialize, create a dummy one to prevent crashes
            print(f"Warning: Failed to initialize report handler: {e}")
            self._report_handler = None
        # self.add_to_canvas_pb.setFixedSize(self.view_logs_tb.size())
        # self.add_to_canvas_pb.setMinimumSize(self.view_logs_tb.size())

        if self.job.is_vector():
            self.edit_tb.setEnabled(False)
            layers = QgsProject.instance().mapLayers()
            # Get the first vector result
            vector_result = self.job.get_first_result_by_type(VectorResults)
            if vector_result and vector_result.uri:
                # Use main uri, fall back to vector.uri for backward compatibility
                vector_uri = str(vector_result.uri.uri)
                for lyr in layers.values():
                    # Ensure this vector layer is added to the map
                    if lyr.source().split("|")[0] == vector_uri:
                        self.edit_tb.setEnabled(True)
                        break

        self.name_la.setText(self.job.visible_name)

        area_name = self.job.local_context.area_of_interest_name

        # Handle missing start_date gracefully with defensive checks
        if self.job.start_date is not None:
            try:
                if hasattr(self.job.start_date, "strftime"):
                    job_start_date = utils.utc_to_local(self.job.start_date).strftime(
                        "%Y-%m-%d %H:%M"
                    )
                else:
                    job_start_date = "Date unknown"
            except (AttributeError, ValueError, TypeError):
                job_start_date = "Date unknown"
        else:
            job_start_date = "Date unknown"

        if area_name:
            notes_text = f"{area_name} ({job_start_date})"
        else:
            notes_text = f"{job_start_date}"
        self.notes_la.setText(notes_text)

        # Setup status bar
        status_text, status_color = self._get_status_display_info(self.job.status)
        self._setup_status_bar(status_text, status_color)

        self.download_tb.setEnabled(False)

        self.delete_tb.setEnabled(True)

        offline_mode = settings_manager.get_value(Setting.OFFLINE_MODE)

        # Reset download button to default state before status handling
        self.download_tb.setStyleSheet("")  # Clear any previous styling
        self.download_tb.setText("")  # Clear any previous text

        # Set up the status bar display
        status_text, status_color = self._get_status_display_info(self.job.status)
        self._setup_status_bar(status_text, status_color)

        # Handle specific status behavior
        if self.job.status in [JobStatus.RUNNING, JobStatus.PENDING, JobStatus.READY]:
            # Jobs that aren't completed yet - disable main actions
            self._disable_main_action_buttons()
            if self.job.status in [JobStatus.RUNNING, JobStatus.PENDING]:
                self._handle_time_series_result()
        elif self.job.status == JobStatus.FINISHED:
            result_auto_download = settings_manager.get_value(Setting.DOWNLOAD_RESULTS)

            if result_auto_download:
                self.download_tb.hide()
            else:
                self.download_tb.show()

                if offline_mode:
                    # Disable the download button so that the user cannot download
                    self.download_tb.setEnabled(False)
                else:
                    self.download_tb.setEnabled(True)

                self.download_tb.clicked.connect(
                    functools.partial(manager.job_manager.download_job_results, job)
                )

            # Hide buttons that don't make sense for finished but not downloaded jobs
            self.add_to_canvas_pb.setEnabled(False)
            self.metadata_pb.setEnabled(
                False
            )  # Disable metadata for finished jobs (remote URLs cause errors)
            self.load_tb.hide()  # Hide the load vector button
            self.edit_tb.hide()  # Hide the edit button

            if self.job.is_timeseries():
                self.plot_tb.setEnabled(True)
                self.plot_tb.show()
                self.download_tb.hide()
                self.add_to_canvas_pb.hide()
                self.metadata_pb.hide()
                self.view_logs_tb.show()
        elif self.job.status in (JobStatus.DOWNLOADED, JobStatus.GENERATED_LOCALLY):
            self.download_tb.hide()
            self.add_to_canvas_pb.setEnabled(self.has_loadable_result())
            self.metadata_pb.setEnabled(self.has_loadable_result())
        elif self.job.status in [
            JobStatus.FAILED,
            JobStatus.CANCELLED,
            JobStatus.EXPIRED,
        ]:
            self._hide_buttons_failed_cancelled()
        else:
            # For unknown statuses, disable main actions
            self.download_tb.hide()
            self._disable_main_action_buttons()

        # Apply job type specific visibility for completed jobs only
        if self.job.status in (JobStatus.DOWNLOADED, JobStatus.GENERATED_LOCALLY):
            if self.job.is_timeseries():
                # Timeseries jobs - show plot button, hide raster/vector buttons
                self.plot_tb.setEnabled(True)
                self.plot_tb.show()
                self.add_to_canvas_pb.hide()
                self.metadata_pb.hide()
                self.load_tb.hide()
                self.edit_tb.hide()
            elif self.job.is_vector():
                self.download_tb.hide()
                self.add_to_canvas_pb.hide()
                self.open_details_tb.hide()
            elif self.job.is_file():
                # File jobs need most buttons hidden
                self.download_tb.hide()
                self.add_to_canvas_pb.hide()
                self.metadata_pb.hide()
                self.load_tb.hide()
                self.edit_tb.hide()
            else:
                # Raster jobs - hide vector-specific buttons
                self.load_tb.hide()
                self.edit_tb.hide()

        # Show logs button for all job types (local and remote)
        self.view_logs_tb.show()

        # Set up dual loading menu for appropriate jobs
        # Only show dual menu for downloaded/local jobs that have both vector and raster results
        if (
            self.job.status in (JobStatus.DOWNLOADED, JobStatus.GENERATED_LOCALLY)
            and self._has_both_vector_and_raster_results()
        ):
            self.load_vector_menu_setup()
            self.load_tb.setMenu(self.load_vector_menu)

        # Initialize dataset report handler
        if self._report_handler:
            try:
                self._report_handler.init()
            except Exception as e:
                print(f"Warning: Failed to initialize report handler: {e}")
                self._report_handler = None

    def _has_both_vector_and_raster_results(self):
        """Check if this job has both vector and raster results available."""
        # Only show dual menu for jobs that are actually downloaded/available locally
        if self.job.status not in (JobStatus.DOWNLOADED, JobStatus.GENERATED_LOCALLY):
            return False

        # Use cache-aware is_vector() / is_raster() which work even when
        # results have not been loaded from pickle.
        has_vector = self.job.is_vector()
        has_raster = self.job.is_raster()

        # Error recode jobs store raster references in params, not in results
        if has_vector and self._is_error_recode_job():
            return True

        # Only return true if we have both types AND they're actually accessible
        if has_vector and has_raster:
            # Need results loaded to verify the vector URI is actually local.
            # If results are not loaded yet, trust the cached flags and return True.
            if self.job.results is None:
                return True

            # Additional check: make sure the vector URI actually points to a file
            # (not just a placeholder or remote URL)
            vector_result = self.job.get_first_result_by_type(VectorResults)
            try:
                if vector_result and vector_result.uri:
                    vector_uri = str(vector_result.uri.uri)
                    # If it's a local file path or accessible resource, show dual menu
                    if vector_uri and not vector_uri.startswith("http"):
                        return True
            except (AttributeError, TypeError):
                pass

        return False

    def _disable_main_action_buttons(self):
        """Helper method to disable main action buttons for jobs that can't be used."""
        self.add_to_canvas_pb.setEnabled(False)
        self.metadata_pb.setEnabled(False)
        self.load_tb.hide()
        self.edit_tb.hide()

    def _hide_all_action_buttons(self):
        """Helper method to hide all action buttons for failed/cancelled jobs."""
        self.add_to_canvas_pb.setEnabled(False)
        self.add_to_canvas_pb.hide()
        self.metadata_pb.setEnabled(False)
        self.metadata_pb.hide()
        self.load_tb.hide()
        self.edit_tb.hide()
        self.report_pb.hide()
        self.download_tb.hide()

    def has_loadable_result(self):
        """Check if this job has loadable results.

        When results are loaded, checks directly. When results are not loaded
        (for performance), uses the cached has_loadable_result flag from SQLite.
        """
        # First check for cached value (set when loading from SQLite without results)
        cached_value = getattr(self.job, "_cached_has_loadable_result", None)
        if cached_value is not None:
            return cached_value

        # Fall back to checking results directly
        result = False

        if self.job.results is not None:
            # Check each result for a loadable URI
            for res in self.job._get_results_list():
                if not hasattr(res, "uri") or not res.uri:
                    continue
                if manager.is_gdal_vsi_path(res.uri.uri) or (
                    res.uri.uri.suffix in [".vrt", ".tif"] and res.uri.uri.exists()
                ):
                    result = True
                    break

        return result

    def show_details(self):
        manager.job_manager.ensure_results_loaded(self.job)
        manager.job_manager.ensure_params_loaded(self.job)
        self.main_dock.pause_scheduler()
        DatasetDetailsDialogue(self.job, parent=iface.mainWindow()).exec_()
        self.main_dock.resume_scheduler()

    def show_metadata(self, file_path):
        if not self.is_widget_valid():
            return
        self.main_dock.pause_scheduler()
        ds_metadata = metadata.read_qmd(file_path)
        dlg = metadata_dialog.DlgDatasetMetadata(iface.mainWindow())
        dlg.set_metadata(ds_metadata)
        dlg.exec_()
        ds_metadata = dlg.get_metadata()
        metadata.save_qmd(file_path, ds_metadata)
        self.main_dock.resume_scheduler()

    def view_execution_logs(self):
        """Open the execution logs dialog as a non-blocking dialog."""
        from ..dialog_manager import dialog_manager
        from ..execution_logs_dialog import DlgExecutionLogs

        # Create unique dialog ID for this job
        dialog_id = f"logs_{str(self.job.id)}"

        # Check if dialog already exists and is visible
        existing_dialog = dialog_manager.get_dialog(dialog_id)
        if existing_dialog and not existing_dialog.isHidden():
            # Bring existing dialog to front
            existing_dialog.raise_()
            existing_dialog.activateWindow()
            return

        # Create and show the dialog as non-blocking
        # Use None as parent to make it a top-level window
        self.logs_dialog = DlgExecutionLogs(self.job, parent=None)
        self.logs_dialog.show()
        self.logs_dialog.raise_()
        self.logs_dialog.activateWindow()

    def load_data_menu_setup(self):
        self.load_data_menu = QtWidgets.QMenu()
        action_add_default_data_to_map = self.load_data_menu.addAction(
            self.tr("Add default layers from this dataset to map")
        )
        action_add_default_data_to_map.triggered.connect(self.load_dataset)
        action_choose_layers_to_add_to_map = self.load_data_menu.addAction(
            self.tr("Select specific layers from this dataset to add to map...")
        )
        action_choose_layers_to_add_to_map.triggered.connect(
            self.load_dataset_choose_layers
        )

    def load_dataset(self):
        manager.job_manager.ensure_results_loaded(self.job)
        manager.job_manager.display_default_job_results(self.job)

    def delete_dataset(self):
        self.main_dock.pause_scheduler()
        utils.delete_dataset(self.job)
        self.main_dock.resume_scheduler()

    def load_dataset_choose_layers(self):
        manager.job_manager.ensure_results_loaded(self.job)
        self.main_dock.pause_scheduler()
        dialogue = DlgDataIOAddLayersToMap(self, self.job)
        dialogue.exec_()
        self.main_dock.resume_scheduler()

    def load_layer(self):
        # Display only vector layers - this is triggered by "Add vector layer to map"
        manager.job_manager.ensure_results_loaded(self.job)
        for result in self.job._get_results_list():
            if isinstance(result, VectorResults):
                layer_path = result.uri.uri
                layers.add_vector_layer(str(layer_path), result.name)
        self.edit_tb.setEnabled(True)

    def show_time_series_plot(self):
        """Show the time series plot dialog as a non-blocking, persistent dialog."""
        from ..dialog_manager import dialog_manager

        manager.job_manager.ensure_results_loaded(self.job)

        # Get table from TimeSeriesTableResult if available
        ts_result = self.job.get_first_result_by_type(TimeSeriesTableResult)
        table = ts_result.table if ts_result else []
        if len(table) == 0:
            self.main_dock.iface.messageBar().pushMessage(
                self.tr("Time series table is empty"), level=1, duration=5
            )
            return

        # Create unique dialog ID for this job's timeseries
        dialog_id = f"timeseries_{str(self.job.id)}"

        # Check if dialog already exists and is visible
        existing_dialog = dialog_manager.get_dialog(dialog_id)
        if existing_dialog and not existing_dialog.isHidden():
            # Bring existing dialog to front
            existing_dialog.raise_()
            existing_dialog.activateWindow()
            return

        data = [x for x in table if x["name"] == "mean"][0]
        base_title = self.tr("Time Series")
        if self.job.task_name:
            task_name = self.job.task_name
        else:
            task_name = ""

        # Create dialog as top-level window with job_id for dialog manager
        dlg_plot = DlgPlotTimeries(parent=None, job_id=self.job.id)
        self.set_widget_title(dlg_plot, base_title)
        labels = {
            "title": task_name,
            "bottom": self.tr("Time"),
            "left": [self.tr("Integrated NDVI"), self.tr("NDVI x 10000")],
        }
        dlg_plot.plot_data(data["time"], data["y"], labels)

        # Show as non-blocking dialog
        dlg_plot.show()
        dlg_plot.raise_()
        dlg_plot.activateWindow()

        # Store reference to prevent garbage collection
        self.timeseries_dialog = dlg_plot

    def _setup_status_bar(self, status_text: str, color: str):
        """Set up the colored status bar with status text."""
        # Set the status frame background color
        self.status_frame.setStyleSheet(f"""
            QFrame#status_frame {{
                background-color: {color};
                border: none;
            }}
        """)

        # Set the status label text and styling
        self.status_label.setText(status_text)
        self.status_label.setStyleSheet("""
            QLabel#status_label {
                color: white;
                border: none;
                border-radius: 0px;
                font-weight: bold;
                font-size: 12pt;
                padding: 1px;
                background-color: transparent;
            }
        """)

        # Create a custom rotated text approach
        # We'll override the paintEvent to draw rotated text
        self._setup_rotated_label(status_text)

    def _setup_rotated_label(self, status_text: str):
        """Set up the status label with rotated text."""

        # Create a custom paint event for the status label
        def paintEvent(event):
            painter = QtGui.QPainter(self.status_label)

            # Enable high-quality text rendering
            painter.setRenderHint(QtGui.QPainter.Antialiasing, True)
            painter.setRenderHint(QtGui.QPainter.TextAntialiasing, True)
            painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform, True)
            painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing, True)

            # Get the label's rectangle
            rect = self.status_label.rect()

            # Set up the font and color with better settings
            font = QtGui.QFont("Arial", 8)  # Use specific font and size
            font.setBold(True)
            font.setHintingPreference(QtGui.QFont.PreferFullHinting)
            painter.setFont(font)

            # Use a proper QPen for better text rendering
            pen = QtGui.QPen(QtGui.QColor("white"))
            pen.setWidth(1)
            painter.setPen(pen)

            # Save the current transformation
            painter.save()

            # Get text metrics for proper positioning
            font_metrics = painter.fontMetrics()
            text_width = font_metrics.horizontalAdvance(status_text)
            text_height = font_metrics.height()

            # Move to center and rotate
            center = rect.center()
            painter.translate(center.x(), center.y())
            painter.rotate(-90)  # 90 degrees counterclockwise

            # Draw text centered at origin
            text_x = -text_width // 2
            text_y = text_height // 4  # Adjust for baseline

            painter.drawText(text_x, text_y, status_text)

            # Restore transformation
            painter.restore()
            painter.end()

        # Override the paintEvent of the status_label
        self.status_label.paintEvent = paintEvent

        # Clear the label text since we're drawing it manually
        self.status_label.setText("")

    def _get_status_display_info(self, job_status: JobStatus):
        """Get the display text and color for a job status."""
        status_mapping = {
            JobStatus.RUNNING: ("RUNNING", "#2196F3"),
            JobStatus.PENDING: ("PENDING", "#9C27B0"),
            JobStatus.READY: ("READY", "#9C27B0"),
            JobStatus.FINISHED: ("FINISHED", "#4CAF50"),
            JobStatus.DOWNLOADED: ("DOWNLOADED", "#8F5D00"),
            JobStatus.GENERATED_LOCALLY: ("LOCAL", "#CC942C"),
            JobStatus.FAILED: ("FAILED", "#EC2121"),
            JobStatus.CANCELLED: ("CANCELLED", "#E2810A"),
            JobStatus.EXPIRED: ("EXPIRED", "#727272"),
        }

        return status_mapping.get(job_status, ("UNKNOWN", "#4E4E4E"))

    def _handle_time_series_result(self):
        """Helper method to handle TimeSeriesTableResult specific UI changes."""
        if self.job.is_timeseries():
            self.plot_tb.show()
            self.add_to_canvas_pb.hide()
            self.metadata_pb.hide()
            self.view_logs_tb.show()

    def _hide_buttons_failed_cancelled(self):
        """Helper method to hide buttons on FAILED/CANCELLED jobs."""
        self._hide_all_action_buttons()
        self.view_logs_tb.show()

    def set_widget_title(
        self, widget: QtWidgets.QWidget, base_title: typing.Optional[str] = None
    ):
        # Convenient function for setting the title of a widget.
        if base_title is None:
            base_title = widget.windowTitle()

        if self.job.task_name:
            task_name = self.job.task_name
        else:
            task_name = ""

        if not task_name:
            win_title = base_title
        else:
            win_title = f"{base_title} - {task_name}"

        widget.setWindowTitle(win_title)

    def edit_layer(self):
        manager.job_manager.ensure_results_loaded(self.job)
        if not self.has_connected_data():
            self.main_dock.pause_scheduler()
            dlg = DlgSelectDataset(self, validate_all=True)
            self.set_widget_title(dlg)
            if dlg.exec_() == QtWidgets.QDialog.Accepted:
                prod = dlg.prod_band()
                lc = dlg.lc_band()
                soil = dlg.soil_band()
                sdg = dlg.sdg_band()

                if prod:
                    self.job.params["prod"] = {
                        "path": str(prod.path),
                        "band": prod.band_index,
                        "band_name": prod.band_info.name,
                        "uuid": str(prod.job.id),
                    }

                if lc:
                    self.job.params["lc"] = {
                        "path": str(lc.path),
                        "band": lc.band_index,
                        "band_name": lc.band_info.name,
                        "uuid": str(lc.job.id),
                    }

                if soil:
                    self.job.params["soil"] = {
                        "path": str(soil.path),
                        "band": soil.band_index,
                        "band_name": soil.band_info.name,
                        "uuid": str(soil.job.id),
                    }
                if sdg:
                    self.job.params["sdg"] = {
                        "path": str(sdg.path),
                        "band": sdg.band_index,
                        "band_name": sdg.band_info.name,
                        "uuid": str(sdg.job.id),
                    }

                manager.job_manager.write_job_metadata_file(self.job)
                manager.job_manager.display_error_recode_layer(self.job)

                band_datas = [
                    {
                        "path": str(prod.path.as_posix()),
                        "name": prod.band_info.name,
                        "out_name": "land_productivity",
                        "index": prod.band_index,
                    },
                    {
                        "path": str(lc.path.as_posix()),
                        "name": lc.band_info.name,
                        "out_name": "land_cover",
                        "index": lc.band_index,
                    },
                    {
                        "path": str(soil.path.as_posix()),
                        "name": soil.band_info.name,
                        "out_name": "soil_organic_carbon",
                        "index": soil.band_index,
                    },
                    {
                        "path": str(sdg.path.as_posix()),
                        "name": sdg.band_info.name,
                        "out_name": "sdg",
                        "index": sdg.band_index,
                    },
                ]
                # Get the error recode vector result
                vector_result = self.job.get_first_result_by_type(VectorResults)
                if vector_result and vector_result.uri:
                    layers.set_default_stats_value(
                        str(vector_result.uri.uri), band_datas
                    )

                manager.job_manager.edit_error_recode_layer(self.job)
                self.main_dock.resume_scheduler()
        else:
            manager.job_manager.display_error_recode_layer(self.job)
            manager.job_manager.edit_error_recode_layer(self.job)

    def has_connected_data(self):
        manager.job_manager.ensure_params_loaded(self.job)
        has_prod = True if "prod" in self.job.params else False
        has_lc = True if "lc" in self.job.params else False
        has_soil = True if "soil" in self.job.params else False
        has_sdg = True if "sdg" in self.job.params else False

        return has_prod and has_lc and has_soil and has_sdg

    def prepare_metadata_menu(self):
        self.metadata_menu.clear()
        manager.job_manager.ensure_results_loaded(self.job)

        file_path = (
            os.path.splitext(manager.job_manager.get_job_file_path(self.job))[0]
            + ".qmd"
        )
        action = self.metadata_menu.addAction(self.tr("Dataset metadata"))
        action.triggered.connect(lambda _, x=file_path: self.show_metadata(x))
        self.metadata_menu.addSeparator()

        # Check all RasterResults for rasters with metadata
        for raster_result in self.job.get_results_by_type(RasterResults):
            if hasattr(raster_result, "rasters") and raster_result.rasters:
                for raster in raster_result.rasters.values():
                    file_path = os.path.splitext(raster.uri.uri)[0] + ".qmd"
                    action = self.metadata_menu.addAction(
                        self.tr("{} metadata").format(os.path.split(raster.uri.uri)[1])
                    )
                    action.triggered.connect(
                        lambda _, x=file_path: self.show_metadata(x)
                    )

    @property
    def report_handler(self):
        """
        Returns handler with helper methods for generating and viewing
        reports. Returns None if widget is being destroyed.
        """
        if not self.is_widget_valid():
            return None

        try:
            return self._report_handler
        except RuntimeError:
            # Widget or report handler has been deleted
            return None

    def load_vector_menu_setup(self):
        self.load_vector_menu = QtWidgets.QMenu()
        action_add_vector_to_map = self.load_vector_menu.addAction(
            self.tr("Add vector layer to map")
        )
        action_add_vector_to_map.triggered.connect(self.load_layer)
        action_add_rasters_to_map = self.load_vector_menu.addAction(
            self.tr("Add raster layers to map")
        )
        action_add_rasters_to_map.triggered.connect(self.load_rasters_layers)

    def _is_error_recode_job(self):
        """Check if this job is an error recode job based on script id."""
        if not self.job.script:
            return False
        script_id = self.job.script.id or ""
        # Use exact match on the error recode script id
        return script_id == "sdg-15-3-1-error-recode"

    def load_rasters_layers(self):
        if self._is_error_recode_job():
            # For error recode jobs, load rasters from referenced jobs via params
            manager.job_manager.ensure_params_loaded(self.job)
            jobs = manager.job_manager._known_downloaded_jobs.copy()
            self._load_raster(jobs, "soil")
            self._load_raster(jobs, "lc")
            self._load_raster(jobs, "prod")
            self._load_raster(jobs, "sdg")
        else:
            # For generic jobs, load rasters directly from this job's results
            manager.job_manager.ensure_results_loaded(self.job)
            for result in self.job._get_results_list():
                if isinstance(result, RasterResults):
                    if result.uri.uri.suffix in [".tif", ".vrt"]:
                        for band_index, band in enumerate(result.get_bands(), start=1):
                            if band.add_to_map:
                                layers.add_layer(
                                    str(result.uri.uri),
                                    band_index,
                                    JobBand.Schema().dump(band),
                                )

    def _load_raster(self, jobs, name):
        if name in self.job.params:
            data = self.job.params[name]
            job = (
                jobs[uuid.UUID(data["uuid"])]
                if uuid.UUID(data["uuid"]) in jobs
                else None
            )
            if job:
                # Get bands from the first raster result
                raster_result = job.get_first_result_by_type(RasterResults)
                if raster_result:
                    band = raster_result.get_bands()[data["band"] - 1]
                    layers.add_layer(
                        str(data["path"]),
                        int(data["band"]),
                        JobBand.Schema().dump(band),
                    )

    def closeEvent(self, event):
        """Handle widget cleanup when being closed/destroyed."""
        self._is_being_destroyed = True

        # Clean up report handler to prevent accessing deleted widgets
        if hasattr(self, "_report_handler") and self._report_handler:
            try:
                # Clear the button reference in the report handler to prevent access to deleted widget
                self._report_handler._rpt_btn = None
            except (RuntimeError, AttributeError):
                # Widget may already be deleted, that's ok
                pass

        super().closeEvent(event)

    def is_widget_valid(self):
        """Check if this widget and its UI elements are still valid."""
        if self._is_being_destroyed:
            return False

        try:
            # Try to access a basic property to see if widget is still valid
            _ = self.isVisible()
            return True
        except RuntimeError:
            # Widget has been deleted
            return False
