"""
/***************************************************************************
 QRAVEDockWidget
                                 A QGIS plugin
 QRAVE Dock Widget
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                             -------------------
        begin                : 2021-04-12
        git sha              : $Format:%H$
        copyright            : (C) 2021 by NAR
        email                : info@northarrowresearch.com
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""

from __future__ import annotations

from collections.abc import Iterator
import json
import os

from qgis.core import Qgis, QgsApplication, QgsProject
from qgis.PyQt.QtCore import QModelIndex, Qt, QTimer, QUrl, pyqtSignal, pyqtSlot
from qgis.PyQt.QtGui import QDesktopServices, QStandardItem, QStandardItemModel
from qgis.PyQt.QtWidgets import QApplication, QDockWidget, QFileDialog, QMessageBox

from .classes.basemaps import BaseMaps, QRaveBaseMap
from .classes.context_menu import ContextMenu
from .classes.data_exchange.DataExchangeAPI import DataExchangeAPI
from .classes.GraphQLAPI import FetchJsonTask, RefreshTokenTask, RunGQLQueryTask
from .classes.project import Project, ProjectTreeData
from .classes.qrave_map_layer import QRaveMapLayer, QRaveTreeTypes
from .classes.remote_project import RemoteProject
from .classes.rspaths import safe_make_abspath, safe_make_relpath
from .classes.settings import CONSTANTS, MESSAGE_CATEGORY, Settings
from .classes.telemetry import Telemetry
from .compat import MSGBOX_BTN_NO, MSGBOX_BTN_YES, USER_ROLE
from .icon_utils import qrave_icon
from .meta_widget import MetaType
from .project_download_dialog import ProjectDownloadDialog
from .project_upload_dialog import ProjectUploadDialog
from .ui.dock_widget_ui import Ui_QRAVEDockWidgetBase

ADD_TO_MAP_TYPES = ["polygon", "raster", "point", "line"]


class QRAVEDockWidget(QDockWidget, Ui_QRAVEDockWidgetBase):
    """QGIS Plugin Implementation."""

    layerMenuOpen = pyqtSignal(ContextMenu, QStandardItem, ProjectTreeData)

    closingPlugin = pyqtSignal()
    dataChange = pyqtSignal()
    showMeta = pyqtSignal()
    metaChange = pyqtSignal(str, str, dict, str, bool)

    def __init__(self, parent=None):
        """Constructor."""
        super().__init__(parent)

        self.setupUi(self)
        self.settings = Settings()
        self.qproject = QgsProject.instance()
        self.qproject.cleared.connect(self.close_all)

        self.qproject.homePathChanged.connect(self.project_homePathChanged)
        self.qproject.readProject.connect(self.reload_tree)

        self.treeView.setContextMenuPolicy(getattr(Qt, "CustomContextMenu", Qt.ContextMenuPolicy.CustomContextMenu))
        self.treeView.customContextMenuRequested.connect(self.open_menu)
        self.treeView.doubleClicked.connect(self.default_tree_action)
        self.treeView.clicked.connect(self.item_change)

        self.treeView.expanded.connect(self.expand_tree_item)

        # If a project fails to load we track it and don't autorefresh it.
        self.failed_loads = []
        self._remote_project_cache = {}
        self._fetching_projects = set()
        self.dataExchangeAPI: DataExchangeAPI | None = None

        self.model = QStandardItemModel()

        # Initialize our classes
        self.basemaps = BaseMaps()
        self.treeView.setModel(self.model)

        self.dataChange.connect(self.reload_tree)
        # self.fix_broken_project_paths()
        self.reload_tree()

    def expand_tree_item(self, idx: QModelIndex) -> None:
        item = self.model.itemFromIndex(idx)
        item_data = item.data(USER_ROLE)
        if item_data and item_data.data and isinstance(item_data.data, QRaveBaseMap):
            item_data.data.load_layers()

    def _get_projects(self) -> list:
        """Get the list of loaded projects

        Returns:
            [type]: [description]
        """
        qrave_projects = self.get_project_settings()  # noqa: F841
        root_item = self.model.invisibleRootItem()
        projects = []

        for row in range(root_item.rowCount()):
            child_item = root_item.child(row)
            if child_item is not None:
                item_data = child_item.data(USER_ROLE)
                if item_data and hasattr(item_data, "project") and item_data.project is not None and getattr(item_data, "type", None) != QRaveTreeTypes.PROJECT_LOAD_ERROR:
                    projects.append(item_data.project)
        return projects

    def _get_project_by_name(self, name: str) -> Project | RemoteProject | None:
        try:
            for project in self._get_projects():
                if project.qproject.text() == name:
                    return project
            return None
        except Exception:
            return None

    @pyqtSlot()
    def reload_tree(self) -> None:
        # re-initialize our model and reload the projects from file
        # Try not to do this too often if you can

        # Store sets of expanded paths keyed by project name
        expanded_paths_by_project = {}

        def get_expanded_paths(idx, parent_path=""):
            paths = set()

            # If the current item is expanded, add its path
            if self.treeView.isExpanded(idx):
                paths.add(parent_path)

            for idy in range(self.model.rowCount(idx)):
                child_idx = self.model.index(idy, 0, idx)
                item = self.model.itemFromIndex(child_idx)
                if item:
                    # Determine unique path for child
                    # Use a delimiter (e.g., '///') to avoid collision with folder names
                    item_name = item.text()
                    child_path = f"{parent_path}///{item_name}" if parent_path else item_name
                    paths.update(get_expanded_paths(child_idx, child_path))

            return paths

        new_projects = self._get_projects()  # noqa: F841 (used only to warm cache before iterating)

        for project in self._get_projects():
            if not isinstance(project, str):
                project_name = project.qproject.text()
                paths = get_expanded_paths(self.model.indexFromItem(project.qproject), "")
                expanded_paths_by_project[project_name] = paths

        basemap_paths = None
        region = self.settings.getValue("basemapRegion")
        if self.basemaps.regions is not None and len(self.basemaps.regions) > 0:
            if region in self.basemaps.regions:
                # We need to find the basemap item in the current model
                item = self.basemaps.regions[region]
                idx = self.model.indexFromItem(item)
                if idx.isValid():
                    basemap_paths = get_expanded_paths(idx, "")

        self.model.clear()

        qrave_projects = self.get_project_settings()

        for project_name, _basename, project_path in qrave_projects:
            if project_path.startswith("remote:"):
                project_id = project_path[7:]
                if project_id in self._remote_project_cache:
                    project = RemoteProject(self._remote_project_cache[project_id])
                    project.load()
                    if project.qproject:
                        project.qproject.setText(project_name)
                        self.model.appendRow(project.qproject)
                        if project_name in expanded_paths_by_project:
                            self.restore_expanded_state(self.model.indexFromItem(project.qproject), expanded_paths_by_project[project_name], "")
                        else:
                            self.expand_children_recursive(self.model.indexFromItem(project.qproject))
                else:
                    self.show_loading(project_name)
                    self.fetch_missing_remote_project(project_id)
                continue

            project = Project(project_path)
            project.load()

            if project is not None and project.exists and project.qproject is not None and project.loadable:
                project.qproject.setText(project_name)
                self.model.appendRow(project.qproject)
                if project_name in expanded_paths_by_project:
                    self.restore_expanded_state(self.model.indexFromItem(project.qproject), expanded_paths_by_project[project_name], "")
                else:
                    self.expand_children_recursive(self.model.indexFromItem(project.qproject))
            else:
                # Project failed to load: show a placeholder error node so the
                # user can see what failed and choose to remove or retry it.
                error_item = self._make_load_error_node(project_name, project_path, project)
                self.model.appendRow(error_item)

        # Load the tree objects
        self.basemaps.load()

        # Now load the basemaps
        region = self.settings.getValue("basemapRegion")
        if self.settings.getValue("basemapsInclude") and region is not None and len(region) > 0 and region in self.basemaps.regions.keys():
            self.model.appendRow(self.basemaps.regions[region])
            if basemap_paths is not None:
                self.restore_expanded_state(self.model.indexFromItem(self.basemaps.regions[region]), basemap_paths, "")
            else:
                self.expand_children_recursive(self.model.indexFromItem(self.basemaps.regions[region]))

    def get_project_settings(self) -> list:
        """Return the list of projects from settings, no user interaction."""
        try:
            qrave_projects_raw, type_conversion_ok = self.qproject.readEntry(CONSTANTS["settingsCategory"], "qrave_projects")
            if not type_conversion_ok:
                qrave_projects = []
            else:
                qrave_projects = json.loads(qrave_projects_raw)

                if qrave_projects is None or not isinstance(qrave_projects, list):
                    qrave_projects = []

        except Exception as e:
            self.settings.log(f"Error loading project settings: {e}", Qgis.Warning)
            return []

        qgs_path = self.qproject.absoluteFilePath()
        if os.path.isfile(qgs_path):
            qgs_path_dir = os.path.dirname(qgs_path)
            # Change all relative paths back to absolute ones
            qrave_projects = [(name, basename, xml_path if xml_path.startswith("remote:") else safe_make_abspath(xml_path, qgs_path_dir)) for name, basename, xml_path in qrave_projects]

        return qrave_projects

    def fix_broken_project_paths(self) -> None:
        """Prompt user to fix broken project paths, update settings if fixed."""
        qrave_projects = self.get_project_settings()
        clean_projects = []
        for name, basename, xml_path in qrave_projects:
            if not os.path.isfile(xml_path):
                result = QMessageBox.question(
                    self, "Locate Missing Project", f"The Riverscapes project file for '{name}' could not be found at the expected location:\n\n{xml_path}\n\nWould you like to locate it?", MSGBOX_BTN_YES | MSGBOX_BTN_NO, MSGBOX_BTN_NO
                )
                if result != MSGBOX_BTN_YES:
                    continue
                new_path, _ = QFileDialog.getOpenFileName(self, f"Locate missing project file for '{name}'", "", "Riverscapes Project (project.rs.xml *.xml);;All Files (*)")
                if new_path:
                    clean_projects.append((name, basename, new_path))
            else:
                clean_projects.append((name, basename, xml_path))
        self.set_project_settings(clean_projects)

    @pyqtSlot()
    def project_homePathChanged(self) -> None:
        """Trigger an event before saving the project so we have an opportunity to corrent the paths"""
        projects = self.get_project_settings()
        self.set_project_settings(projects)

    def set_project_settings(self, projects: list[tuple]) -> None:
        qgs_path = self.qproject.absoluteFilePath()
        if projects and os.path.isdir(os.path.dirname(qgs_path)):
            qgs_path_dir = os.path.dirname(qgs_path)
            # Swap all abspaths for relative ones
            projects = [(name, basename, xml_path if xml_path.startswith("remote:") else safe_make_relpath(xml_path, qgs_path_dir)) for name, basename, xml_path in projects]
        self.qproject.writeEntry(CONSTANTS["settingsCategory"], "qrave_projects", json.dumps(projects))

    def remove_project_settings(self) -> None:
        self.qproject.removeEntry(CONSTANTS["settingsCategory"], "qrave_projects")

    @pyqtSlot(str)
    def add_project(self, xml_path: str) -> None:
        qrave_projects = self.get_project_settings()

        # Prevent the same project file from being loaded more than once
        abs_xml_path = os.path.abspath(xml_path)
        if any(not p.startswith("remote:") and os.path.abspath(p) == abs_xml_path for _, _, p in qrave_projects):
            self.settings.msg_bar(
                "Project already open",
                abs_xml_path,
                Qgis.Warning,
            )
            return

        test_project = Project(xml_path)
        test_project.load()
        if not test_project.loadable or not test_project.exists or test_project.qproject is None:
            self.settings.log(f"Error loading project: {xml_path}", Qgis.Warning)
            return
        basename = test_project.qproject.text()
        count = [project[1] for project in qrave_projects].count(basename)
        name = f"{basename} Copy {count:02d}" if count > 0 else basename
        qrave_projects.insert(0, (name, basename, xml_path))
        self.set_project_settings(qrave_projects)
        self.reload_tree()

        new_project = self._get_project_by_name(name)

        # If this is a fresh load and the setting is set we load the default view
        load_default_setting = self.settings.getValue("loadDefaultView")

        if new_project is not None:
            self.zoom_to_project(new_project)
            if load_default_setting and new_project.default_view is not None and new_project.default_view in new_project.views:
                self.add_children_to_map(new_project.qproject, new_project.views[new_project.default_view])

            # Optional telemetry
            Telemetry(MESSAGE_CATEGORY).send("Load_Project")
            self._add_to_recent_projects(xml_path)

    def _add_to_recent_projects(self, xml_path: str, name: str | None = None) -> None:
        """Prepend xml_path to the recent projects list, capped at 5.

        Each entry is stored as a dict ``{"path": ..., "name": ...}`` so that
        human-readable labels are available even for ``remote:`` entries.
        Legacy entries (bare strings) are tolerated on read.
        """
        recent: list[dict] = []
        for entry in self.settings.getValue("recentProjects") or []:
            if isinstance(entry, dict):
                recent.append(entry)
            else:
                # Back-compat: promote bare string to dict
                recent.append({"path": str(entry), "name": ""})

        is_remote = xml_path.startswith("remote:")
        key = xml_path if is_remote else os.path.abspath(xml_path)
        # Deduplicate by path
        recent = [e for e in recent if e.get("path", "") != key]
        if not name:
            if is_remote:
                name = key[7:]  # fall back to the UUID portion
            else:
                name = os.path.basename(os.path.dirname(key)) or os.path.basename(key)
        recent.insert(0, {"path": key, "name": name})
        self.settings.setValue("recentProjects", recent[:5])

    @pyqtSlot(dict)
    def add_remote_project(self, gql_data: dict) -> None:
        qrave_projects = self.get_project_settings()

        test_project = RemoteProject(gql_data)
        test_project.load()

        # Prevent the same remote project from being loaded more than once
        remote_key = f"remote:{test_project.id}"
        if any(p == remote_key for _, _, p in qrave_projects):
            self.settings.msg_bar(
                "Remote project already open",
                str(test_project.id),
                Qgis.Warning,
            )
            return

        if test_project.qproject is None:
            self.settings.log("Error loading remote project", Qgis.Warning)
            return

        basename = test_project.qproject.text()
        count = [project[1] for project in qrave_projects].count(basename)
        name = f"{basename} Copy {count:02d}" if count > 0 else basename

        # We store it with a 'remote:' prefix in the xml_path field
        qrave_projects.insert(0, (name, basename, f"remote:{test_project.id}"))
        self.set_project_settings(qrave_projects)

        # We also need to store the data somewhere if we want to reload it without re-fetching
        # But for now, let's just reload it from the GQL response which we have.
        # However, reload_tree clears the model.

        # I'll add a cache for remote project data
        if not hasattr(self, "_remote_project_cache"):
            self._remote_project_cache = {}
        self._remote_project_cache[test_project.id] = gql_data

        self.reload_tree()
        self.fetch_dataset_metadata(test_project.id)

        # Track in recent projects so the toolbar menu shows this project by name
        self._add_to_recent_projects(f"remote:{test_project.id}", name=name)

        # If this is a fresh load and the setting is set we load the default view
        new_project = self._get_project_by_name(name)
        load_default_setting = self.settings.getValue("loadDefaultView")

        if new_project:
            self.zoom_to_project(new_project)
            if load_default_setting and new_project.default_view is not None and new_project.default_view in new_project.views and new_project.views[new_project.default_view] is not None:
                view_layers = new_project.views[new_project.default_view]
                self.add_children_to_map(new_project.qproject, view_layers)

    def show_loading(self, label: str) -> None:
        """Add a temporary loading item to the tree"""
        # Make sure it's not already there
        self.hide_loading()

        loading_item = QStandardItem(qrave_icon("refresh.png"), f"Loading Remote: {label}...")
        # Use a special data role to identify it
        loading_item.setData("LOADING_PLACEHOLDER", USER_ROLE + 10)
        self.model.insertRow(0, loading_item)

    def hide_loading(self) -> None:
        """Remove any loading items from the tree"""
        root = self.model.invisibleRootItem()
        for i in range(root.rowCount()):
            item = root.child(i)
            if item and item.data(USER_ROLE + 10) == "LOADING_PLACEHOLDER":
                root.removeRow(i)
                # We return here because we only expect one, and loop indices change after removeRow
                return

    def _make_load_error_node(self, project_name: str, project_path: str, project: Project) -> QStandardItem:
        """Create a top-level error placeholder for a project that failed to load.

        Args:
            project_name: Display name of the project.
            project_path: File-system path to the project XML.
            project: The (partially loaded) Project instance.

        Returns:
            A styled QStandardItem ready to be appended to the model root.
        """
        from qgis.PyQt.QtGui import QBrush, QColor

        item = QStandardItem(qrave_icon("close.png"), f"⚠ {project_name}")

        # Italic + muted-red — follows the "missing file" visual pattern
        font = item.font()
        font.setItalic(True)
        item.setFont(font)
        item.setForeground(QBrush(QColor(180, 40, 40)))

        # Build a helpful tooltip
        if not project.exists:
            tip = f"Project file not found:\n{project_path}"
        elif project.load_error:
            tip = f"Failed to load project.\nPath: {project_path}\nError: {project.load_error}\nCheck the Riverscapes Viewer log panel for details."
        else:
            tip = f"Failed to load project.\nPath: {project_path}\nCheck the Riverscapes Viewer log panel for details."
        item.setToolTip(tip)

        # Store structured data so context-menu and click handlers can detect
        # this node by type rather than by sentinel string.
        item.setData(
            ProjectTreeData(
                QRaveTreeTypes.PROJECT_LOAD_ERROR,
                project=project,
                data=project_path,  # data holds the raw path for removal
            ),
            USER_ROLE,
        )
        return item

    def closeEvent(self, event) -> None:
        self.hide()
        self.qproject.removeEntry(CONSTANTS["settingsCategory"], "enabled")
        self.closingPlugin.emit()
        event.accept()

    def zoom_to_project(self, project: Project | RemoteProject) -> None:
        """Zoom to the project extent"""
        if not project.bounds:
            return

        from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject, QgsRectangle
        from qgis.utils import iface

        if isinstance(project, RemoteProject):
            # Remote project bounds are [minLng, minLat, maxLng, maxLat]
            bbox = project.bounds.get("bbox")
            if bbox and len(bbox) >= 4:
                rect = QgsRectangle(bbox[0], bbox[1], bbox[2], bbox[3])
            else:
                return
        else:
            # Local project bounds are dictionary with minLat, minLng, maxLat, maxLng
            try:
                rect = QgsRectangle(project.bounds["minLng"], project.bounds["minLat"], project.bounds["maxLng"], project.bounds["maxLat"])
            except (KeyError, TypeError):
                return

        # The project bounds are always in degrees (WGS 84 - EPSG:4326)
        # We need to transform them to the current map canvas CRS
        source_crs = QgsCoordinateReferenceSystem("EPSG:4326")
        dest_crs = iface.mapCanvas().mapSettings().destinationCrs()

        if source_crs != dest_crs:
            transform = QgsCoordinateTransform(source_crs, dest_crs, QgsProject.instance())
            rect = transform.transformBoundingBox(rect)

        iface.mapCanvas().setExtent(rect)
        iface.mapCanvas().refresh()

    def expand_children_recursive(self, idx: QModelIndex | None = None, force: bool = False) -> None:
        """Expand all the children of a QTreeView node. Do it recursively
        TODO: Recursion might not be the best for large trees here.

        Args:
            idx (QModelIndex, optional): [description]. Defaults to None.
            force: ignore the "collapsed" business logic attribute
        """
        if idx is None:
            idx = self.treeView.rootIndex()

        for idy in range(self.model.rowCount(idx)):
            child = self.model.index(idy, 0, idx)
            self.expand_children_recursive(child, force)

        item = self.model.itemFromIndex(idx)
        item_data = item.data(USER_ROLE) if item is not None else None

        # NOTE: This is pretty verbose on purpose

        # This thing needs to have data or it defaults to being expanded
        if item_data is None or item_data.data is None:
            collapsed = False

        # Collapsed is an attribute set in the business logic
        # Never expand the QRaveBaseMap object becsause there's a network call involved
        elif isinstance(item_data.data, QRaveBaseMap) or (isinstance(item_data.data, dict) and "collapsed" in item_data.data and str(item_data.data["collapsed"]).lower() == "true"):
            collapsed = True

        else:
            collapsed = False

        if not self.treeView.isExpanded(idx) and not collapsed:
            self.treeView.setExpanded(idx, True)

    def restore_expanded_state(self, idx: QModelIndex, expanded_paths: set[str], current_path: str = "") -> None:
        """Expand all the children of a QTreeView node based on saved paths.

        Args:
            idx (QModelIndex): The index to start restoring from
            expanded_paths (set): Set of path strings (e.g. "Root///Folder A") that should be expanded
            current_path (str): The path of the current item
        """
        if idx is None or not idx.isValid():
            return

        # Recurse first so we can expand straight down
        for idy in range(self.model.rowCount(idx)):
            child_idx = self.model.index(idy, 0, idx)
            child_item = self.model.itemFromIndex(child_idx)
            if child_item:
                item_name = child_item.text()
                child_path = f"{current_path}///{item_name}" if current_path else item_name
                self.restore_expanded_state(child_idx, expanded_paths, child_path)

        item = self.model.itemFromIndex(idx)
        item_data = item.data(USER_ROLE) if item is not None else None

        # Determine if we should forcefully collapse (e.g. BaseMaps to avoid network hits)
        is_basemap = False
        if item_data and item_data.data:
            if isinstance(item_data.data, QRaveBaseMap):
                is_basemap = True

        # If the path is in our set, the user had it expanded.
        if current_path in expanded_paths and not is_basemap:
            if not self.treeView.isExpanded(idx):
                self.treeView.setExpanded(idx, True)

    def default_tree_action(self, idx: QModelIndex) -> None:
        if not idx.isValid():
            return

        item = self.model.itemFromIndex(idx)
        item_data: ProjectTreeData = item.data(USER_ROLE)

        if item_data is None or item_data.type == QRaveTreeTypes.PROJECT_LOAD_ERROR:
            return

        # This is the default action for all add-able layers including basemaps
        if isinstance(item_data.data, QRaveMapLayer):
            # Remote projects handle files and layers differently
            if isinstance(item_data.project, RemoteProject):
                if item_data.data.layer_type == QRaveMapLayer.LayerTypes.REPORT:
                    self.open_remote_report_in_browser(item_data)
                elif item_data.data.layer_type == QRaveMapLayer.LayerTypes.FILE:
                    self.open_remote_file_in_browser(item_data)
                else:
                    self.fetch_and_add_remote_layer(item, item_data)

            # Local projects
            elif item_data.data.layer_type in [QRaveMapLayer.LayerTypes.FILE, QRaveMapLayer.LayerTypes.REPORT]:
                self.file_system_open(item_data.data.layer_uri)
            else:
                QRaveMapLayer.add_layer_to_map(item)

        # Expand is the default option for wms because we might need to load the layers
        elif isinstance(item_data.data, QRaveBaseMap):
            if item_data.data.tile_type == "wms":
                pass
            # All the XYZ layers can be added normally.
            else:
                QRaveMapLayer.add_layer_to_map(item)

        elif item_data.type in [QRaveTreeTypes.PROJECT_ROOT]:
            self.change_meta(item, item_data, True)

        # For folder-y types we want Expand and contract is already implemented as a default
        elif item_data.type in [QRaveTreeTypes.PROJECT_FOLDER, QRaveTreeTypes.PROJECT_REPEATER_FOLDER, QRaveTreeTypes.PROJECT_VIEW_FOLDER, QRaveTreeTypes.BASEMAP_ROOT, QRaveTreeTypes.BASEMAP_SUPER_FOLDER, QRaveTreeTypes.BASEMAP_SUB_FOLDER]:
            # print("Default Folder Action")
            pass

        elif item_data.type == QRaveTreeTypes.PROJECT_VIEW:
            pass
            self.add_view_to_map(item_data)

    def item_change(self, pos: QModelIndex | None) -> None:
        """Triggered when the user selects a new item in the tree

        Args:pos
            pos ([type]): [description]
        """
        indexes = self.treeView.selectedIndexes()

        if len(indexes) < 1 or (pos is not None and not isinstance(pos, QModelIndex)):
            return

        # No multiselect so there is only ever one item
        item = self.model.itemFromIndex(indexes[0])
        data_item: ProjectTreeData = item.data(USER_ROLE)

        if data_item is None or data_item.type == QRaveTreeTypes.PROJECT_LOAD_ERROR:
            return

        if data_item.project is None or not data_item.project.exists:
            return

        # Update the metadata if we need to
        self.change_meta(item, data_item)

    def change_meta(self, item: QStandardItem, item_data: ProjectTreeData, show: bool = False) -> None:
        """Update the MetaData dock widget with new information

        Args:
            item (QStandardItem): [description]
            data ([type]): [description]
            show (bool, optional): [description]. Defaults to False.
        """
        data = item_data.data
        if isinstance(data, QRaveMapLayer):
            meta = data.meta if data.meta is not None else {}
            description = data.description
            self.metaChange.emit(item.text(), MetaType.LAYER, meta, description, show)

        elif isinstance(data, QRaveBaseMap):
            # description = data.description
            self.metaChange.emit(item.text(), MetaType.NONE, {}, None, show)

        elif item_data.type == QRaveTreeTypes.PROJECT_ROOT:
            description = item_data.project.description
            self.metaChange.emit(item.text(), MetaType.PROJECT, {"project": item_data.project.meta, "warehouse": item_data.project.warehouse_meta}, description, show)
        elif item_data.type in [QRaveTreeTypes.PROJECT_FOLDER, QRaveTreeTypes.PROJECT_REPEATER_FOLDER, QRaveTreeTypes.PROJECT_VIEW_FOLDER, QRaveTreeTypes.BASEMAP_ROOT, QRaveTreeTypes.BASEMAP_SUPER_FOLDER, QRaveTreeTypes.BASEMAP_SUB_FOLDER]:
            self.metaChange.emit(item.text(), MetaType.FOLDER, data or {}, None, show)
        elif isinstance(data, dict):
            # this is just the generic case for any kind of metadata
            self.metaChange.emit(item.text(), MetaType.NONE, data or {}, None, show)
        else:
            # Do not  update the metadata if we have nothing to show
            self.metaChange.emit(item.text(), MetaType.NONE, {}, None, show)

    def get_warehouse_url(self, wh_meta: dict[str, str]) -> str | None:

        if wh_meta is not None:
            if "program" in wh_meta and "id" in wh_meta:
                return "/".join([CONSTANTS["warehouseUrl"], wh_meta["program"][0], wh_meta["id"][0]])

            elif "_rs_wh_id" in wh_meta and "_rs_wh_program" in wh_meta:
                return "/".join([CONSTANTS["warehouseUrl"], wh_meta["_rs_wh_program"][0], wh_meta["_rs_wh_id"][0]])
            elif "id" in wh_meta:
                return "/".join([CONSTANTS["warehouseUrl"], "p", wh_meta["id"][0]])

        return None

    def project_warehouse_view(self, project: Project) -> None:
        """Open this project in the warehouse if the warehouse meta entries exist"""
        url = self.get_warehouse_url(project.warehouse_meta)
        if url is not None:
            QDesktopServices.openUrl(QUrl(url))

    def layer_warehouse_view(self, data: ProjectTreeData) -> None:
        """Open this project in the warehouse if the warehouse meta entries exist"""
        url = self.get_warehouse_url(data.data.meta)
        if url is not None:
            QDesktopServices.openUrl(QUrl(url))

    def close_all(self) -> None:
        projects = list(self._get_projects())

        for project in reversed(projects):
            try:
                self.close_project(project, reload_tree=False)
            except Exception as e:
                self.settings.log(f"Error closing project: {e}", Qgis.Warning)

        # Remove any PROJECT_LOAD_ERROR nodes directly from the model —
        # _get_projects() skips them so close_project never touches them.
        root = self.model.invisibleRootItem()
        rows_to_remove = []
        for row in range(root.rowCount()):
            child = root.child(row)
            if child is not None:
                item_data = child.data(USER_ROLE)
                if item_data and getattr(item_data, "type", None) == QRaveTreeTypes.PROJECT_LOAD_ERROR:
                    rows_to_remove.append(row)
        for row in reversed(rows_to_remove):
            root.removeRow(row)

        # Clear any remaining settings entries so error nodes don't reappear.
        self.set_project_settings([])
        QTimer.singleShot(0, self.reload_tree)

    def close_project(self, project: Project, reload_tree: bool = True) -> None:
        """Close the project"""
        try:
            qrave_projects = self.get_project_settings()
        except Exception as e:
            self.settings.log(f"Error closing project: {e}", Qgis.Warning)
            qrave_projects = []

        # Filter out the project we want to close and reload the tree
        project_name = project.qproject.text()
        qrave_projects = [(name, basename, xml) for name, basename, xml in qrave_projects if name != project_name]

        QRaveMapLayer.remove_project_from_map(project_name)
        QApplication.processEvents()

        # Write the settings back to the project
        self.set_project_settings(qrave_projects)

        if reload_tree:
            QTimer.singleShot(0, self.reload_tree)

    def file_system_open(self, fpath: str) -> None:
        """Open a file on the operating system using the default action

        Args:
            fpath (str): [description]
        """
        qurl = QUrl.fromLocalFile(fpath)
        QDesktopServices.openUrl(QUrl(qurl))

    def file_system_locate(self, fpath: str) -> None:
        """This the OS-agnostic "show in Finder" or "show in explorer" equivalent
        It should open the folder of the item in question

        Args:
            fpath (str): [description]
        """
        final_path = os.path.dirname(fpath)
        while not os.path.isdir(final_path):
            final_path = os.path.dirname(final_path)

        qurl = QUrl.fromLocalFile(final_path)
        QDesktopServices.openUrl(qurl)

    def open_remote_file_in_browser(self, item_data: ProjectTreeData) -> None:
        """Fetch a signed URL for a remote file and open it in the browser"""

        def _handle_download_url(task: RunGQLQueryTask, resp: dict):
            if task.success and resp and "downloadUrl" in resp:
                QDesktopServices.openUrl(QUrl(resp["downloadUrl"]))
            else:
                self.settings.log(f"Error fetching download URL: {task.error}", Qgis.Warning)
                QMessageBox.warning(self, "Download Failed", f"Could not fetch download URL for {item_data.data.label}")

        if self.dataExchangeAPI is None:
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_download_login(task, item_data))
        else:
            self.dataExchangeAPI.get_download_url(item_data.project.id, item_data.data.layer_uri, _handle_download_url)

    def open_remote_report_in_browser(self, item_data: ProjectTreeData) -> None:
        """Construct and open a remote report URL in the browser"""
        project_type = item_data.project.project_type
        project_id = item_data.project.id
        rs_xpath = item_data.data.bl_attr.get("rsXPath", "")

        if not rs_xpath:
            self.settings.log("Cannot open report: rsXPath is missing", Qgis.Warning)
            QMessageBox.warning(self, "Report Failed", f"Could not determine report URL for {item_data.data.label}")
            return

        # replace # with _ in rs_xpath
        rs_xpath_sani = rs_xpath.replace("#", "_")
        base_wh_url = CONSTANTS["warehouseUrl"].rstrip("/")
        report_url = f"{base_wh_url}/tiles/{project_type}/{project_id}/{rs_xpath_sani}/index.html"
        QDesktopServices.openUrl(QUrl(report_url))

    def fetch_and_add_remote_layer(self, item: QStandardItem, item_data: ProjectTreeData) -> None:
        """Fetch tile metadata and add the remote layer to the map"""

        def _handle_tile_metadata(task: RunGQLQueryTask, resp: dict):
            if task.success and resp:
                # Check for tiling error
                if resp.get("state") == "TILING_ERROR":
                    self.settings.log(f"Tile service is in error state for {item_data.data.label}.", Qgis.Warning)
                    QMessageBox.warning(self, "Add Layer Failed", "Tile service is in error state on the server. Please check the Riverscapes Data Exchange.")
                    return

                # Fetch more details from the indexUrl if it exists
                # This is a workaround because the GQL API doesn't return all metadata yet

                url_val = resp.get("url")
                if not url_val:
                    self.settings.log(f"Tile service URL is missing for {item_data.data.label}.", Qgis.Warning)
                    QMessageBox.warning(self, "Add Layer Failed", f"Tile service URL could not be found for {item_data.data.label}.")
                    return

                base_url = url_val.rstrip("/")
                map_layer: QRaveMapLayer = item_data.data
                layer_name = map_layer.layer_name or map_layer.bl_attr.get("nodeId", "")
                if not layer_name:
                    xpath = map_layer.bl_attr.get("rsXPath", "")
                    if "#" in xpath:
                        layer_name = xpath.split("/")[-1].split("#")[1]
                    else:
                        layer_name = xpath.split("/")[-1]

                index_url = f"{base_url}/{layer_name}/index.json"

                # ── helpers (close over item / item_data / resp) ────────────

                def _finish_with_resp() -> None:
                    """Continue after index.json has been merged into resp."""

                    # Now fetch symbology
                    def _handle_symbology(symb_task: RunGQLQueryTask, symb_resp: dict) -> None:
                        if symb_task.success and symb_resp:
                            resp["mapboxJson"] = symb_resp.get("mapboxJson")
                            self.settings.log(
                                f"Successfully fetched remote symbology for {item_data.data.label}",
                                Qgis.Info,
                            )
                        else:
                            self.settings.log(
                                f"No remote symbology found or error for {item_data.data.label}",
                                Qgis.Info,
                            )

                        if item_data.data.layer_type == QRaveMapLayer.LayerTypes.RASTER:
                            QRaveMapLayer.add_remote_raster_layer_to_map(item, resp)
                        else:
                            QRaveMapLayer.add_remote_vector_layer_to_map(item, resp)

                    # Inject project bounds if they exist to limit the layer extent
                    if item_data.project.bounds:
                        from .classes.remote_project import RemoteProject

                        if isinstance(item_data.project, RemoteProject):
                            resp["bounds"] = item_data.project.bounds.get("bbox")
                        else:
                            b = item_data.project.bounds
                            resp["bounds"] = [b["minLng"], b["minLat"], b["maxLng"], b["maxLat"]]

                    symbology_name = item_data.data.bl_attr.get("symbology")
                    if symbology_name:
                        project_type_id = item_data.project.project_type
                        is_raster = item_data.data.layer_type == QRaveMapLayer.LayerTypes.RASTER
                        self.dataExchangeAPI.get_web_symbology(
                            project_type_id,
                            symbology_name,
                            is_raster,
                            _handle_symbology,
                        )
                    else:
                        if item_data.data.layer_type == QRaveMapLayer.LayerTypes.RASTER:
                            QRaveMapLayer.add_remote_raster_layer_to_map(item, resp)
                        else:
                            QRaveMapLayer.add_remote_vector_layer_to_map(item, resp)

                def _on_index_fetched(fetch_task: FetchJsonTask) -> None:
                    """Called on the main thread when the background index.json fetch completes."""
                    if fetch_task.success and fetch_task.result:
                        self.settings.log(f"Successfully fetched index metadata from {index_url}", Qgis.Info)
                        resp.update(fetch_task.result)
                    else:
                        self.settings.log(
                            f"Failed to fetch valid JSON from {index_url}",
                            Qgis.Warning,
                        )
                    _finish_with_resp()

                # Dispatch to a background task — keeps the GUI thread free.
                QgsApplication.taskManager().addTask(FetchJsonTask(index_url, _on_index_fetched))
            else:
                self.settings.log(f"Error fetching tile metadata: {task.error}", Qgis.Warning)
                QMessageBox.warning(self, "Add Layer Failed", f"Could not fetch tile metadata for {item_data.data.label}")

        if self.dataExchangeAPI is None:
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_add_layer_login(task, item, item_data))
        else:
            if hasattr(item_data.project, "id"):
                project_id = item_data.project.id
            elif item_data.project.warehouse_meta and "id" in item_data.project.warehouse_meta:
                project_id = item_data.project.warehouse_meta["id"][0]
            else:
                project_id = None
            project_type_id = item_data.project.project_type
            rs_xpath = item_data.data.bl_attr.get("rsXPath", "")
            if not rs_xpath:
                self.settings.log("Cannot add layer: rsXPath is missing", Qgis.Warning)
                return
            if not project_id:
                self.settings.log("Cannot add layer: project_id is missing", Qgis.Warning)
                return
            self.dataExchangeAPI.get_layer_tiles(project_id, project_type_id, rs_xpath, _handle_tile_metadata)

    def _on_add_layer_login(self, task: RefreshTokenTask, item: QStandardItem, item_data: ProjectTreeData) -> None:
        if task.success:
            self.fetch_and_add_remote_layer(item, item_data)
        else:
            QMessageBox.critical(self, "Login Failed", "Could not log in to Riverscapes API for layer addition.")

    def _on_download_login(self, task: RefreshTokenTask, item_data: ProjectTreeData) -> None:
        if task.success:
            self.open_remote_file_in_browser(item_data)
        else:
            QMessageBox.critical(self, "Login Failed", "Could not log in to Riverscapes API for download.")

    def fetch_missing_remote_project(self, project_id: str) -> None:
        if project_id in self._fetching_projects:
            return
        self._fetching_projects.add(project_id)

        if self.dataExchangeAPI is None:
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_missing_remote_fetch_login(task, project_id))
        else:
            self.dataExchangeAPI.get_remote_project(project_id, self._on_missing_remote_project_fetched)

    def _on_missing_remote_fetch_login(self, task: RefreshTokenTask, project_id: str) -> None:
        if task.success:
            self.dataExchangeAPI.get_remote_project(project_id, self._on_missing_remote_project_fetched)
        else:
            if project_id in self._fetching_projects:
                self._fetching_projects.remove(project_id)
            self.settings.log(f"Login failed while fetching missing remote project: {project_id}", Qgis.Warning)

    def _on_missing_remote_project_fetched(self, task: RunGQLQueryTask, response: dict) -> None:
        project_id = task.variables.get("id")
        if project_id in self._fetching_projects:
            self._fetching_projects.remove(project_id)

        if task.success and response and "data" in response and response["data"]["project"]:
            self._remote_project_cache[project_id] = response
            self.reload_tree()
            # Now fetch the metadata asynchronously
            self.fetch_dataset_metadata(project_id)
        else:
            self.settings.log(f"Failed to fetch missing remote project: {project_id}", Qgis.Warning)

    def fetch_dataset_metadata(self, project_id: str, offset: int = 0, limit: int = 50) -> None:
        """Fetch metadata for datasets in chunks"""
        self.settings.log(f"Fetching dataset metadata for {project_id} (offset={offset}, limit={limit})", Qgis.Info)

        def _handle_metadata(task: RunGQLQueryTask, datasets_data: dict):
            if task.success and datasets_data:
                items = datasets_data.get("items", [])
                total = datasets_data.get("total", 0)

                # 1. Update the cache
                if project_id in self._remote_project_cache:
                    cached_proj = self._remote_project_cache[project_id]

                    # Robust lookup of project data in cache
                    proj_data = None
                    if "data" in cached_proj and "project" in cached_proj["data"]:
                        proj_data = cached_proj["data"]["project"]
                    elif "project" in cached_proj:
                        proj_data = cached_proj["project"]
                    else:
                        proj_data = cached_proj

                    if proj_data:
                        if "datasets" not in proj_data or proj_data["datasets"] is None:
                            proj_data["datasets"] = {"items": []}

                        cached_items = proj_data["datasets"]["items"]
                        # Create a map for easier update
                        cached_map = {i["id"]: i for i in cached_items if i and "id" in i}

                        for new_item in items:
                            if not new_item:
                                continue
                            if new_item["id"] in cached_map:
                                cached_map[new_item["id"]].update(new_item)
                            else:
                                cached_items.append(new_item)

                # 2. Update the active project object
                # Find the project in the tree (may appear multiple times if copied,
                # but usually there is one remote project per ID)
                projects = self._get_projects()
                for proj in projects:
                    if isinstance(proj, RemoteProject) and proj.id == project_id:
                        proj.update_dataset_metadata(items)
                        # Also refresh the metadata panel if the user has an item selected from this project
                        # We can just emit dataChange or verify current selection
                        self.item_change(None)

                # 3. Fetch next page
                if len(items) > 0 and offset + len(items) < total:
                    self.fetch_dataset_metadata(project_id, offset + len(items), limit)
            else:
                self.settings.log(f"Failed to fetch dataset metadata for {project_id}: {task.error}", Qgis.Warning)

        if self.dataExchangeAPI is None:
            # Should have been initialized by get_remote_project call, but just in case
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self.fetch_dataset_metadata(project_id, offset, limit))
        else:
            self.dataExchangeAPI.get_dataset_metadata(project_id, limit, offset, _handle_metadata)

    def toggleSubtree(self, item: QStandardItem = None, expand: bool = True) -> None:

        def _recurse(curritem):
            # Use curritem (the recursion variable), not item (the outer param)
            idx = self.model.indexFromItem(curritem)
            if expand is not self.treeView.isExpanded(idx):
                self.treeView.setExpanded(idx, expand)

            for row in range(curritem.rowCount()):
                _recurse(curritem.child(row))

        if item is None:
            if expand:
                self.treeView.expandAll()
            else:
                self.treeView.collapseAll()
        else:
            _recurse(item)

    def add_view_to_map(self, item_data: ProjectTreeData) -> None:
        """Add a view and all its layers to the map

        Args:
            item (QStandardItem): [description]
        """
        self.add_children_to_map(item_data.project.qproject, item_data.data)

    def add_children_to_map(self, item: QStandardItem, bl_ids: list[str] | None = None) -> None:
        """Iteratively add all children to the map

        Args:
            item (QStandardItem): [description]
            bl_ids (List[str], optional): List of ids to filter by so we don't load
                everything. This is used for loading views.
        """

        for child in self._get_children(item):
            # Is this something we can add to the map?
            project_tree_data = child.data(USER_ROLE)
            if project_tree_data is not None and isinstance(project_tree_data.data, QRaveMapLayer):
                data = project_tree_data.data
                loadme = False
                # If this layer matches the businesslogic id filter
                if bl_ids is not None and len(bl_ids) > 0:
                    if "id" in data.bl_attr and data.bl_attr["id"] in bl_ids:
                        loadme = True
                else:
                    loadme = True

                if loadme:
                    if isinstance(project_tree_data.project, RemoteProject):
                        self.fetch_and_add_remote_layer(child, project_tree_data)
                    else:
                        data.add_layer_to_map(child)

    def _get_children(self, root_item: QStandardItem) -> Iterator[QStandardItem]:
        """Recursion is going to kill us here so do an iterative solution instead
           https://stackoverflow.com/questions/41949370/collect-all-items-in-qtreeview-recursively

        Yields:
            [type]: [description]
        """
        stack = [root_item]
        while stack:
            parent = stack.pop(0)
            for row in range(parent.rowCount()):
                for column in range(parent.columnCount()):
                    child = parent.child(row, column)
                    yield child
                    if child.hasChildren():
                        stack.append(child)

    def _get_parents(self, start_item: QStandardItem) -> list[QStandardItem]:
        """Return ordered list of parent QStandardItems from root down to the
        immediate parent of *start_item* (exclusive of the invisible root).

        Previously had two bugs:
        1. Advanced ``start_item.parent()`` instead of ``placeholder.parent()``
           causing an infinite loop whenever start_item had any parent.
        2. ``list.reverse()`` returns ``None``; callers always received None.
        """
        stack = []
        placeholder = start_item.parent()
        while placeholder is not None and placeholder != self.model.invisibleRootItem():
            stack.append(placeholder)
            placeholder = placeholder.parent()  # fixed: was start_item.parent()
        stack.reverse()  # in-place; return the stack, not None
        return stack

    def open_menu(self, position) -> None:

        indexes = self.treeView.selectedIndexes()
        if len(indexes) < 1:
            return

        # No multiselect so there is only ever one item
        idx = indexes[0]

        if not idx.isValid():
            return

        item = self.model.itemFromIndex(indexes[0])
        project_tree_data = item.data(USER_ROLE)  # ProjectTreeData object
        # Could be a QRaveBaseMap, a QRaveMapLayer or just some random data
        data = project_tree_data.data

        menu = ContextMenu()

        # This is the layer context menu
        if isinstance(data, QRaveMapLayer):
            if data.layer_type == QRaveMapLayer.LayerTypes.WEBTILE:
                self.basemap_context_menu(menu, idx, item, project_tree_data)
            elif data.layer_type in [QRaveMapLayer.LayerTypes.FILE, QRaveMapLayer.LayerTypes.REPORT]:
                self.file_layer_context_menu(menu, idx, item, project_tree_data)
            else:
                self.map_layer_context_menu(menu, idx, item, project_tree_data)

        elif isinstance(data, QRaveBaseMap):
            # A WMS QARaveBaseMap is just a container for layers
            if data.tile_type == "wms":
                self.folder_dumb_context_menu(menu, idx, item, project_tree_data)
            # Every other kind of basemap is an add-able layer
            else:
                self.basemap_context_menu(menu, idx, item, project_tree_data)

        elif project_tree_data.type == QRaveTreeTypes.PROJECT_ROOT:
            self.project_context_menu(menu, idx, item, project_tree_data)

        elif project_tree_data.type in [QRaveTreeTypes.PROJECT_VIEW_FOLDER, QRaveTreeTypes.BASEMAP_ROOT, QRaveTreeTypes.BASEMAP_SUPER_FOLDER]:
            self.folder_dumb_context_menu(menu, idx, item, project_tree_data)

        elif project_tree_data.type in [QRaveTreeTypes.PROJECT_FOLDER, QRaveTreeTypes.PROJECT_REPEATER_FOLDER, QRaveTreeTypes.BASEMAP_SUB_FOLDER]:
            self.folder_context_menu(menu, idx, item, project_tree_data)

        elif project_tree_data.type == QRaveTreeTypes.PROJECT_VIEW:
            self.view_context_menu(menu, idx, item, project_tree_data)

        elif project_tree_data.type == QRaveTreeTypes.PROJECT_LOAD_ERROR:
            self.load_error_context_menu(menu, idx, item, project_tree_data)

        menu.exec(self.treeView.viewport().mapToGlobal(position))

    def map_layer_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, item_data: ProjectTreeData) -> None:
        if isinstance(item_data.project, RemoteProject):
            menu.addAction("ADD_TO_MAP", lambda: self.fetch_and_add_remote_layer(item, item_data))
        else:
            menu.addAction("ADD_TO_MAP", lambda: QRaveMapLayer.add_layer_to_map(item), enabled=item_data.data.exists)

        menu.addAction("VIEW_LAYER_META", lambda: self.change_meta(item, item_data, True))

        # If the project has a warehouse tag, add "Add WebTiles to map" (for local projects)
        wh_meta = item_data.project.warehouse_meta
        if not isinstance(item_data.project, RemoteProject) and wh_meta and "id" in wh_meta:
            if item_data.data.bl_attr.get("rsXPath"):
                menu.addAction("ADD_WEB_TILES_TO_MAP", lambda: self.fetch_and_add_remote_layer(item, item_data))

        if bool(self.get_warehouse_url(item_data.data.meta)):
            menu.addAction("VIEW_WEB_SOURCE", lambda: self.layer_warehouse_view(item_data))

        if isinstance(item_data.project, RemoteProject):
            project_id = item_data.project.id
            url = f"{CONSTANTS['warehouseUrl'].rstrip('/')}/p/{project_id}/datasets"
            menu.addAction("BROWSE_REMOTE_DATA_EXCHANGE", lambda: QDesktopServices.openUrl(QUrl(url)))
        else:
            menu.addAction("BROWSE_FOLDER", lambda: self.file_system_locate(item_data.data.layer_uri))
        self.layerMenuOpen.emit(menu, item, item_data)

    def file_layer_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, item_data: ProjectTreeData) -> None:
        if isinstance(item_data.project, RemoteProject):
            if item_data.data.layer_type == QRaveMapLayer.LayerTypes.REPORT:
                menu.addAction("OPEN_REPORT", lambda: self.open_remote_report_in_browser(item_data))
            else:
                menu.addAction("OPEN_FILE", lambda: self.open_remote_file_in_browser(item_data))

        else:
            menu.addAction("OPEN_FILE", lambda: self.file_system_open(item_data.data.layer_uri))
            menu.addAction("BROWSE_FOLDER", lambda: self.file_system_locate(item_data.data.layer_uri))

    # Basemap context items
    def basemap_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, data: ProjectTreeData) -> None:
        menu.addAction("ADD_TO_MAP", lambda: QRaveMapLayer.add_layer_to_map(item))

    # Folder-level context menu
    def folder_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, data: ProjectTreeData) -> None:
        if not isinstance(data.project, RemoteProject):
            menu.addAction("ADD_ALL_TO_MAP", lambda: self.add_children_to_map(item))
        menu.addSeparator()
        menu.addAction("COLLAPSE_ALL", lambda: self.toggleSubtree(item, False))
        menu.addAction("EXPAND_ALL", lambda: self.toggleSubtree(item, True))

    # Some folders don't have the 'ADD_ALL_TO_MAP' functionality enabled
    def folder_dumb_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, data: ProjectTreeData) -> None:
        menu.addAction("COLLAPSE_ALL", lambda: self.toggleSubtree(item, False))
        menu.addAction("EXPAND_ALL", lambda: self.toggleSubtree(item, True))

    # View context items
    def view_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, item_data: ProjectTreeData) -> None:
        menu.addAction("ADD_ALL_TO_MAP", lambda: self.add_view_to_map(item_data))

    def load_error_context_menu(
        self,
        menu: ContextMenu,
        idx: QModelIndex,
        item: QStandardItem,
        data: ProjectTreeData,
    ):
        """Context menu for a project-load-error placeholder node."""
        # Prefer the path from the Project object itself — it is always a
        # proper absolute string set in Project.__init__.  data.data is a
        # fallback in case the project object is missing or incomplete.
        project = data.project
        if project is not None and isinstance(getattr(project, "project_xml_path", None), str):
            project_path = project.project_xml_path
        elif isinstance(data.data, str):
            project_path = data.data
        else:
            project_path = None

        menu.addAction("RETRY_LOAD", self.reload_tree)
        if project_path and not project_path.startswith("remote:"):
            menu.addAction(
                "BROWSE_PROJECT_FOLDER",
                lambda checked=False, p=project_path: self.file_system_locate(p),
            )
        menu.addSeparator()
        menu.addAction(
            "CLOSE_PROJECT",
            lambda checked=False, i=item, p=project_path: self._close_error_project(i, p),
        )

    def _close_error_project(self, item: QStandardItem, project_path: str | None) -> None:
        """Remove a broken project node from the tree and clean up settings."""
        # Step 1: remove the item from the model directly — use indexFromItem so
        # we avoid Python-identity (`is`) comparisons which fail with PyQt wrappers.
        idx = self.model.indexFromItem(item)
        if idx.isValid():
            self.model.removeRow(idx.row(), idx.parent())

        # Step 2: best-effort removal from persisted settings so the entry does
        # not reappear on the next QGIS session load.
        if not project_path:
            self.settings.log("Close error project: no path available — removed from tree only", Qgis.Warning)
            return
        try:
            qrave_projects = self.get_project_settings()
            norm_target = os.path.normcase(os.path.abspath(project_path))
            filtered = [(n, b, x) for n, b, x in qrave_projects if x.startswith("remote:") or os.path.normcase(os.path.abspath(x)) != norm_target]
            if len(filtered) == len(qrave_projects):
                self.settings.log(f"Close error project: '{project_path}' not found in settings — removed from tree only", Qgis.Warning)
            self.set_project_settings(filtered)
        except Exception as e:
            self.settings.log(f"Close error project: could not update settings ({e}) — removed from tree only", Qgis.Warning)

    # Project-level context menu
    def project_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, data: ProjectTreeData) -> None:
        menu.addAction("COLLAPSE_ALL", lambda: self.toggleSubtree(item, False))
        menu.addAction("EXPAND_ALL", lambda: self.toggleSubtree(item, True))
        menu.addAction("ZOOM_TO_PROJECT", lambda: self.zoom_to_project(data.project), enabled=data.project.has_bounds)
        menu.addAction("ADD_PROJECT_BOUNDS_TO_MAP", lambda: QRaveMapLayer.add_project_bounds_to_map(data.project.bounds_path, "Project Bounds", data.project.qproject.text()), enabled=data.project.has_bounds_layer)
        menu.addSeparator()
        menu.addAction("UPLOAD_PROJECT", lambda: self.project_upload_load(data.project))
        wh_meta = data.project.warehouse_meta
        if isinstance(data.project, RemoteProject) or (wh_meta and "id" in wh_meta):
            menu.addAction("DOWNLOAD_ADD_PROJECT", lambda: self.project_download_load(data.project))
        menu.addSeparator()
        if not isinstance(data.project, RemoteProject):
            menu.addAction("BROWSE_PROJECT_FOLDER", lambda: self.file_system_locate(data.project.project_xml_path))
        menu.addAction("VIEW_PROJECT_META", lambda: self.change_meta(item, data, True))
        menu.addAction("WAREHOUSE_VIEW", lambda: self.project_warehouse_view(data.project), enabled=bool(self.get_warehouse_url(data.project.warehouse_meta)))
        menu.addAction("ADD_ALL_TO_MAP", lambda: self.add_children_to_map(item))
        menu.addSeparator()
        menu.addAction("REFRESH_PROJECT_HIERARCHY", self.reload_tree)
        menu.addAction("CUSTOMIZE_PROJECT_HIERARCHY", enabled=False)
        menu.addSeparator()
        menu.addAction("CLOSE_PROJECT", lambda: self.close_project(data.project), enabled=bool(data.project))

    def project_upload_load(self, project: Project | RemoteProject) -> None:
        """
        Open the Project Upload dialog
        """

        dialog = ProjectUploadDialog(None, project)
        dialog.exec()
        # Reload the project after the upload (and even just on upload cancel)
        self.reload_tree()

    def project_download_load(self, project: Project | RemoteProject) -> None:
        """
        Open the Project Download dialog
        """
        project_id = None
        local_path = None

        if isinstance(project, RemoteProject):
            project_id = project.id
        else:
            wh_meta = project.warehouse_meta
            project_id = wh_meta["id"][0] if wh_meta and "id" in wh_meta else None
            local_path = project.project_xml_path

        dialog = ProjectDownloadDialog(None, project_id=project_id, local_path=local_path)
        dialog.projectDownloaded.connect(self.add_project)
        dialog.exec()
        self.reload_tree()
