from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
import json
import os
from typing import Any
import urllib.parse

from qgis.core import (
    Qgis,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsLayerMetadata,
    QgsLayerTreeGroup,
    QgsLayerTreeLayer,
    QgsMapLayer,
    QgsMessageLog,
    QgsProject,
    QgsRasterLayer,
    QgsRectangle,
    QgsReferencedRectangle,
    QgsVectorLayer,
    QgsVectorTileLayer,
)

# Some builds of QGIS do not have the specialized MapboxGL renderer
try:
    from qgis.core import QgsVectorTileMapboxGLRenderer
except ImportError:
    QgsVectorTileMapboxGLRenderer = None

# Most builds have the style converter
try:
    from qgis.core import QgsMapBoxGlStyleConverter
except ImportError:
    QgsMapBoxGlStyleConverter = None
from qgis.PyQt.QtGui import QStandardItem

from ..compat import MAPBOX_GL_SUCCESS, USER_ROLE
from .rspaths import parse_rel_path
from .settings import CONSTANTS, Settings

SYMBOLOGY_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "resources", "symbology")
# BASE is the name we want to use inside the settings keys
MESSAGE_CATEGORY = CONSTANTS["logCategory"]


class QRaveTreeTypes(str, Enum):
    """Tree-node type tags.  Inheriting ``str`` means instances compare equal
    to their string values, keeping all existing ``== 'PROJECT_ROOT'`` checks
    and JSON round-trips working transparently.
    """

    PROJECT_ROOT = "PROJECT_ROOT"
    PROJECT_FOLDER = "PROJECT_FOLDER"
    PROJECT_REPEATER_FOLDER = "PROJECT_REPEATER_FOLDER"
    PROJECT_VIEW_FOLDER = "PROJECT_VIEW_FOLDER"
    PROJECT_VIEW = "PROJECT_VIEW"
    LEAF = "LEAF"  # any kind of end node: map-layers and other open-able files
    PROJECT_LOAD_ERROR = "PROJECT_LOAD_ERROR"
    # Basemaps
    BASEMAP_ROOT = "BASEMAP_ROOT"
    BASEMAP_SUPER_FOLDER = "BASEMAP_SUPER_FOLDER"
    BASEMAP_SUB_FOLDER = "BASEMAP_SUB_FOLDER"


@dataclass
class ProjectTreeData:
    """Container attached to every ``QStandardItem`` via ``USER_ROLE``.

    Attributes:
        type:    Node kind — used by context-menu and click handlers.
        project: The owning ``Project`` or ``RemoteProject`` instance, or
                 ``None`` for basemap nodes.
        data:    Node payload: a ``QRaveMapLayer``, a ``dict`` of business-logic
                 attributes, a raw path ``str``, a view-layer ``list``, etc.
    """

    type: QRaveTreeTypes
    project: Any = None
    data: Any = None


class QRaveMapLayer:
    class LayerTypes:
        POLYGON = "polygon"
        LINE = "line"
        POINT = "point"
        RASTER = "raster"
        TIN = "tin"
        # Regular file types
        FILE = "file"
        REPORT = "report"
        # Tile Types
        WEBTILE = "webtile"

    def __init__(self, label: str, layer_type: str, layer_uri: str, bl_attr: dict[str, str] | None = None, meta: dict[str, str] | None = None, layer_name: str | None = None, tile_type: str | None = None, description: str | None = None):

        self.label = label
        self.tiles_label = f"{label} (Tiles)"
        self.layer_uri = layer_uri
        layer_type = layer_type.lower() if layer_type else None

        # If this is a real file then sanitize the URI
        if isinstance(self.layer_uri, str) and len(layer_uri) > 0 and layer_type != QRaveMapLayer.LayerTypes.WEBTILE:
            sani_path = parse_rel_path(layer_uri)
            self.layer_uri = os.path.abspath(sani_path)

        self.bl_attr = bl_attr
        self.meta = meta
        self.transparency = 0
        self.layer_name = layer_name
        self.tile_type = tile_type
        self.description = description

        if layer_type not in QRaveMapLayer.LayerTypes.__dict__.values():
            settings = Settings()
            settings.log(f'Layer type "{layer_type}" is not valid', Qgis.Critical)
        self.layer_type = layer_type

        self.exists = False
        if self.layer_type == QRaveMapLayer.LayerTypes.WEBTILE:
            self.exists = True
        elif isinstance(self.layer_uri, str) and len(self.layer_uri) > 0:
            self.exists = os.path.isfile(self.layer_uri)

    @staticmethod
    def _getlayerposition(item: QStandardItem) -> tuple[int, list]:

        name = item.text()
        order = [name]
        absolute_position = 0
        parent = item.parent()
        if parent is not None:
            child_idx = 0
            child = parent.child(child_idx)
            child_data = child.data(USER_ROLE)
            while child_data is not None:
                if child.text() == name:
                    return absolute_position, order
                if isinstance(child_data.data, QRaveMapLayer):
                    if child_data.data.layer_type in [
                        QRaveMapLayer.LayerTypes.LINE,
                        QRaveMapLayer.LayerTypes.POINT,
                        QRaveMapLayer.LayerTypes.POLYGON,
                        QRaveMapLayer.LayerTypes.RASTER,
                    ]:
                        absolute_position += 1
                else:
                    if child_data.type == QRaveTreeTypes.PROJECT_FOLDER:
                        absolute_position += 1
                order.append(child.text())
                child_idx += 1
                child = parent.child(child_idx)
                child_data = child.data(USER_ROLE) if child is not None else None

        return absolute_position, order

    @staticmethod
    def _addgrouptomap(sGroupName: str, sGroupOrder: int, parentGroup: QgsLayerTreeGroup | None) -> QgsLayerTreeGroup:
        """
        Add a hierarchical group to the layer manager.

        Searches only direct children (not the full subtree) to avoid
        accidentally matching a same-named group in a different project.
        Group creation uses ``addGroup`` / ``insertGroup`` (both return a
        stable C++-owned reference) rather than the
        ``QgsLayerTreeGroup() + insertChildNode`` pattern, which transfers
        Python->C++ ownership and invalidates the Python wrapper in PyQt6.
        """
        if parentGroup is None:
            parentGroup = QgsProject.instance().layerTreeRoot()

        # Search direct children only - avoids recursive false-matches when
        # multiple projects with identical folder names are open.
        thisGroup = None
        for child in parentGroup.children():
            if isinstance(child, QgsLayerTreeGroup) and child.name() == sGroupName:
                thisGroup = child
                break

        if thisGroup is None:
            # Basemaps always appended at the bottom of the root.
            if sGroupName == "Basemaps" and sGroupOrder == 0 and parentGroup == QgsProject.instance().layerTreeRoot():
                thisGroup = parentGroup.addGroup(sGroupName)
            elif hasattr(parentGroup, "insertGroup"):
                # QGIS 3: convenience wrapper preserves sibling ordering.
                thisGroup = parentGroup.insertGroup(sGroupOrder, sGroupName)
            else:
                # QGIS 4: insertGroup removed.  addGroup appends at the end
                # but returns a stable C++-owned reference - unlike the
                # ``QgsLayerTreeGroup() + insertChildNode`` pattern which
                # transfers Python->C++ ownership and invalidates the wrapper
                # in PyQt6, causing all subsequent groups to be re-rooted.
                thisGroup = parentGroup.addGroup(sGroupName)

        return thisGroup

    @staticmethod
    def find_layer_symbology(item: QStandardItem) -> str | None:

        settings = Settings()
        chosen_qml = None
        # No multiselect so there is only ever one item
        pt_data: ProjectTreeData = item.data(USER_ROLE)
        project = pt_data.project
        map_layer: QRaveMapLayer = pt_data.data

        symbology = map_layer.bl_attr["symbology"] if map_layer.bl_attr is not None and "symbology" in map_layer.bl_attr else None
        # If the business logic has symbology defined
        if symbology is not None:
            if len(symbology) == 0:
                settings.log(
                    f"Empty Symbology attribute for node with attributes: {map_layer.bl_attr}",
                    level=Qgis.Warning,
                )
            else:
                qml_fname = f"{symbology}.qml"
                os.path.abspath(os.path.join(project.project_dir, qml_fname))

                # Here are the search paths for QML files in order of precedence
                hierarchy = [
                    os.path.abspath(os.path.join(project.project_dir, qml_fname)),
                    # This is the default one
                    os.path.abspath(os.path.join(SYMBOLOGY_DIR, project.project_type, qml_fname)),
                    os.path.abspath(os.path.join(SYMBOLOGY_DIR, "Shared", qml_fname)),
                ]
                # Find the first match
                try:
                    chosen_qml = next(iter([candidate for candidate in hierarchy if os.path.isfile(candidate)]))
                    # Report to the terminal if we couldn't find a qml file to use
                    if chosen_qml is None:
                        settings.log(
                            "Missing Symbolog: Could not find a valid .qml symbology file for layer {}. Search paths: \n[\n{}]".format(map_layer.layer_uri, "   ,\n".join(hierarchy)),
                            level=Qgis.Warning,
                        )

                except StopIteration:
                    settings.log(
                        "Could not find valid symbology for layer at any of the following search paths: [ {} ]".format(", ".join(hierarchy)),
                        Qgis.Warning,
                    )
            return chosen_qml

    @staticmethod
    def _prepare_parent_group(item: QStandardItem) -> tuple:
        """Prepare the parent group for a layer in the tree"""
        # Loop over all the parent group layers for this raster
        # ensuring they are in the tree in correct, nested order
        ancestry = []
        ancestry_order = []
        parent = item.parent()
        while parent is not None and len(ancestry) < 50:
            pos, order = QRaveMapLayer._getlayerposition(parent)
            ancestry.append((parent.text(), pos))
            ancestry_order.append((parent.text(), order))
            parent = parent.parent()

        ancestry.reverse()
        ancestry_order.reverse()
        parentGroup = None
        for agroup, group_order in ancestry_order:
            if parentGroup is None:
                parentGroup = QgsProject.instance().layerTreeRoot()
            pos = 0
            group_order.reverse()
            for group in group_order:
                test_group = parentGroup.findGroup(group)
                if test_group:
                    pos += 1
            parentGroup = QRaveMapLayer._addgrouptomap(agroup, pos, parentGroup)

        if parentGroup is None:
            parentGroup = QgsProject.instance().layerTreeRoot()

        return parentGroup, ancestry

    @staticmethod
    def add_layer_to_map(item: QStandardItem) -> None:
        """
        Add a layer to the map
        :param layer:
        :return:
        """

        # No multiselect so there is only ever one item
        pt_data: ProjectTreeData = item.data(USER_ROLE)
        map_layer: QRaveMapLayer = pt_data.data

        settings = Settings()

        if not map_layer.exists:
            # Layer does not exist. do not try to put it on the map
            return

        parentGroup, ancestry = QRaveMapLayer._prepare_parent_group(item)

        # Loop over all the parent group layers for this raster
        # ensuring they are in the tree in correct, nested order

        # Only add the layer if it's not already in the registry
        exists = False
        existing_layers = QgsProject.instance().mapLayersByName(map_layer.label)
        layers_ancestry = [QRaveMapLayer.get_layer_ancestry(lyr) for lyr in existing_layers]

        # Now we compare the ancestry group labels to the business logic ancestry branch names
        # to see if this layer is already in the map
        for lyr in layers_ancestry:
            if len(lyr) == len(ancestry) and all(iter([ancestry[x][0] == lyr[x] for x in range(len(ancestry))])):
                exists = True
                break
            elif lyr and lyr[0] == ancestry[0][0]:  # same-named project check
                exists = True
                break

        if not exists:
            layer_uri = map_layer.layer_uri
            rOutput = None
            # This might be a basemap
            if map_layer.layer_type == QRaveMapLayer.LayerTypes.WEBTILE:
                out_uri = layer_uri.replace("%3F", "?").replace("%3A", ":").replace("%2F", "/").replace("%3D", "=")
                rOutput = QgsRasterLayer(out_uri, map_layer.label, "wms")

            elif map_layer.layer_type in [
                QRaveMapLayer.LayerTypes.LINE,
                QRaveMapLayer.LayerTypes.POLYGON,
                QRaveMapLayer.LayerTypes.POINT,
            ]:
                if map_layer.layer_name is not None:
                    layer_uri += f"|layername={map_layer.layer_name}"
                rOutput = QgsVectorLayer(layer_uri, map_layer.label, "ogr")

            elif map_layer.layer_type == QRaveMapLayer.LayerTypes.RASTER:
                # Raster
                rOutput = QgsRasterLayer(layer_uri, map_layer.label)

            if rOutput is not None:
                ##########################################
                # Symbology
                ##########################################
                chosen_qml = QRaveMapLayer.find_layer_symbology(item)
                if chosen_qml:
                    rOutput.loadNamedStyle(chosen_qml)

                ############################################################
                # Transparency. A few notes:
                # - QML transparency will prevail for rasters before 3.18
                # - We set this here so that QML layer transparency will be
                #   overruled
                ############################################################
                transparency = 0

                try:
                    if map_layer.bl_attr is not None:
                        transparency = int(map_layer.bl_attr.get("transparency", 0))
                    else:
                        transparency = map_layer.transparency
                except Exception as e:
                    settings.log(f"Error interpretting error in business logic: {e}", Qgis.Warning)

                try:
                    if transparency > 0:
                        if rOutput.__class__ is QgsVectorLayer:
                            rOutput.setOpacity((100 - transparency) / 100.0)
                        elif rOutput.__class__ is QgsRasterLayer:
                            renderer = rOutput.renderer()
                            renderer.setOpacity((100 - transparency) / 100.0)
                            # rOutput.triggerRepaint()
                except Exception as e:
                    settings.log(f"Error deriving transparency from layer: {e}", Qgis.Warning)

                QgsProject.instance().addMapLayer(rOutput, False)
                parentGroup.insertChildNode(-1, QgsLayerTreeLayer(rOutput))

                ##########################################
                # Feature Filter (Definition Query)
                ##########################################

                filter_expr = map_layer.bl_attr["filter"] if map_layer.bl_attr is not None and "filter" in map_layer.bl_attr else None

                if filter_expr is not None:
                    rOutput.setSubsetString(filter_expr)

        # if the layer already exists trigger a refresh
        else:
            QgsProject.instance().mapLayersByName(map_layer.label)[0].triggerRepaint()

    @staticmethod
    def add_remote_vector_layer_to_map(item: QStandardItem, tile_service: dict) -> None:
        """Add a remote vector tile layer to the map"""
        pt_data: ProjectTreeData = item.data(USER_ROLE)
        map_layer: QRaveMapLayer = pt_data.data
        settings = Settings()

        parentGroup, ancestry = QRaveMapLayer._prepare_parent_group(item)

        # Check if exists
        existing_layers = QgsProject.instance().mapLayersByName(map_layer.tiles_label)
        layers_ancestry = [QRaveMapLayer.get_layer_ancestry(lyr) for lyr in existing_layers]

        exists = False
        for lyr in layers_ancestry:
            if len(lyr) == len(ancestry) and all(iter([ancestry[x][0] == lyr[x] for x in range(len(ancestry))])):
                exists = True
                break

        if exists:
            QgsProject.instance().mapLayersByName(map_layer.tiles_label)[0].triggerRepaint()
            return

        # Construct Tile URL
        base_url = tile_service.get("url", "").rstrip("/")

        # TODO: Fix minZoom and maxZoom on the server-side so we don't need to fetch indexUrl manually
        # For vector layers we use layer_name or nodeId
        layer_name = map_layer.layer_name
        if not layer_name:
            layer_name = map_layer.bl_attr.get("nodeId", "")
        if not layer_name:
            # Strip the id off of the rsXPath. E.g. for
            # Project/Realizations/Realization#ID/Datasets/Vector#Name
            # we want just the trailing Name.
            xpath = map_layer.bl_attr.get("rsXPath", "")
            if "#" in xpath:
                layer_name = xpath.split("/")[-1].split("#")[1]
            else:
                layer_name = xpath.split("/")[-1]

        fmt = tile_service.get("format", "pbf")

        # Build the URI
        # Examples: type=xyz&url=https://.../{z}/{x}/{y}.pbf
        tile_url = f"{base_url}/{layer_name}/{{z}}/{{x}}/{{y}}.{fmt}"

        rOutput = None
        uri = None
        if fmt == "pbf":
            # Vector Tile URI format: type=xyz&url=...&zmin=...&zmax=...
            encoded_url = urllib.parse.quote(tile_url, safe="/:?={}")
            uri = f"type=xyz&url={encoded_url}"
            if tile_service.get("maxZoom") is not None:
                uri += f"&zmax={tile_service['maxZoom']}"
            if tile_service.get("minZoom") is not None:
                uri += f"&zmin={tile_service['minZoom']}"

            rOutput = QgsVectorTileLayer(uri, map_layer.tiles_label)
        elif fmt == "gpkg":
            # GeoPackage format - assume it's a file download
            # Construct the URL: base_url/layer_name.gpkg
            # We use /vsicurl/ to stream it if possible
            file_url = f"{base_url}/{layer_name}.gpkg"
            uri = f"/vsicurl/{file_url}"
            rOutput = QgsVectorLayer(uri, map_layer.tiles_label, "ogr")
        else:
            settings.log(f"Unsupported format: {fmt}", Qgis.Warning)

        if uri:
            settings.log(f"Adding remote layer URI: {uri}", Qgis.Info)

        if rOutput and rOutput.isValid():
            QgsProject.instance().addMapLayer(rOutput, False)
            parentGroup.insertChildNode(-1, QgsLayerTreeLayer(rOutput))

            # Set extent and metadata AFTER adding to project to ensure they stick
            bounds = tile_service.get("bounds")
            if bounds and len(bounds) == 4:
                try:
                    rect = QgsRectangle(bounds[0], bounds[1], bounds[2], bounds[3])
                    src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                    dest_crs = rOutput.crs()
                    if not dest_crs.isValid():
                        dest_crs = QgsCoordinateReferenceSystem("EPSG:3857")
                        rOutput.setCrs(dest_crs)

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

                    rOutput.setExtent(rect)

                    metadata = rOutput.metadata()
                    metadata.setIdentifier(map_layer.tiles_label)
                    metadata.setTitle(map_layer.tiles_label)

                    spatialExtent = QgsLayerMetadata.SpatialExtent()
                    spatialExtent.extent = QgsReferencedRectangle(rect, dest_crs)

                    extent = QgsLayerMetadata.Extent()
                    extent.setSpatialExtents([spatialExtent])
                    metadata.setExtent(extent)

                    rOutput.setMetadata(metadata)
                    settings.log(f"Finalized layer metadata and extent: {rect.toString()}", Qgis.Info)
                except Exception as e:
                    settings.log(f"Error finalising metadata: {e}", Qgis.Warning)

            rOutput.triggerRepaint()

            # Apply Mapbox GL Symbology if present
            mapbox_json = tile_service.get("mapboxJson")
            if mapbox_json:
                try:
                    # If it's a string, parse it
                    if isinstance(mapbox_json, str):
                        try:
                            mapbox_json = json.loads(mapbox_json)
                        except json.JSONDecodeError:
                            settings.log(f"Failed to parse mapboxJson string for {map_layer.tiles_label}", Qgis.Warning)
                            mapbox_json = None

                    if mapbox_json:
                        # The API might return a full Riverscapes Symbology object or just a list of layers
                        layers = []
                        if isinstance(mapbox_json, dict):
                            if "layerStyles" in mapbox_json:
                                layers = mapbox_json["layerStyles"]
                            elif "layers" in mapbox_json:
                                layers = mapbox_json["layers"]
                        elif isinstance(mapbox_json, list):
                            layers = mapbox_json

                        if layers:
                            # Dynamically fix source and source-layer
                            # QGIS expects these to match the source ID and layer name in the tiles
                            source_id = "vector_tiles"
                            for layer in layers:
                                if isinstance(layer, dict):
                                    layer["source"] = source_id
                                    layer["source-layer"] = layer_name

                    style = {"version": 8, "sources": {source_id: {"type": "vector", "tiles": [tile_url]}}, "layers": layers}
                    style_json = json.dumps(style)

                    # Option 1: Use the specialized native renderer if available
                    if QgsVectorTileMapboxGLRenderer:
                        renderer = QgsVectorTileMapboxGLRenderer()
                        renderer.setStyle(style_json)
                        rOutput.setRenderer(renderer)
                        settings.log("Applied Mapbox GL symbology using native renderer", Qgis.Info)

                    # Option 2: Fallback to the style converter (more compatible)
                    # https://qgis.org/pyqgis/master/core/QgsMapBoxGlStyleConverter.html
                    elif QgsMapBoxGlStyleConverter:
                        converter = QgsMapBoxGlStyleConverter()
                        result = converter.convert(style_json)
                        if result == MAPBOX_GL_SUCCESS:
                            rOutput.setRenderer(converter.renderer())
                            rOutput.setLabeling(converter.labeling())
                            settings.log("Applied Mapbox GL symbology and labeling using style converter", Qgis.Info)
                        else:
                            settings.log(f"Mapbox style conversion failed: {converter.errorMessage()}", Qgis.Warning)

                    else:
                        settings.log("No compatible Mapbox GL renderer found in this QGIS build.", Qgis.Warning)

                except Exception as e:
                    settings.log(f"Error applying Mapbox GL symbology: {e}", Qgis.Warning)

            rOutput.triggerRepaint()

            # TODO: Transparency and other settings if needed
            transparency = 0
            try:
                transparency = int(map_layer.bl_attr.get("transparency", 0))
                if transparency > 0:
                    if isinstance(rOutput, QgsVectorTileLayer) or isinstance(rOutput, QgsVectorLayer):
                        if hasattr(rOutput, "setOpacity"):
                            rOutput.setOpacity((100 - transparency) / 100.0)
                    elif isinstance(rOutput, QgsRasterLayer):
                        rOutput.renderer().setOpacity((100 - transparency) / 100.0)
            except Exception as e:
                settings.log(f"Error setting transparency: {e}", Qgis.Warning)
        else:
            settings.log(f"Failed to create valid remote layer for {map_layer.tiles_label}", Qgis.Critical)

    @staticmethod
    def add_remote_raster_layer_to_map(item: QStandardItem, tile_service: dict) -> None:
        """Add a remote raster layer to the map"""
        pt_data: ProjectTreeData = item.data(USER_ROLE)
        map_layer: QRaveMapLayer = pt_data.data
        settings = Settings()

        parentGroup, ancestry = QRaveMapLayer._prepare_parent_group(item)

        # Check if exists
        existing_layers = QgsProject.instance().mapLayersByName(map_layer.tiles_label)
        layers_ancestry = [QRaveMapLayer.get_layer_ancestry(lyr) for lyr in existing_layers]

        exists = False
        for lyr in layers_ancestry:
            if len(lyr) == len(ancestry) and all(iter([ancestry[x][0] == lyr[x] for x in range(len(ancestry))])):
                exists = True
                break

        if exists:
            QgsProject.instance().mapLayersByName(map_layer.tiles_label)[0].triggerRepaint()
            return

        # Symbology logic provided by user
        symbology_name = map_layer.bl_attr.get("symbology")
        symbology_key = symbology_name if symbology_name and symbology_name != "NONE" else "raster"

        fmt = tile_service.get("format", "png")
        is_cog = fmt == "COG"
        base_url = tile_service.get("url", "")

        if is_cog:
            # COG logic: User says replace {symbology} and remove trailing slash
            tile_url = base_url.replace("{symbology}", symbology_key).rstrip("/")
        else:
            # Construct XYZ URL: {url}{symbology}/{z}/{x}/{y}.{format}
            # Following user's logic: ${tileService.url}${symbologyKey}/{z}/{x}/{y}.${tileService.format || 'png'}
            # We ensure base_url ends with / if it doesn't already
            xyz_base = base_url
            if not xyz_base.endswith("/"):
                xyz_base += "/"
            tile_url = f"{xyz_base}{symbology_key}/{{z}}/{{x}}/{{y}}.{fmt}"

        # Determine if it's an XYZ tile service or a single file
        # If it has tile placeholders, it's an XYZ service
        if "{z}" in tile_url or "{x}" in tile_url or "{y}" in tile_url:
            # Don't forget to encode the URL so that special characters are handled correctly
            encoded_url = urllib.parse.quote(tile_url, safe="/:?={}")
            uri = f"type=xyz&url={encoded_url}"
            provider = "wms"
        else:
            # Single file COG or other raster
            uri = tile_url
            if uri.startswith("http") and not uri.startswith("/vsicurl/"):
                uri = f"/vsicurl/{uri}"
            provider = "gdal"

        settings.log(f"Attempting to add remote raster: {map_layer.tiles_label}", Qgis.Info)
        settings.log(f"  - Format: {fmt} (is_cog: {is_cog})", Qgis.Info)
        settings.log(f"  - Symbology Key: {symbology_key}", Qgis.Info)
        settings.log(f"  - Constructed Tile URL: {tile_url}", Qgis.Info)
        settings.log(f"  - Final URI: {uri}", Qgis.Info)
        settings.log(f"  - Provider: {provider}", Qgis.Info)

        rOutput = QgsRasterLayer(uri, map_layer.tiles_label, provider)

        if rOutput and rOutput.isValid():
            QgsProject.instance().addMapLayer(rOutput, False)
            parentGroup.insertChildNode(-1, QgsLayerTreeLayer(rOutput))

            # Set extent and metadata
            bounds = tile_service.get("bounds")
            if bounds and len(bounds) == 4:
                try:
                    rect = QgsRectangle(bounds[0], bounds[1], bounds[2], bounds[3])
                    src_crs = QgsCoordinateReferenceSystem("EPSG:4326")
                    dest_crs = rOutput.crs()
                    if not dest_crs.isValid():
                        dest_crs = QgsCoordinateReferenceSystem("EPSG:3857")
                        rOutput.setCrs(dest_crs)

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

                    rOutput.setExtent(rect)

                    metadata = rOutput.metadata()
                    metadata.setIdentifier(map_layer.tiles_label)
                    metadata.setTitle(map_layer.tiles_label)

                    spatialExtent = QgsLayerMetadata.SpatialExtent()
                    spatialExtent.extent = QgsReferencedRectangle(rect, dest_crs)

                    extent = QgsLayerMetadata.Extent()
                    extent.setSpatialExtents([spatialExtent])
                    metadata.setExtent(extent)

                    rOutput.setMetadata(metadata)
                    settings.log(f"  - Set extent: {rect.toString()}", Qgis.Info)
                except Exception as e:
                    settings.log(f"Error finalising metadata for raster: {e}", Qgis.Warning)

            # Transparency
            try:
                transparency = int(map_layer.bl_attr.get("transparency", 0))
                if transparency > 0:
                    rOutput.renderer().setOpacity((100 - transparency) / 100.0)
                    settings.log(f"  - Set transparency: {transparency}%", Qgis.Info)
            except Exception as e:
                settings.log(f"Error setting transparency for raster: {e}", Qgis.Warning)

            rOutput.triggerRepaint()
            settings.log(f"Successfully added remote raster layer: {map_layer.tiles_label}", Qgis.Info)
        else:
            settings.log(
                f"Failed to create valid remote raster layer for {map_layer.tiles_label}. isValid() is False.",
                Qgis.Critical,
            )
            # Try a fallback without the provider if it failed with it
            if provider == "gdal":
                settings.log("Retrying without explicit 'gdal' provider...", Qgis.Info)
                rOutput = QgsRasterLayer(uri, map_layer.tiles_label)
                if rOutput and rOutput.isValid():
                    QgsProject.instance().addMapLayer(rOutput, False)
                    parentGroup.insertChildNode(-1, QgsLayerTreeLayer(rOutput))
                    settings.log("Success on retry without provider!", Qgis.Info)
                else:
                    settings.log("Fallback also failed.", Qgis.Critical)

    @staticmethod
    def get_layer_ancestry(layer: QgsMapLayer) -> list[str]:
        root = QgsProject.instance().layerTreeRoot()
        tree_layer = root.findLayer(layer.id())

        lyr_ancestry = []
        if tree_layer:
            layer_parent = tree_layer.parent()
            while layer_parent is not None and layer_parent.name() != "" and len(lyr_ancestry) < 50:
                lyr_ancestry.append(layer_parent.name())
                layer_parent = layer_parent.parent()

        lyr_ancestry.reverse()
        return lyr_ancestry

    @staticmethod
    def remove_project_from_map(project_name: str) -> None:

        root = QgsProject.instance().layerTreeRoot()
        for group in [child for child in root.children() if isinstance(child, QgsLayerTreeGroup)]:
            if group.name() == project_name:
                root.removeChildNode(group)

    @staticmethod
    def add_project_bounds_to_map(bounds_path: str, project_name: str, group_name: str):
        """Add a project bounds GeoJSON layer to the top of the project group.

        Args:
            bounds_path: Absolute path to the GeoJSON file.
            project_name: Display name for the layer (unused, kept for API symmetry).
            group_name: Name of the QGIS layer group to insert into.
        """
        settings = Settings()

        if not bounds_path or not os.path.isfile(bounds_path):
            settings.log(f"Project bounds file not found: {bounds_path}", Qgis.Warning)
            return None

        # Find or create the top-level group
        root = QgsProject.instance().layerTreeRoot()
        group = None
        for child in root.children():
            if isinstance(child, QgsLayerTreeGroup) and child.name() == group_name:
                group = child
                break

        if group is None:
            group = QRaveMapLayer._addgrouptomap(group_name, 0, root)

        # Check whether a 'Project Bounds' layer already exists in this group
        layer_label = "Project Bounds"
        for child in group.children():
            if isinstance(child, QgsLayerTreeLayer):
                lyr = child.layer()
                if lyr is not None and lyr.name() == layer_label:
                    lyr.triggerRepaint()
                    return lyr

        # Create the vector layer
        layer = QgsVectorLayer(bounds_path, layer_label, "ogr")
        if not layer.isValid():
            QgsMessageLog.logMessage(f"Project bounds layer is not valid: {bounds_path}", MESSAGE_CATEGORY, Qgis.Warning)
            return None

        # Load QML symbology if available
        qml_path = os.path.join(SYMBOLOGY_DIR, "project_bounds.qml")
        if os.path.isfile(qml_path):
            layer.loadNamedStyle(qml_path)

        # Add to the map and insert at position 0 (top of the group)
        QgsProject.instance().addMapLayer(layer, False)
        group.insertChildNode(0, QgsLayerTreeLayer(layer))

        return layer
