"""
/***************************************************************************
 Riverscapes Viewer
                                 A QGIS plugin
 Explore symbolized Riverscapes projects
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                              -------------------
        begin                : 2021-04-13
        git sha              : $Format:%H$
        copyright            : (C) 2021 by North Arrow Research
        email                : info@northarrowresearch.com
 ***************************************************************************/
"""

from __future__ import annotations

import os.path
from pathlib import Path
import re
import sys
from time import time

from qgis.core import Qgis, QgsApplication, QgsMessageLog, QgsProject
from qgis.PyQt.QtCore import QCoreApplication, QSettings, QTimer, QTranslator, QUrl
from qgis.PyQt.QtGui import QDesktopServices
from qgis.PyQt.QtWidgets import QAction, QFileDialog, QMenu, QMessageBox, QToolButton

from ..__version__ import __version__

# initialize Qt resources from file resources.py
from . import resources  # noqa: F401 — registers Qt icons/images as a side effect
from .about_dialog import AboutDialog
from .classes.data_exchange.DataExchangeAPI import DataExchangeAPI
from .classes.GraphQLAPI import RefreshTokenTask, RunGQLQueryTask
from .classes.map import get_map_center, get_zoom_level
from .classes.net_sync import NetSync
from .classes.settings import CONSTANTS, Settings
from .compat import (
    LEFT_DOCK,
    MAPLAYER_VECTOR,
    MSGBOX_BTN_NO,
    MSGBOX_BTN_YES,
    MSGBOX_ICON_QUESTION,
    RIGHT_DOCK,
    TOOL_BTN_TEXT_BESIDE,
    TOOL_BTN_TEXT_ONLY,
    VERTICAL,
)
from .dock_widget import QRAVEDockWidget
from .frm_project_bounds import FrmProjectBounds
from .icon_utils import qrave_icon
from .meta_widget import QRAVEMetaWidget

# Initialize Qt resources from file resources.py
# Import the code for the dialog
from .options_dialog import OptionsDialog
from .project_download_dialog import ProjectDownloadDialog
from .remote_project_dialog import RemoteProjectDialog

RESOURCES_DIR = os.path.join(os.path.dirname(__file__), "..", "resources")

# BASE is the name we want to use inside the settings keys
MESSAGE_CATEGORY = CONSTANTS["logCategory"]

if hasattr(QToolButton, "MenuButtonPopup"):
    MENU_BUTTON_POPUP_MODE = QToolButton.MenuButtonPopup
    INSTANT_POPUP_MODE = QToolButton.InstantPopup
else:
    MENU_BUTTON_POPUP_MODE = QToolButton.ToolButtonPopupMode.MenuButtonPopup
    INSTANT_POPUP_MODE = QToolButton.ToolButtonPopupMode.InstantPopup


class QRAVE:
    """QGIS Plugin Implementation."""

    def __init__(self, iface):
        """Constructor.

        :param iface: An interface instance that will be passed to this class
            which provides the hook by which you can manipulate the QGIS
            application at run time.
        :type iface: QgsInterface
        """
        # Save reference to the QGIS interface
        self.iface = iface
        self.tm = QgsApplication.taskManager()
        self.qproject = QgsProject.instance()
        self.settings = Settings(iface=self.iface)

        self.settings.setValue("DEBUG", os.environ.get("RS_DEBUG", "False").lower() == "true")
        self.settings.setValue("Staging", os.environ.get("RS_STAGING", "False").lower() == "true")

        self.pluginIsActive = False

        self.dockwidget = QRAVEDockWidget()
        self.metawidget = QRAVEMetaWidget()

        # Populated on load from a URL
        self.acknowledgements = None

        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)
        # initialize locale
        locale = QSettings().value("locale/userLocale")[0:2]
        locale_path = os.path.join(self.plugin_dir, "i18n", f"QRAVE_{locale}.qm")

        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        # Declare instance attributes
        self.actions = []
        self.menu = self.tr("&Riverscapes Viewer Plugin")

        # TODO: We are going to let the user set this up in a future iteration
        self.toolbar = self.iface.addToolBar("Riverscapes Viewer")
        self.toolbar.setObjectName("Riverscapes Viewer")
        self.menu = QMenu()

        self.debugpy = None
        self._enable_debug()

    def _enable_debug(self) -> None:
        debug_port = 5678
        debug_host = "localhost"
        DEBUG_ON = self.settings.getValue("DEBUG")
        if not DEBUG_ON:
            return

        if self.debugpy is None:
            try:
                import debugpy

                self.debugpy = debugpy
            except ImportError:
                self.settings.log("Need install debugpy: pip3 install debugpy", Qgis.Warning)

        if self.debugpy is None:
            return
        else:
            try:
                # if we are in OSX then this is the path
                if sys.platform == "darwin":
                    python_path = os.path.join(
                        Path(os.__file__).parents[2],
                        "bin",
                        Path(os.__file__).parent.name,
                    )
                elif sys.platform == "win32":
                    python_path = os.path.join(Path(os.__file__).parents[1], "python.exe")
                else:
                    # Linux and other platforms
                    python_path = sys.executable
                self.settings.log(f"debugpy imported and attached to: {python_path}", Qgis.Success)
                debugpy.configure(python=python_path)
            except Exception as e:
                self.settings.log(f"Error initializing debugpy: {e}", Qgis.Critical)
                raise e

        if self.debugpy.is_client_connected():
            self.settings.log(
                f"ALREADY ACTIVE: Remote Debug for Visual Studio is active({debug_port})",
                Qgis.Warning,
            )
            return
        else:
            try:
                _, new_port = self.debugpy.listen((debug_host, debug_port))
            except Exception as e:
                self.settings.log(f"Error starting debugpy: {e}", Qgis.Critical)
                return
            msgPort = f'"request": "enable_attach", "Port": {new_port}, "host": "{debug_host}"'
            self.settings.log(f"Remote Debug for Visual Studio is running({msgPort})", Qgis.Success)

    def tr(self, message: str) -> str:
        """Get the translation for a string using Qt translation API.

        We implement this ourselves since we do not inherit QObject.

        :param message: String for translation.
        :type message: str, QString

        :returns: Translated version of message.
        :rtype: QString
        """
        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
        return QCoreApplication.translate("Riverscapes Viewer", message)

    def initGui(self) -> None:
        """Create the menu entries and toolbar icons inside the QGIS GUI."""
        self.qproject.readProject.connect(self.onProjectLoad)

        self.openAction = QAction(
            qrave_icon("viewer-icon.svg"),
            self.tr("Show Riverscapes Viewer Panel"),
            self.iface.mainWindow(),
        )
        self.openAction.triggered.connect(self.toggle_widget)
        self.openAction.setStatusTip("Toggle the project viewer")
        self.openAction.setWhatsThis("Toggle the project viewer")
        self.actions.append(self.openAction)

        self.openProjectAction = QAction(
            qrave_icon("open.svg"),
            self.tr("Open Riverscapes Project"),
            self.iface.mainWindow(),
        )
        self.openProjectAction.triggered.connect(self.projectBrowserDlg)
        self.openProjectAction.setStatusTip("Open Riverscapes project")
        self.openProjectAction.setWhatsThis("Open Riverscapes project")
        self.actions.append(self.openProjectAction)

        self.openRemoteProjectAction = QAction(
            qrave_icon("data-exchange-icon.svg"),
            self.tr("Open Remote Project"),
            self.iface.mainWindow(),
        )
        self.openRemoteProjectAction.triggered.connect(self.remoteProjectDlg)
        self.openRemoteProjectAction.setStatusTip("Open Remote Riverscapes project")
        self.openRemoteProjectAction.setWhatsThis("Open Remote Riverscapes project")
        self.actions.append(self.openRemoteProjectAction)

        self.closeAllProjectsAction = QAction(
            qrave_icon("close.png"),
            self.tr("Close All Riverscapes Projects"),
            self.iface.mainWindow(),
        )
        self.closeAllProjectsAction.triggered.connect(self.closeAllProjects)
        self.closeAllProjectsAction.setStatusTip("Close all open Riverscapes projects")
        self.closeAllProjectsAction.setWhatsThis("Close all open Riverscapes projects")
        self.actions.append(self.closeAllProjectsAction)

        self.browseExchangeProjectsAction = QAction(
            qrave_icon("data-exchange-icon.svg"),
            self.tr("Browse Data Exchange Projects in Map Area"),
            self.iface.mainWindow(),
        )
        self.browseExchangeProjectsAction.triggered.connect(self.browseExchangeProjects)
        self.actions.append(self.browseExchangeProjectsAction)

        self.downloadProjectAction = QAction(
            qrave_icon("download.svg"),
            self.tr("Download Riverscapes Project"),
            self.iface.mainWindow(),
        )
        self.downloadProjectAction.triggered.connect(lambda: self.downloadProjectDlg())
        self.downloadProjectAction.setStatusTip("Download a Riverscapes project from the Data Exchange")
        self.downloadProjectAction.setWhatsThis("Download a Riverscapes project from the Data Exchange")
        self.actions.append(self.downloadProjectAction)

        self.toolsButton = QToolButton()
        self.toolsButton.setToolButtonStyle(TOOL_BTN_TEXT_ONLY)
        self.toolsButton.setMenu(QMenu())
        self.toolsButton.setPopupMode(MENU_BUTTON_POPUP_MODE)
        self.toolsButton.setText("Tools")

        m_tools = self.toolsButton.menu()
        m_tools.setTitle("Tools")
        m_tools.setIcon(qrave_icon("tools"))

        self.raveOptionsAction = QAction(qrave_icon("Options.png"), self.tr("Settings"), self.iface.mainWindow())
        self.raveOptionsAction.triggered.connect(self.options_load)

        self.net_sync_action = QAction(
            qrave_icon("refresh.png"),
            self.tr("Update resources"),
            self.iface.mainWindow(),
        )
        self.net_sync_action.triggered.connect(lambda: self.net_sync_load(force=True))

        self.find_resources_action = QAction(
            qrave_icon("BrowseFolder.png"),
            self.tr("Open Resources folder"),
            self.iface.mainWindow(),
        )
        self.find_resources_action.triggered.connect(self.locateResources)

        self.generate_project_bounds = QAction(
            qrave_icon("bounds"),
            self.tr("Generate Project Bounds"),
            self.iface.mainWindow(),
        )
        self.actions.append(self.generate_project_bounds)

        # Open a project bounds dialog
        self.generate_project_bounds.triggered.connect(lambda: self.show_project_bounds())

        m_tools.addAction(self.net_sync_action)
        m_tools.addAction(self.find_resources_action)
        m_tools.addSeparator()
        m_tools.addAction(self.generate_project_bounds)
        m_tools.addSeparator()
        m_tools.addAction(self.raveOptionsAction)

        self.helpButton = QToolButton()
        self.helpButton.setToolButtonStyle(TOOL_BTN_TEXT_ONLY)
        self.helpButton.setMenu(QMenu())
        self.helpButton.setPopupMode(MENU_BUTTON_POPUP_MODE)

        m = self.helpButton.menu()
        m.setTitle("Help")
        m.setIcon(qrave_icon("Help.png"))

        self.helpAction = QAction(
            qrave_icon("Help.png"),
            self.tr("Riverscapes Viewer Online Help"),
            self.iface.mainWindow(),
        )
        self.helpAction.triggered.connect(lambda: QDesktopServices.openUrl(QUrl("https://viewer.riverscapes.net/software-help/help-qgis/")))

        self.about_action = QAction(
            qrave_icon("viewer-icon.svg"),
            self.tr("About Riverscapes Viewer"),
            self.iface.mainWindow(),
        )
        self.about_action.triggered.connect(self.about_load)
        self.actions.append(self.about_action)
        self.actions.append(self.raveOptionsAction)
        self.actions.append(self.net_sync_action)
        self.actions.append(self.find_resources_action)
        self.actions.append(self.helpAction)

        m.addAction(self.helpAction)
        m.addAction(self.about_action)
        self.helpButton.setDefaultAction(self.helpAction)

        # Add your actions to self.menu instead of directly to the toolbar

        self.recentProjectsMenu = QMenu(self.tr("Recent Riverscapes Projects"))
        self.recentProjectsMenu.setIcon(qrave_icon("open.svg"))
        self.recentProjectsMenu.aboutToShow.connect(self._populate_recent_menu)

        self.menu.addAction(self.openProjectAction)
        self.menu.addMenu(self.recentProjectsMenu)
        self.menu.addAction(self.openRemoteProjectAction)
        self.menu.addAction(self.closeAllProjectsAction)

        # Add a separator and your tools/help submenus if desired
        self.menu.addSeparator()
        self.menu.addAction(self.browseExchangeProjectsAction)
        self.menu.addAction(self.downloadProjectAction)
        self.menu.addAction(self.openAction)
        self.menu.addMenu(self.toolsButton.menu())
        self.menu.addMenu(self.helpButton.menu())

        # Create a single QToolButton with icon and text, and set the menu
        self.viewer_button = QToolButton(self.toolbar)
        self.viewer_button.setText("  Riverscapes Viewer")
        self.viewer_button.setIcon(qrave_icon("viewer-icon.svg"))
        self.viewer_button.setToolButtonStyle(TOOL_BTN_TEXT_BESIDE)
        self.viewer_button.setMenu(self.menu)
        self.viewer_button.setPopupMode(INSTANT_POPUP_MODE)  # Clicking anywhere opens the menu

        self.toolbar.addWidget(self.viewer_button)

        # Do a check to see if the stored version is different than the current version
        lastVersion = self.settings.getValue("pluginVersion")

        # This does a lazy netsync (i.e. it will run it if it feels like it)
        versionChange = lastVersion != __version__
        self.net_sync_load(force=versionChange)

        if versionChange:
            QgsMessageLog.logMessage(
                f"Version change detected: {lastVersion} ==> {__version__}",
                MESSAGE_CATEGORY,
                level=Qgis.Info,
            )
            self.settings.setValue("pluginVersion", __version__)

        # Restore the dock widget visibility if it was open last time
        if self.settings.getValue("dockVisible"):
            # Note that toggle_widget will also restore the height
            self.toggle_widget(forceOn=True)
            if self.dockwidget is not None:
                self.dockwidget.reload_tree()

    def onProjectLoad(self, doc) -> None:
        # If the project has the plugin enabled then restore it.
        qrave_enabled, type_conversion_ok = self.qproject.readEntry(CONSTANTS["settingsCategory"], "enabled")
        if type_conversion_ok and qrave_enabled == "1":
            self.toggle_widget(forceOn=True)
            self.settings.setValue("dockVisible", True)
            if self.dockwidget is not None:
                self.dockwidget.reload_tree()

        # Always check for broken project paths after the project finishes loading,
        # regardless of the enabled flag, as long as the dock widget is present.
        # Deferred via QTimer so the dialog does not block the QGIS load sequence.
        if self.dockwidget is not None:
            QTimer.singleShot(0, self.dockwidget.fix_broken_project_paths)

    def onClosePlugin(self) -> None:
        """Cleanup necessary items here when plugin dockwidget is closed"""
        if self.metawidget is not None:
            self.metawidget.hide()
        if self.dockwidget is not None:
            self._save_dock_height()
            self.dockwidget.hide()

        # disconnects
        try:
            self.dockwidget.closingPlugin.disconnect(self.onClosePlugin)
        except TypeError:
            pass  # Signal was not connected

        try:
            self.qproject.readProject.disconnect(self.onProjectLoad)
        except TypeError:
            pass  # Signal was not connected

        # remove this statement if dockwidget is to remain
        self.pluginIsActive = False

    def unload(self) -> None:
        """Removes the plugin menu item and icon from QGIS GUI."""
        if self.metawidget is not None:
            self.metawidget.hide()
            self.iface.removeDockWidget(self.metawidget)
            self.metawidget.deleteLater()

        if self.dockwidget is not None:
            self._save_dock_height()
            self.dockwidget.hide()
            self.iface.removeDockWidget(self.dockwidget)
            self.dockwidget.deleteLater()

        for action in self.actions:
            self.iface.removePluginMenu(self.tr("&Riverscapes Viewer Plugin"), action)
            self.iface.removeToolBarIcon(action)

        # Disconnect signals
        try:
            self.qproject.readProject.disconnect(self.onProjectLoad)
        except TypeError:
            pass

        # remove the toolbar
        if self.toolbar is not None:
            self.iface.mainWindow().removeToolBar(self.toolbar)
            self.toolbar.deleteLater()
            self.toolbar = None

    def toggle_widget(self, forceOn: bool = False) -> None:
        """Toggle the widget open and closed when clicking the toolbar"""
        if not self.pluginIsActive:
            self.pluginIsActive = True

            # Hook metadata changes up to the metawidget
            self.dockwidget.metaChange.connect(self.metawidget.load)

            # Run a network sync operation to get the latest stuff. Don't force it.
            #  This is just a quick check
            self.net_sync_load()

            # connect to provide cleanup on closing of dockwidget
            self.dockwidget.closingPlugin.connect(self.onClosePlugin)
            dock_location = LEFT_DOCK if self.settings.getValue("dockLocation") == "left" else RIGHT_DOCK

            # show the dockwidget
            self.iface.addDockWidget(dock_location, self.dockwidget)
            self.iface.addDockWidget(dock_location, self.metawidget)
            self.dockwidget.show()
            self._restore_dock_height()

        else:
            if self.dockwidget is not None:
                if self.dockwidget.isHidden():
                    self.dockwidget.show()
                    self._restore_dock_height()
                elif not forceOn:
                    self._save_dock_height()
                    self.dockwidget.hide()

        # The metawidget always starts hidden
        if self.metawidget is not None:
            self.metawidget.hide()

        if self.dockwidget is not None and not self.dockwidget.isHidden():
            # Check if the project is already enabled. 'readEntry' might return '1' or True/False
            qrave_enabled, type_conversion_ok = self.qproject.readEntry(CONSTANTS["settingsCategory"], "enabled")
            already_enabled = type_conversion_ok and (qrave_enabled in ("1", True))

            # If we are just restoring the window state on startup (forceOn=True) and the project
            # is a clean/new project, we should NOT write to the project to avoid dirtying it.
            # This prevents the "Do you want to save..." dialog when opening QGIS with the plugin active.
            is_pristine = not self.qproject.fileName() and not self.qproject.isDirty()

            should_write = True
            if already_enabled:
                should_write = False
            elif forceOn and is_pristine:
                should_write = False

            if should_write:
                self.qproject.writeEntry(CONSTANTS["settingsCategory"], "enabled", True)

            self.settings.setValue("dockVisible", True)
        else:
            self.qproject.removeEntry(CONSTANTS["settingsCategory"], "enabled")
            self.settings.setValue("dockVisible", False)

    def _save_dock_height(self) -> None:
        """Save the height of the dock widget to settings."""
        if self.dockwidget is not None:
            h = self.dockwidget.height()
            # If the widget is hidden, height() might return 0 or its last size.
            # We only save if it's positive.
            if h > 0:
                self.settings.setValue("dockHeight", h)

    def _restore_dock_height(self) -> None:
        """Restore the height of the dock widget from settings."""
        saved_height = self.settings.getValue("dockHeight")
        if saved_height and self.dockwidget:
            self.iface.mainWindow().resizeDocks([self.dockwidget], [saved_height], VERTICAL)

    def net_sync_load(self, force: bool = False) -> None:
        """
        Periodically check for new files
        """

        lastDigestSync = self.settings.getValue("lastDigestSync")
        lastVersion = self.settings.getValue("pluginVersion")

        currTime = int(time())  # timestamp in seconds
        plugin_init = self.settings.getValue("initialized")
        autoUpdate = self.settings.getValue("autoUpdate")

        self.netsync = NetSync("Sync Riverscapes resource files")

        perform_sync = False

        # setting the force flag overrides everything else
        if force:
            perform_sync = True

        # Otherwise you only get a sync if 'autoUpdate' is turned on
        elif autoUpdate:
            # If this is an old version or the plugin is uninitialized
            if not plugin_init or lastVersion != __version__ or self.netsync.need_sync:
                perform_sync = True
            # If we haven't checked for more than `digestSyncFreqHours` hours
            elif isinstance(lastDigestSync, int) and ((currTime - lastDigestSync) / 3600) > CONSTANTS["digestSyncFreqHours"]:
                perform_sync = True

        if not perform_sync:
            self.netsync = None
            return

        # Trigger the dockwidget to repaint after the netsync
        if self.dockwidget is not None:
            self.netsync.taskCompleted.connect(self.dockwidget.reload_tree)

        # FOR DEBUGGING ONLY. NEVER IN PRODUCTION
        # self.netsync.run()

        # COMMENT THIS OUT AND USE THE LINE ABOVE FOR SYNCHRONOUS DEBUGGING
        self.tm.addTask(self.netsync)

    def projectBrowserDlg(self) -> None:
        """
        Browse for a project directory
        :return:
        """
        last_browse_path = self.settings.getValue("lastBrowsePath")
        last_dir = os.path.dirname(last_browse_path) if last_browse_path is not None else None

        dialog_return = QFileDialog.getOpenFileName(
            self.dockwidget,
            "Open a Riverscapes project",
            last_dir,
            self.tr("Riverscapes Project files (project.rs.xml)"),
        )
        if dialog_return is not None and len(dialog_return[0]) > 0:
            # Remember this path for next time
            self.settings.setValue("lastBrowsePath", dialog_return[0])
        if dialog_return is not None and dialog_return[0] != "" and os.path.isfile(dialog_return[0]):
            # We set the proect path in the project settings. This way it will be saved with the QgsProject file
            if self.dockwidget is None or self.dockwidget.isHidden():
                self.toggle_widget(forceOn=True)
            self.dockwidget.add_project(dialog_return[0])

    def _populate_recent_menu(self) -> None:
        """Rebuild the recent projects submenu."""
        self.recentProjectsMenu.clear()
        stored = self.settings.getValue("recentProjects") or []

        # Normalise entries - legacy format is a bare string; new format is {"path": ..., "name": ...}
        def _normalise(entry: object) -> tuple[str, str]:
            if isinstance(entry, dict):
                return entry.get("path", ""), entry.get("name", "")
            return str(entry), ""

        normalised = [_normalise(e) for e in stored]

        # Keep remote entries and local files that still exist
        def _is_valid(path: str) -> bool:
            return path.startswith("remote:") or os.path.isfile(path)

        valid = [(path, name) for path, name in normalised if _is_valid(path)]

        # Persist cleaned list if stale entries were removed
        if len(valid) != len(normalised):
            self.settings.setValue("recentProjects", [{"path": p, "name": n} for p, n in valid])

        if not valid:
            no_action = QAction(
                self.tr("(No recent projects)"),
                self.iface.mainWindow(),
            )
            no_action.setEnabled(False)
            self.recentProjectsMenu.addAction(no_action)
            return

        for path, name in valid:
            if not name:
                if path.startswith("remote:"):
                    name = path[7:]  # UUID fallback
                else:
                    name = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
            icon = qrave_icon("data-exchange-icon.svg") if path.startswith("remote:") else qrave_icon("viewer-icon.svg")
            action = QAction(icon, name, self.iface.mainWindow())
            action.setToolTip(path)
            action.triggered.connect(lambda checked, p=path: self._open_recent_project(p))
            self.recentProjectsMenu.addAction(action)

        self.recentProjectsMenu.addSeparator()
        clear_action = QAction(
            self.tr("Clear Recent Projects"),
            self.iface.mainWindow(),
        )
        clear_action.triggered.connect(self._clear_recent_projects)
        self.recentProjectsMenu.addAction(clear_action)

    def _open_recent_project(self, xml_path: str) -> None:
        """Open a project from the recent projects list."""
        if xml_path.startswith("remote:"):
            project_id = xml_path[7:]
            if self.dockwidget is None or self.dockwidget.isHidden():
                self.toggle_widget(forceOn=True)
            if self.dockwidget:
                self.dockwidget.show_loading(project_id)
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_remote_login(task, project_id))
            return
        if not os.path.isfile(xml_path):
            msg = self.tr("Could not find project:\n") + xml_path
            QMessageBox.warning(
                self.iface.mainWindow(),
                self.tr("Project Not Found"),
                msg,
            )
            return
        if self.dockwidget is None or self.dockwidget.isHidden():
            self.toggle_widget(forceOn=True)
        self.dockwidget.add_project(xml_path)

    def _clear_recent_projects(self) -> None:
        """Clear the recent projects list."""
        self.settings.setValue("recentProjects", [])

    def downloadProjectDlg(self, project_id: str | None = None, local_path: str | None = None):
        """
        Open the download dialog
        """
        dialog = ProjectDownloadDialog(self.iface.mainWindow(), project_id=project_id, local_path=local_path)
        dialog.projectDownloaded.connect(self.dockwidget.add_project)
        dialog.exec()

    def remoteProjectDlg(self) -> None:
        """
        Open a dialog to enter a project ID or URL
        """
        dialog = RemoteProjectDialog(self.iface.mainWindow())
        if dialog.exec():
            text = dialog.get_text()
            ok = True
        else:
            text = ""
            ok = False

        if ok and text:
            # Extract project ID from URL if necessary
            project_id = text.strip()
            if "/" in project_id:
                project_id = project_id.rstrip("/").split("/")[-1]

            # Validate that this is a proper guid like 4dd028c6-3e9f-4b14-a317-fe74903ed279
            if not re.match(
                r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
                project_id,
            ):
                QMessageBox.warning(
                    self.iface.mainWindow(),
                    "Invalid Project ID",
                    "The project ID you entered is invalid.",
                )
                return

            if self.dockwidget is None or self.dockwidget.isHidden():
                self.toggle_widget(forceOn=True)

            if self.dockwidget:
                self.dockwidget.show_loading(project_id)

            # Use DataExchangeAPI to fetch the project
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_remote_login(task, project_id))

    def _on_remote_login(self, task: RefreshTokenTask, project_id: str) -> None:
        if task.success:
            self.dataExchangeAPI.get_remote_project(project_id, self._on_remote_project_fetched)
        else:
            if self.dockwidget:
                self.dockwidget.hide_loading()
            QMessageBox.critical(
                self.iface.mainWindow(),
                "Login Failed",
                "Could not log in to Riverscapes API.",
            )

    def _on_remote_project_fetched(self, task: RunGQLQueryTask, response: dict | None) -> None:
        if self.dockwidget:
            self.dockwidget.hide_loading()

        if task.success and response and "data" in response and response["data"]["project"]:
            if self.dockwidget is None or self.dockwidget.isHidden():
                self.toggle_widget(forceOn=True)
            self.dockwidget.add_remote_project(response)
        else:
            QMessageBox.warning(self.iface.mainWindow(), "Project Not Found", "This project could not be found in the Riverscapes Data Exchange. It may be private, deleted, or the ID may be incorrect.")

    def closeAllProjects(self) -> None:
        """Close all open projects"""
        if self.dockwidget is not None:
            if len(self.dockwidget._get_projects()) > 0:
                msgBox = QMessageBox()
                msgBox.setWindowTitle("Close All Riverscapes Projects?")
                msgBox.setIcon(MSGBOX_ICON_QUESTION)
                msgBox.setText("Are you sure that you want to close all Riverscapes projects? This will also remove the layers related to these projects from your current map document.")
                # msgBox.setInformativeText("Are you sure that you want to close all Riverscapes projects? This will also remove the layers related to these projects from your current map document.")
                msgBox.setStandardButtons(MSGBOX_BTN_YES | MSGBOX_BTN_NO)
                msgBox.setDefaultButton(MSGBOX_BTN_NO)
                response = msgBox.exec()
                if response == MSGBOX_BTN_YES:
                    self.dockwidget.close_all()
                    if self.metawidget is not None:
                        self.metawidget.clear_and_hide()

    def show_project_bounds(self) -> None:
        """
        Open the project bounds dialog
        """
        # Check if there are any Vector layers in the project
        layers = QgsProject.instance().mapLayers()
        if not any(layer.type() == MAPLAYER_VECTOR for layer in layers.values()):
            QMessageBox.information(
                None,
                "No layers",
                "There are no Vector Layers in the map. Please add at least one Vector Layer to the map before generating project bounds.",
            )
            return

        dialog = FrmProjectBounds()
        dialog.exec()

    def locateResources(self) -> 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]
        """
        qurl = QUrl.fromLocalFile(RESOURCES_DIR)
        QDesktopServices.openUrl(qurl)

    def options_load(self) -> None:
        """
        Open the options/settings dialog
        """
        dialog = OptionsDialog()
        if self.dockwidget:
            dialog.dataChange.connect(self.dockwidget.dataChange)
            dialog.dataChange.connect(self.redock_widgets)
        dialog.exec()

    def about_load(self) -> None:
        """
        Open the About dialog
        """
        dialog = AboutDialog()
        dialog.exec()

    def redock_widgets(self) -> None:
        """Redock the widget according to the saved settings.
        If the widget is hidden, set the new dock location but do not show it.
        """
        dock_location = LEFT_DOCK if self.settings.getValue("dockLocation") == "left" else RIGHT_DOCK

        # Redock main dockwidget
        if self.dockwidget is not None:
            was_visible = not self.dockwidget.isHidden()
            self.iface.removeDockWidget(self.dockwidget)
            self.iface.addDockWidget(dock_location, self.dockwidget)
            if not was_visible:
                self.dockwidget.hide()

        # Redock metadata widget
        if self.metawidget is not None:
            was_visible = not self.metawidget.isHidden()
            self.iface.removeDockWidget(self.metawidget)
            self.iface.addDockWidget(dock_location, self.metawidget)
            if not was_visible:
                self.metawidget.hide()

    def browseExchangeProjects(self) -> None:

        # Get the center and zoom level to build the search url
        canvas = self.iface.mapCanvas()
        center = get_map_center(canvas)
        zoom = get_zoom_level(canvas)
        search_url = f"{CONSTANTS['warehouseUrl']}/s?type=Project&bounded=1&view=map&geo={center.x()}%2C{center.y()}%2C{zoom}"
        # Open the URL in the default web browser
        QDesktopServices.openUrl(QUrl(search_url))
