# -*- 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.TypeVectorLine]
            )
        )

        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()
    
    
