# -*- coding: utf-8 -*-
"""
/***************************************************************************
 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 typing import List, Dict
import os
import json
import requests

from qgis.PyQt.QtCore import pyqtSignal, pyqtSlot, Qt, QModelIndex, QUrl, QTimer
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtWidgets import QDockWidget, QWidget, QTreeView, QVBoxLayout, QMenu, QAction, QFileDialog, QMessageBox, QApplication
from qgis.PyQt.QtGui import QStandardItemModel, QStandardItem, QIcon, QDesktopServices
from qgis.core import Qgis, QgsRasterLayer, QgsVectorLayer, QgsProject, QgsBlockingNetworkRequest
from qgis.PyQt import uic

from .classes.rspaths import safe_make_abspath, safe_make_relpath
from .ui.dock_widget import Ui_QRAVEDockWidgetBase
from .meta_widget import MetaType

from .project_upload_dialog import ProjectUploadDialog
from .project_download_dialog import ProjectDownloadDialog
from .classes.qrave_map_layer import QRaveMapLayer, QRaveTreeTypes
from .classes.context_menu import ContextMenu
from .classes.project import Project, ProjectTreeData
from .classes.remote_project import RemoteProject
from .classes.basemaps import BaseMaps, QRaveBaseMap
from .classes.settings import Settings, CONSTANTS
from .classes.data_exchange.DataExchangeAPI import DataExchangeAPI
from .classes.GraphQLAPI import RefreshTokenTask, RunGQLQueryTask


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(QRAVEDockWidget, self).__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(Qt.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.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):
        item = self.model.itemFromIndex(idx)
        item_data = item.data(Qt.UserRole)
        if item_data and item_data.data and isinstance(item_data.data, QRaveBaseMap):
            item_data.data.load_layers()

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

        Returns:
            [type]: [description]
        """
        qrave_projects = self.get_project_settings()
        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(Qt.UserRole)
                if item_data and hasattr(item_data, 'project') and item_data.project is not None:
                    projects.append(item_data.project)
        return projects

    def _get_project_by_name(self, name):
        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):
        # 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()

        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 is True \
                    and project.qproject is not None \
                    and project.loadable is True:
                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:
                # If this project is unloadable then make sure it never tries to load again
                self.set_project_settings(
                    [x for x in qrave_projects if x != project_path])

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

        # Now load the basemaps
        region = self.settings.getValue('basemapRegion')
        if self.settings.getValue('basemapsInclude') is True \
                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):
        """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 type_conversion_ok is False:
                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):
        """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?",
                    QMessageBox.Yes | QMessageBox.No,
                    QMessageBox.No
                )
                if result != QMessageBox.Yes:
                    continue
                new_path, _ = QFileDialog.getOpenFileName(
                    self, f"Locate missing project file for '{name}'", "", "Riverscapes Project (*.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):
        """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[str]):
        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):
        self.qproject.removeEntry(
            CONSTANTS['settingsCategory'], 'qrave_projects')

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

        test_project = Project(xml_path)
        test_project.load()
        if test_project.loadable is False or test_project.exists is False 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 and load_default_setting is True \
                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])

    @pyqtSlot(dict)
    def add_remote_project(self, gql_data: Dict):
        qrave_projects = self.get_project_settings()
        
        test_project = RemoteProject(gql_data)
        test_project.load()
        
        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)

        # 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 and 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):
        """Add a temporary loading item to the tree"""
        # Make sure it's not already there
        self.hide_loading()
        
        loading_item = QStandardItem(QIcon(':/plugins/qrave_toolbar/refresh.png'), f"Loading Remote: {label}...")
        # Use a special data role to identify it
        loading_item.setData("LOADING_PLACEHOLDER", Qt.UserRole + 10)
        self.model.insertRow(0, loading_item)

    def hide_loading(self):
        """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(Qt.UserRole + 10) == "LOADING_PLACEHOLDER":
                root.removeRow(i)
                # We return here because we only expect one, and loop indices change after removeRow
                return

    def closeEvent(self, event):
        """ When the user clicks the "X" in the dockwidget titlebar
        """
        self.hide()
        self.qproject.removeEntry(CONSTANTS['settingsCategory'], 'enabled')
        self.closingPlugin.emit()
        event.accept()

    def expand_children_recursive(self, idx: QModelIndex = None, force=False):
        """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(Qt.UserRole) 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, current_path=""):
        """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(Qt.UserRole) 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):
        if not idx.isValid():
            return

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

        # 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:
            print("Default View Action")
            self.add_view_to_map(item_data)

    def item_change(self, pos):
        """Triggered when the user selects a new item in the tree

        Args:pos
            pos ([type]): [description]
        """
        indexes = self.treeView.selectedIndexes()
    
        if len(indexes) < 1:
            return

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

        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=False):
        """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]):

        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):
        """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):
        """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):
        projects = list(self._get_projects())
        if len(projects) == 0:
            return

        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)

        QTimer.singleShot(0, self.reload_tree)

    def close_project(self, project: Project, reload_tree=True):
        """ Close the project
        """
        try:
            qrave_projects = self.get_project_settings()
        except Exception as e:
            self.settings.log(
                'Error closing project: {}'.format(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):
        """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):
        """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):
        """ 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 not hasattr(self, 'dataExchangeAPI') or 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):
        """ 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('#', '_')
        report_url = f"{CONSTANTS['warehouseUrl'].rstrip('/')}/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):
        """ 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", f"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', '')
                index_url = f"{base_url}/{layer_name}/index.json"

                if index_url:
                    def _fetch_json(url):
                        headers = {}
                        try:
                            resp_json = requests.get(url, headers=headers, timeout=10)
                            if resp_json.status_code == 200:
                                return resp_json.json()
                            else:
                                self.settings.log(f"Failed to fetch JSON from {url}. Status: {resp_json.status_code}", Qgis.Warning)
                        except Exception as e:
                            self.settings.log(f"Error fetching JSON from {url}: {e}", Qgis.Warning)
                        return None

                    index_data = _fetch_json(index_url)
                    
                    if index_data:
                        self.settings.log(f"Successfully fetched index metadata from {index_url}", Qgis.Info)
                        resp.update(index_data)
                    else:
                        self.settings.log(f"Failed to fetch valid JSON from indexUrl or fallback: {index_url}", Qgis.Warning)

                # Now fetch symbology
                def _handle_symbology(symb_task: RunGQLQueryTask, symb_resp: Dict):
                    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)

                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)
            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 not hasattr(self, 'dataExchangeAPI') or self.dataExchangeAPI is None:
            self.dataExchangeAPI = DataExchangeAPI(on_login=lambda task: self._on_add_layer_login(task, item, item_data))
        else:
            project_id = item_data.project.id if hasattr(item_data.project, 'id') else (item_data.project.warehouse_meta['id'][0] if item_data.project.warehouse_meta and 'id' in item_data.project.warehouse_meta else 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):
        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):
        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):
        if project_id in self._fetching_projects:
            return
        self._fetching_projects.add(project_id)

        if not hasattr(self, 'dataExchangeAPI') or 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):
        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):
        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=0, limit=50):
        """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]
                    # We need to deeply merge the metadata into the cached dataset items
                    # This is O(N*M) but N and M are small (50 items per page)
                    # We expect the cache to have all items from the initial load (limited to 50? Wait.)
                    # The initial load has dsLimit: 50. 
                    # If the project has > 50 datasets, we only have 50 in the cache initially?
                    # The user said "The issue is that if we request Metadata ... we will time out".
                    # If we paginate, we are better.
                    # BUT `webRaveProject.graphql` takes `dsLimit`.
                    # DataExchangeAPI.get_remote_project sets limit=50.
                    # So we only have the first 50 datasets in the project structure initially.
                    # If we fetch metadata for *more* than 50, we might need to append them to the cache?
                    # Or does RemoteProject only show the first 50?
                    
                    # RemoteProject uses `self.data.get('datasets', {}).get('items', [])`.
                    # If the tree has leaves referencing datasets that are NOT in the first 50 items,
                    # they won't be in `dataset_meta_map`.
                    # So we should probably fetch metadata for ALL datasets and update the map.
                    # The `RemoteProject` tree leaves are the source of truth for what layers exist.
                    # `datasets` list helps with metadata.
                    
                    # Update cache logic:
                    if 'data' in cached_proj and 'project' in cached_proj['data']:
                        proj_data = cached_proj['data']['project']
                        if 'datasets' not in proj_data:
                            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 'id' in i}
                        
                        for new_item in items:
                            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 (it might be multiple times if copied, but usually 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 not hasattr(self, 'dataExchangeAPI') or 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=True):

        def _recurse(curritem):
            idx = self.model.indexFromItem(item)
            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 is True:
                self.treeView.expandAll()
            else:
                self.treeView.collapseAll()
        else:
            _recurse(item)

    def add_view_to_map(self, item_data: ProjectTreeData):
        """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):
        """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(Qt.UserRole)
            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 is True:
                    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):
        """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):
        stack = []
        placeholder = start_item.parent()
        while placeholder is not None and placeholder != self.model.invisibleRootItem():
            stack.append(placeholder)
            placeholder = start_item.parent()

        return stack.reverse()

    def open_menu(self, position):

        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(Qt.UserRole)  # 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)

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

    def map_layer_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, item_data: ProjectTreeData):
        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)
        if not isinstance(item_data.project, RemoteProject) and item_data.project.warehouse_meta and 'id' in item_data.project.warehouse_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):
        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):
        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):
        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):
        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):
        menu.addAction(
            'ADD_ALL_TO_MAP', lambda: self.add_view_to_map(item_data))

    # Project-level context menu
    def project_context_menu(self, menu: ContextMenu, idx: QModelIndex, item: QStandardItem, data: ProjectTreeData):
        menu.addAction('COLLAPSE_ALL', lambda: self.toggleSubtree(None, False))
        menu.addAction('EXPAND_ALL', lambda: self.toggleSubtree(None, True))
        menu.addSeparator()
        menu.addAction('UPLOAD_PROJECT', lambda: self.project_upload_load(data.project))
        if isinstance(data.project, RemoteProject) or (data.project.warehouse_meta and 'id' in data.project.warehouse_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):
        """
        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):
        """
        Open the Project Download dialog
        """
        project_id = None
        local_path = None
        
        if isinstance(project, RemoteProject):
            project_id = project.id
        else:
            project_id = project.warehouse_meta['id'][0] if project.warehouse_meta and 'id' in project.warehouse_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()
