# Source code for marche_a_lombre.marche_a_lombre_algorithm

    # -*- coding: utf-8 -*-

    # /***************************************************************************
    #  MarcheALOmbre
    #                                  A QGIS plugin
    #  This plugin calculates for a given hike the shady and sunny parts
    #  Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
    #                               -------------------
    #         begin                : 2025-12-11
    #         copyright            : (C) 2025 by Yolanda Seifert
    #         email                : yolanda.seifert@gmx.de
    #  ***************************************************************************/

    # /***************************************************************************
    #  *                                                                         *
    #  *   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.                                   *
    #  *                                                                         *
    #  ***************************************************************************/

    __author__ = 'Yolanda Seifert'
    __date__ = '2025-12-11'
    __copyright__ = '(C) 2025 by Yolanda Seifert'

    # This will get replaced with a git SHA1 when you do a git archive

    __revision__ = '$Format:%H$'

    import os
    import inspect
    import math
    from osgeo import gdal
    from qgis.PyQt.QtGui import QIcon
    from qgis.PyQt.QtCore import QCoreApplication, QMetaType
    from qgis.core import (QgsProcessing,
                            QgsFeatureSink,
                            QgsProcessingAlgorithm,
                            QgsProcessingParameterFeatureSource,
                            QgsProcessingParameterFeatureSink,
                            QgsProcessingParameterDateTime,
                            QgsProcessingParameterNumber,
                            QgsProcessingParameterPoint,
                            QgsProcessingParameterBoolean,
                            QgsPoint,
                            QgsProcessingException,
                            QgsProcessingParameterRasterDestination,
                            QgsProcessingParameterFileDestination,
                            QgsCoordinateReferenceSystem,
                            QgsFields,
                            QgsWkbTypes,
                            QgsFeature,
                            QgsGeometry,
                            QgsField,
                            QgsProcessingUtils,
                            QgsCategorizedSymbolRenderer,
                            QgsRendererCategory,
                            QgsSymbol,
                            QgsSimpleMarkerSymbolLayer,
                            QgsVectorLayer,
                            QgsProperty,
                            QgsSymbolLayer)

    from .mns_downloader import MNSDownloader
    from .trail import Trail
    from .shadow_calculator import ShadowCalculator

    class MarcheALOmbreAlgorithm(QgsProcessingAlgorithm):
        """
        This is an example algorithm that takes a vector layer and
        creates a new identical one.

        It is meant to be used as an example of how to create your own
        algorithms and explain methods and variables used to do it. An
        algorithm like this will be available in all elements, and there
        is not need for additional work.

        All Processing algorithms should extend the QgsProcessingAlgorithm
        class.
        """

        # Constants used to refer to parameters and outputs. They will be
        # used when calling the algorithm from another algorithm, or when
        # calling from the QGIS console.

        OUTPUT = 'OUTPUT'
        INPUT = 'INPUT'
        DEPARTURE_TIME = 'DEPARTURE_TIME'
        HIKING_SPEED = 'HIKING_SPEED'
        ADJUST_FOR_SLOPE = 'ADJUST_FOR_SLOPE'
        PICNIC_POINT = 'PICNIC_POINT'
        PICNIC_DURATION = 'PICNIC_DURATION'
        REVERSE_DIRECTION = 'REVERSE_DIRECTION'
        BUFFER_MODE = 'BUFFER_MODE'
        OUTPUT_POINTS = 'OUTPUT_POINTS'
        LOW_RES_MNS = 'LOW_RES_MNS'
        OUTPUT_CSV = 'OUTPUT_CSV'

        def initAlgorithm(self, config):
            """
            Here we define the inputs and output of the algorithm, along
            with some other properties.
            """

            # We add the input vector features source. It can have any kind of
            # geometry.
            self.addParameter(
                QgsProcessingParameterFeatureSource(
                    self.INPUT,
                    self.tr('Input layer - tracks'),
                    [QgsProcessing.TypeVectorAnyGeometry]
                )
            )

            self.addParameter(
                QgsProcessingParameterDateTime(
                    self.DEPARTURE_TIME,
                    self.tr('Departure Date and Time (Local Time)'),
                    type=QgsProcessingParameterDateTime.DateTime  # Allows selecting both Date and Time
                )
            )

            self.addParameter(
                QgsProcessingParameterNumber(
                    self.HIKING_SPEED,
                    self.tr('Average Hiking Speed (km/h)'),
                    type=QgsProcessingParameterNumber.Double,
                    defaultValue=4.0,  # A standard hiking speed
                    minValue=0.0
                )
            )

            self.addParameter(
                QgsProcessingParameterBoolean(
                    'ADJUST_FOR_SLOPE',
                    self.tr('Adjust hiking speed for slope (recommended for mountainous regions)'),
                    defaultValue=False
                )
            )

            self.addParameter(
                QgsProcessingParameterPoint(
                    self.PICNIC_POINT,
                    self.tr('Picnic Break Location (1h stop)'),
                    optional=True  # Optional: user might not want a break
                )
            )

            self.addParameter(
                QgsProcessingParameterNumber(
                    self.PICNIC_DURATION,
                    self.tr('Picnic Duration (minutes)'),
                    type=QgsProcessingParameterNumber.Integer,
                    defaultValue=60,
                    minValue=0,
                    optional=True
                )
            )

            self.addParameter(
                QgsProcessingParameterBoolean(
                    self.REVERSE_DIRECTION,
                    self.tr('Reverse Trail Direction (Finish to Start)'),
                    defaultValue=False
                )
            )

            self.addParameter(
                QgsProcessingParameterBoolean(
                    self.BUFFER_MODE,
                    self.tr('Calculate with Buffer (Center, Left 5m, Right 5m)'),
                    defaultValue=False
                )
            )

            self.addParameter(
                QgsProcessingParameterFeatureSink(
                    self.OUTPUT_POINTS,
                    self.tr('Densified Trail Points'),
                    type=QgsProcessing.TypeVectorPoint
                )
            )

            self.addParameter(
                QgsProcessingParameterRasterDestination(
                    self.LOW_RES_MNS,
                    self.tr('Low Resolution MNS (15m)')
                )
            )

            self.addParameter(
                QgsProcessingParameterRasterDestination(
                    self.OUTPUT,
                    self.tr('High Resolution MNS (0.5m)')
                )
            )

            self.addParameter(
                QgsProcessingParameterFileDestination(
                    self.OUTPUT_CSV,
                    self.tr('Statistics Table'),
                    fileFilter='CSV files (*.csv)'
                )
            )

        def processAlgorithm(self, parameters, context, feedback):
            """
            Here is where the processing itself takes place.
            """

            # Retrieve the feature source and sink. The 'dest_id' variable is used
            # to uniquely identify the feature sink, and must be included in the
            # dictionary returned by the processAlgorithm function.
            source = self.parameterAsSource(parameters, self.INPUT, context)
            departure_dt = self.parameterAsDateTime(parameters, self.DEPARTURE_TIME, context)
            speed = self.parameterAsDouble(parameters, self.HIKING_SPEED, context)
            adjust_for_slope = self.parameterAsBool(parameters, 'ADJUST_FOR_SLOPE', context)
            picnic_point = self.parameterAsPoint(parameters, self.PICNIC_POINT, context)
            picnic_duration = self.parameterAsDouble(parameters, self.PICNIC_DURATION, context)
            picnic_point_crs = self.parameterAsPointCrs(parameters, self.PICNIC_POINT, context)
            reverse_direction = self.parameterAsBool(parameters, self.REVERSE_DIRECTION, context)
            buffer_mode = self.parameterAsBool(parameters, self.BUFFER_MODE, context)
            csv_path = self.parameterAsFileOutput(parameters, self.OUTPUT_CSV, context)

            if not picnic_point or (picnic_point.x() == 0.0 and picnic_point.y() == 0.0):
                picnic_point_crs = None

            # Compute the number of steps to display within the progress bar and
            # get features from source
            total = 100.0 / source.featureCount() if source.featureCount() else 0
            features = source.getFeatures()

            ########################## TRAIL PROCESSING #########################
            feedback.pushInfo("Processing trail...")
            source_crs = source.sourceCrs()
            if not source_crs.isValid():
                raise QgsProcessingException(
                    "Input layer has no valid CRS. Please define the layer CRS before running this algorithm."
                )

            trail = Trail(
                max_sep=10,
                speed=speed,
                source_crs=source_crs,
                transform_context=context.transformContext(),
                feedback=feedback
            )
            trail.process_trail(source_tracks=source,
                                start_time=departure_dt,
                                break_point=picnic_point,
                                picnic_duration=picnic_duration,
                                reverse=reverse_direction,
                                buffer=buffer_mode,
                                project_crs=picnic_point_crs,
                                adjust_for_slope=adjust_for_slope)

            ########################## MNT DOWNLOAD (For Z Values) ##################
            feedback.pushInfo(f"Download Digital Terrain Model (MNT) from IGN...")
            # Generate a temporary file path for the MNT
            mnt_path = QgsProcessingUtils.generateTempFilename('mnt_elevation.tif')
            target_crs = trail.target_crs
            downloader = MNSDownloader(
                crs=target_crs,
                transform_context=context.transformContext(),
                feedback=feedback)
            target_resolution = 0.5
            # Download MNT (mns=False)
            success_mnt = downloader.read_tif(
                extent=trail.extent,
                resolution=target_resolution*2, # less variation in trail elevation so lower resolution necessary
                output_path=mnt_path,
                input_crs=target_crs,
                is_mns=False
            )

            if not success_mnt:
                raise QgsProcessingException("Failed to download MNT data.")

            # Integrate MNT into Trail (Sample Z values)
            feedback.pushInfo("Using MNT for trail point elevation values...")
            trail.sample_elevation(mnt_path, departure_dt, buffer_mode)

            ########################## MNS DOWNLOAD (for Shade) ############################
            feedback.pushInfo(f"Download Digital Surface Model (MNS) from IGN...")
            output_path = self.parameterAsOutputLayer(parameters, self.OUTPUT, context)
            low_res_path = self.parameterAsOutputLayer(parameters, self.LOW_RES_MNS, context)

            downloader = MNSDownloader(
                crs=target_crs,
                transform_context=context.transformContext(),
                feedback=feedback)

            downloader.download_dual_quality_mns(
                trail_extent=trail.extent,
                high_res_path=output_path,
                low_res_path=low_res_path,
                trail_lat=trail.center_lat,
                input_crs=target_crs,
                high_res=target_resolution, # meters per pixel
            )

            # Open MNS raster with GDAL
            ds = gdal.Open(output_path)
            if ds is None:
                raise QgsProcessingException("Could not open downloaded MNS.")

            mns_band = ds.GetRasterBand(1)
            mns_array = mns_band.ReadAsArray() # Returns numpy array
            geo_transform = ds.GetGeoTransform()

            ds = None # Close dataset

            ########################## CALCULATE SHADOWS ############################
            feedback.pushInfo("Calculating shadows...")

            calculator = ShadowCalculator(
                high_res_path=output_path,
                low_res_path=low_res_path
            )

            shadow_results = calculator.calculate_shadows(
                trail_points=trail.trail_points,
                max_dist_m=20000
            )

            ########################## CALCULATE STATISTICS ############################
            total_points = len(shadow_results)
            shady_points = sum(shadow_results)
            sunny_points = total_points - shady_points

            percent_shady = (shady_points / total_points * 100) if total_points > 0 else 0.0
            percent_sunny = (sunny_points / total_points * 100) if total_points > 0 else 0.0

            feedback.pushInfo(f"Statistics: {percent_shady:.1f}% Shady, {percent_sunny:.1f}% Sunny")

            if csv_path:
                try:
                    with open(csv_path, 'w') as f:
                        # Header
                        f.write("Duration (min), % Shady, % Sunny, Time spent in Shadow (min), Time spent in Sun (min), Shady Points, Sunny Points, Total Points\n")

                        duration_min = 0
                        if trail.trail_points:
                            start = trail.trail_points[0].datetime
                            end = trail.trail_points[-1].datetime
                            duration_min = start.secsTo(end) / 60
                        time_shady = int(duration_min*percent_shady/100)
                        time_sunny = int(duration_min*percent_sunny/100)

                        f.write(f"{duration_min:.1f},{percent_shady:.2f},{percent_sunny:.2f},{time_shady},{time_sunny},{shady_points},{sunny_points},{total_points}\n")
                except Exception as e:
                    feedback.reportError(f"Failed to write CSV: {e}")

            ########################## WRITE OUTPUT POINTS ##########################
            # Define attribute table columns
            fields = QgsFields()
            fields.append(QgsField("id", QMetaType.Int))
            fields.append(QgsField("status", QMetaType.QString))    # Sunny / Shady
            fields.append(QgsField("is_shadow", QMetaType.Int))     # 0 / 1
            fields.append(QgsField("x_proj", QMetaType.Double))     # Lambert-93 X
            fields.append(QgsField("y_proj", QMetaType.Double))     # Lambert-93 Y
            fields.append(QgsField("z_mnt", QMetaType.Double))      # Altitude
            fields.append(QgsField("latitude", QMetaType.Double))   # WGS84 Lat
            fields.append(QgsField("longitude", QMetaType.Double))  # WGS84 Lon
            fields.append(QgsField("arrival_time", QMetaType.QDateTime)) # Time
            fields.append(QgsField("elevation_deg", QMetaType.Double)) # Sun elevation angle
            fields.append(QgsField("azimuth_deg", QMetaType.Double)) # Sun azimtuh angle
            fields.append(QgsField("course", QMetaType.Double))

            (point_sink, point_dest_id) = self.parameterAsSink(
                parameters,
                self.OUTPUT_POINTS,
                context,
                fields,
                QgsWkbTypes.PointZ,
                QgsCoordinateReferenceSystem(target_crs)
            )
            self.point_dest_id = point_dest_id

            if point_sink is None:
                raise QgsProcessingException("Could not create point sink")

            feedback.pushInfo("Writing results...")
            for i, tp in enumerate(trail.trail_points):
                if feedback.isCanceled(): break

                # Calculate direction to next point for visualization
                course = 0.0
                if i < len(trail.trail_points) - 1:
                    next_tp = trail.trail_points[i+1]
                    course = QgsPoint(tp.x, tp.y).azimuth(QgsPoint(next_tp.x, next_tp.y))
                elif i > 0:
                    course = prev_course
                prev_course = course

                feat = QgsFeature(fields)
                geom = QgsGeometry.fromPoint(QgsPoint(tp.x, tp.y, tp.z))
                feat.setGeometry(geom)

                is_shadow = shadow_results[i]
                status_str = "Shadow" if is_shadow == 1 else "Sun"

                feat.setAttributes([
                    i,
                    status_str,
                    int(is_shadow),
                    tp.x,
                    tp.y,
                    tp.z,
                    tp.lat,
                    tp.lon,
                    tp.datetime,
                    math.degrees(tp.solar_pos[0]),
                    math.degrees(tp.solar_pos[1]),
                    course
                ])
                point_sink.addFeature(feat, QgsFeatureSink.FastInsert)
                feedback.setProgress(int(i * total))

            # Return the results of the algorithm. In this case our only result is
            # the feature sink which contains the processed features, but some
            # algorithms may return multiple feature sinks, calculated numeric
            # statistics, etc. These should all be included in the returned
            # dictionary, with keys matching the feature corresponding parameter
            # or output names.
            self.results = {
                self.OUTPUT: output_path,
                self.OUTPUT_POINTS: point_dest_id,
                self.LOW_RES_MNS: low_res_path,
                self.OUTPUT_CSV: csv_path,
                'detected_crs': target_crs
            }
            return self.results

        def postProcessAlgorithm(self, context, feedback):
            # Set project CRS to match the detected region
            detected_crs_str = self.results.get('detected_crs')
            if detected_crs_str:
                detected_crs = QgsCoordinateReferenceSystem(detected_crs_str)
                project = context.project()

                if detected_crs.isValid() and project.crs().authid() != detected_crs.authid():
                    feedback.pushInfo(f"Changing project CRS from {project.crs().authid()} to {detected_crs.authid()} to match detected region")
                    project.setCrs(detected_crs)
            # style the output trail depending on Sun/Shadow
            layer = QgsProcessingUtils.mapLayerFromString(self.point_dest_id, context)

            if layer:
                categories = []
                styles = [
                    (0, "Sun", "gold"),
                    (1, "Shadow", "#1f78b4")
                ]

                for val, label, color in styles:
                    sym_layer = QgsSimpleMarkerSymbolLayer.create({
                        'name': 'filled_arrowhead',
                        'color': color,
                        'size': '2.5',
                    })

                    # Direction of arrow based on course field
                    prop_angle = QgsProperty.fromExpression('"course" - 90')
                    sym_layer.setDataDefinedProperty(QgsSymbolLayer.PropertyAngle, prop_angle)

                    sym = QgsSymbol.defaultSymbol(layer.geometryType())
                    sym.changeSymbolLayer(0, sym_layer)
                    categories.append(QgsRendererCategory(val, sym, label, True))

                renderer = QgsCategorizedSymbolRenderer("is_shadow", categories)
                layer.setRenderer(renderer)
                layer.triggerRepaint()

            csv_path = self.results.get(self.OUTPUT_CSV)
            if csv_path and os.path.exists(csv_path):
                # Create delimited text layer from csv
                path = csv_path.replace('\\', '/')
                uri = f"file:///{path}?type=csv&watchFile=no"

                vlayer = QgsVectorLayer(uri, "Shadow Statistics", "delimitedtext")
                if vlayer.isValid():
                    context.project().addMapLayer(vlayer)
            return self.results

        def helpUrl(self):
            """
            Returns the URL to the help page
            """
            cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
            help_path = os.path.join(cmd_folder, 'help', 'build', 'html', 'index.html')

            if os.path.exists(help_path):
                return "file://" + help_path

            # Fallback if local help isn't built yet
            return "https://github.com/yolanda225/qgis-marche-a-lombre"

        def shortHelpString(self):
            """
            Returns a brief description of the algorithm
            (appears in the right-hand panel of the dialog)
            """
            return self.tr("This algorithm calculates the shady and sunny portions of a trail "
                           "based on LiDAR HD data (MNS) and the sun's position at the time "
                           "you are physically at each point on the hike.")

        def name(self):
            """
            Returns the algorithm name, used for identifying the algorithm. This
            string should be fixed for the algorithm, and must not be localised.
            The name should be unique within each provider. Names should contain
            lowercase alphanumeric characters only and no spaces or other
            formatting characters.
            """
            return "marche_a_lombre"

        def displayName(self):
            """
            Returns the translated algorithm name, which should be used for any
            user-visible display of the algorithm name.
            """
            return self.tr(self.name())

        def group(self):
            """
            Returns the name of the group this algorithm belongs to. This string
            should be localised.
            """
            return self.tr(self.groupId())

        def groupId(self):
            """
            Returns the unique ID of the group this algorithm belongs to. This
            string should be fixed for the algorithm, and must not be localised.
            The group id should be unique within each provider. Group id should
            contain lowercase alphanumeric characters only and no spaces or other
            formatting characters.
            """
            return ''

        def tr(self, string):
            return QCoreApplication.translate('Processing', string)

        def icon(self):
            cmd_folder = os.path.split(inspect.getfile(inspect.currentframe()))[0]
            icon = QIcon(os.path.join(os.path.join(cmd_folder, 'logo.png')))
            return icon

        def createInstance(self):
            return MarcheALOmbreAlgorithm()