import json
import math
from typing import List
from collections import namedtuple

import numpy as np
from qgis.core import (
    QgsFeature,
    QgsFeatureRequest,
    QgsFeatureSource,
    QgsGeometry,
    QgsLineString,
    QgsProcessing,
    QgsProcessingException,
    QgsProcessingParameterBoolean,
    QgsProcessingParameterFeatureSource,
    QgsProcessingParameterField,
    QgsProcessingParameterNumber,
    QgsProcessingParameterString,
    QgsProcessingParameterVectorLayer,
)

from PreCourlis.core import to_float
from PreCourlis.core.precourlis_file import PreCourlisFileLine
from PreCourlis.processing.precourlis_algorithm import PreCourlisAlgorithm


class ImportLayerFromProfilesAlgorithm(PreCourlisAlgorithm):
    TARGET_PROFILES = "TARGET_PROFILES"
    TARGET_LAYER_NAME = "TARGET_LAYER_NAME"
    SOURCE_PROFILES = "SOURCE_PROFILES"
    SOURCE_FIELD = "SOURCE_FIELD"
    IMPORT_SOURCE_POINTS = "IMPORT_SOURCE_POINTS"
    MASK = "MASK"
    POINTS_FILTER = "POINTS_FILTER"
    DEFAULT_ELEVATION = "DEFAULT_ELEVATION"

    PROJECTED_PROFILES = "PROJECTED_PROFILES"

    def initAlgorithm(self, config=None):
        self.addParameter(
            QgsProcessingParameterVectorLayer(
                self.TARGET_PROFILES,
                self.tr("Target profiles"),
                types=[QgsProcessing.TypeVectorLine],
                defaultValue=None,
            )
        )
        self.addParameter(
            QgsProcessingParameterString(
                self.TARGET_LAYER_NAME,
                self.tr("Target layer to fill"),
                # parentLayerParameterName=self.TARGET_PROFILES,
                defaultValue=None,
            )
        )
        self.addParameter(
            QgsProcessingParameterFeatureSource(
                self.SOURCE_PROFILES, self.tr("Source profiles"), defaultValue=None
            )
        )
        self.addParameter(
            QgsProcessingParameterField(
                self.SOURCE_FIELD,
                self.tr("Source layer field"),
                parentLayerParameterName=self.SOURCE_PROFILES,
                defaultValue=None,
            )
        )
        self.addParameter(
            QgsProcessingParameterBoolean(
                self.IMPORT_SOURCE_POINTS,
                self.tr("Import source points"),
                defaultValue=False,
            )
        )
        self.addParameter(
            QgsProcessingParameterFeatureSource(
                self.MASK,
                self.tr("Mask"),
                types=[QgsProcessing.TypeVectorPolygon],
                optional=True,
            )
        )
        self.addParameter(
            QgsProcessingParameterString(
                self.POINTS_FILTER,
                self.tr(
                    "Points filter (formatted as a json array or arrays : [ [sec_id, p_id], ... ])"
                ),
                optional=True,
            )
        )
        self.addParameter(
            QgsProcessingParameterNumber(
                self.DEFAULT_ELEVATION,
                self.tr(
                    "Default elevation (the default value when there is no Z value to extract)"
                ),
                QgsProcessingParameterNumber.Double,
                optional=True,
            )
        )
        """
        self.addParameter(
            QgsProcessingParameterFeatureSink(
                self.PROJECTED_PROFILES,
                self.tr("Projected source profiles"),
                optional=True,
                createByDefault=False,
            )
        )
        """

    def get_nearest_source_profiles(
        self, target_profile: QgsFeature, source_profiles: QgsFeatureSource
    ) -> List[QgsFeature]:
        # Search for intersecting profiles
        request = QgsFeatureRequest()
        request.setFilterExpression(
            f"intersects($geometry, geom_from_wkt('{target_profile.geometry().asWkt()}'))"
        )
        features = list(source_profiles.getFeatures(request))
        if len(features) > 0:
            return features

        # Search for ones intersecting buffer around target profile
        request = QgsFeatureRequest()
        request.setFilterExpression(
            f"intersects($geometry,"
            f" geom_from_wkt('{target_profile.geometry().buffer(1.0, segments=8).asWkt()}'))"
        )
        features = list(source_profiles.getFeatures(request))
        return features

    def processAlgorithm(self, parameters, context, feedback):
        target_profiles_layer = self.parameterAsVectorLayer(
            parameters, self.TARGET_PROFILES, context
        )
        target_layer_name = self.parameterAsString(
            parameters, self.TARGET_LAYER_NAME, context
        )
        source_profiles = self.parameterAsSource(
            parameters, self.SOURCE_PROFILES, context
        )
        source_field_z = self.parameterAsString(parameters, self.SOURCE_FIELD, context)
        mask = self.parameterAsSource(parameters, self.MASK, context)

        points_filter = self.parameterAsString(parameters, self.POINTS_FILTER, context)
        import_source_points = self.parameterAsBool(
            parameters, self.IMPORT_SOURCE_POINTS, context
        )
        has_filtering_points = len(points_filter) > 0
        if has_filtering_points:
            filtering_points = [
                [sec_id, p_id] for sec_id, p_id in json.loads(points_filter)
            ]
        else:
            filtering_points = []

        if parameters.get(self.DEFAULT_ELEVATION, None) is not None:
            default_elevation = self.parameterAsDouble(
                parameters, self.DEFAULT_ELEVATION, context
            )
        else:
            default_elevation = None

        # This algorithm directly edit input target_profiles_layer in place using edit buffer
        target_profiles_layer.startEditing()
        target_profiles_layer.beginEditCommand("Set values from other profiles")

        target_field_index = target_profiles_layer.fields().indexFromName(
            target_layer_name
        )
        if target_field_index == -1:
            file = PreCourlisFileLine(target_profiles_layer)
            file.add_sedimental_layer(target_layer_name)
            target_field_index = target_profiles_layer.fields().indexFromName(
                target_layer_name
            )

        total = (
            100.0 / target_profiles_layer.featureCount()
            if target_profiles_layer.featureCount()
            else 0
        )
        for current, target_profile in enumerate(target_profiles_layer.getFeatures()):
            # Stop the algorithm if cancel button has been clicked
            if feedback.isCanceled():
                break

            nearest_source_profiles = self.get_nearest_source_profiles(
                target_profile, source_profiles
            )
            if len(nearest_source_profiles) == 0:
                raise QgsProcessingException(
                    f"No corresponding profile found"
                    f" for target profile {target_profile.attribute('sec_name')}"
                )
            if len(nearest_source_profiles) > 1:
                raise QgsProcessingException(
                    f"Multiple profiles found"
                    f" for target profile {target_profile.attribute('sec_name')}"
                )
            source_profile = nearest_source_profiles[0]

            source_line: QgsLineString = next(
                source_profile.geometry().constParts()
            ).clone()

            target_line: QgsLineString = next(
                target_profile.geometry().constParts()
            ).clone()

            # TODO: Add distance parameter for the extend ?
            support_line: QgsLineString = target_line.clone()
            support_line.extend(1000, 1000)
            support_geom = QgsGeometry(support_line)

            target_distances = np.array(
                [
                    float(support_geom.lineLocatePoint(QgsGeometry(target_point)))
                    for target_point in target_line.points()
                ]
            )
            source_projected_distances = np.array(
                [
                    float(support_geom.lineLocatePoint(QgsGeometry(source_point)))
                    for source_point in source_line.points()
                ]
            )
            source_values = np.array(
                [
                    to_float(v) or np.nan
                    for v in source_profile.attribute(source_field_z).split(",")
                ]
            )

            # Remove empty source points
            nans = np.isnan(source_values)
            source_projected_distances = source_projected_distances[~nans]
            source_values = source_values[~nans]

            if len(source_values) == 0:
                values = np.array([np.nan for p in target_line.points()])
            else:
                values = np.interp(
                    target_distances,
                    source_projected_distances,
                    source_values,
                    left=np.nan,
                    right=np.nan,
                )

            # Replace nan by initial values
            initial_values = np.array(
                [
                    to_float(v) or np.nan
                    for v in target_profile.attribute(target_field_index).split(",")
                ]
            )
            values[np.isnan(values)] = initial_values[np.isnan(values)]

            # Apply default elevation
            if default_elevation:
                values = np.nan_to_num(values, nan=default_elevation)

            # Filter import with filtering_points and mask
            import_mask = np.array([True for p in target_line.points()])

            # with filtering_points
            if filtering_points:
                sec_id = int(target_profile.attribute("sec_id"))
                target_p_ids = [
                    int(s) for s in target_profile.attribute("p_id").split(",")
                ]
                import_mask = import_mask & np.array(
                    [[sec_id, p_id] in filtering_points for p_id in target_p_ids]
                )

            # with mask
            if mask:
                import_mask = import_mask & np.array(
                    [
                        any(
                            [
                                feature.geometry().contains(QgsGeometry(point.clone()))
                                for feature in mask.getFeatures()
                            ]
                        )
                        for point in target_line.points()
                    ]
                )
            values = np.where(
                import_mask,
                values,
                initial_values,
            )

            if not import_source_points:
                # Update target layer
                value = ",".join(
                    [str(v) if not np.isnan(v) else "NULL" for v in values]
                )
                target_profiles_layer.changeAttributeValue(
                    target_profile.id(), target_field_index, value
                )

            else:
                # Combine source and target profiles together
                # Does not filtering points

                Point = namedtuple(
                    "Point", ["distance", "point", "topo_bat", "elevation"]
                )

                source_points = map(
                    Point,
                    source_projected_distances,
                    [
                        support_line.interpolatePoint(d)
                        for d in source_projected_distances
                    ],
                    source_profile.attribute("topo_bat").split(","),
                    [
                        to_float(v)
                        for v in source_profile.attribute(source_field_z).split(",")
                    ],
                )

                # exclude points already in target
                filtered_source_points = [
                    point
                    for point in source_points
                    if not any(
                        [math.isclose(point.distance, d) for d in target_distances]
                    )
                ]

                # exclude points outside mask
                if mask:
                    filtered_source_points = [
                        p
                        for p in filtered_source_points
                        if any(
                            [
                                feature.geometry().contains(
                                    QgsGeometry(p.point.clone())
                                )
                                for feature in mask.getFeatures()
                            ]
                        )
                    ]

                all_distances = np.concatenate(
                    [
                        target_distances,
                        [p.distance for p in filtered_source_points],
                    ]
                )
                permutation = all_distances.argsort()
                all_distances = all_distances[permutation]

                # geometry
                points = np.array(
                    target_line.points() + [p.point for p in filtered_source_points]
                )[permutation]
                geometry = QgsGeometry(QgsLineString([p.clone() for p in points]))
                target_profiles_layer.changeGeometry(target_profile.id(), geometry)

                # attributes
                abs_lat = [
                    geometry.lineLocatePoint(QgsGeometry(p.clone())) for p in points
                ]
                topo_bat = np.array(
                    target_profile.attribute("topo_bat").split(",")
                    + [p.topo_bat for p in filtered_source_points]
                )[permutation]

                attributes = {
                    "p_id": ",".join([str(i) for i in range(len(all_distances))]),
                    "topo_bat": ",".join(topo_bat),
                    "abs_lat": ",".join([str(v) for v in abs_lat]),
                    # "zfond": ",".join(zfond),
                    # layers
                }

                layers = target_profile.attribute("layers").split(",")
                for field in ["zfond"] + layers:
                    if target_layer_name == field:
                        # Interpolate target values
                        layer_values = np.concatenate(
                            (
                                values,
                                np.array([p.elevation for p in filtered_source_points]),
                            )
                        )[permutation]

                    else:
                        # Interpolate source values
                        target_layer_values = np.array(
                            [
                                to_float(v) or np.nan
                                for v in target_profile.attribute(field).split(",")
                            ]
                        )

                        # Remove empty target points
                        nans = np.isnan(target_layer_values)
                        target_layer_distances = target_distances[~nans]
                        target_layer_values = target_layer_values[~nans]

                        layer_values = np.concatenate(
                            (
                                np.array(
                                    [
                                        to_float(v) or np.nan
                                        for v in target_profile.attribute(field).split(
                                            ","
                                        )
                                    ]
                                ),
                                np.interp(
                                    np.array(
                                        [p.distance for p in filtered_source_points]
                                    ),
                                    target_layer_distances,
                                    target_layer_values,
                                    left=np.nan,
                                    right=np.nan,
                                ),
                            )
                        )[permutation]

                    layer_value = ",".join(
                        [str(v) if not np.isnan(v) else "NULL" for v in layer_values]
                    )
                    attributes[field] = layer_value

                # Update layer
                attributeMap = {}
                for field_name, value in attributes.items():
                    field_index = target_profiles_layer.fields().indexFromName(
                        field_name
                    )
                    attributeMap[field_index] = value

                target_profiles_layer.changeGeometry(target_profile.id(), geometry)
                target_profiles_layer.changeAttributeValues(
                    target_profile.id(), attributeMap
                )

            # Update the progress bar
            feedback.setProgress(int(current * total))

        target_profiles_layer.endEditCommand()

        return {}

    def name(self):
        return "import_layer_from_profiles"

    def displayName(self):
        return self.tr("Import layer from profiles")

    def group(self):
        return self.tr("Import")

    def groupId(self):
        return "Import"

    def createInstance(self):
        return ImportLayerFromProfilesAlgorithm()

    def shortHelpString(self):
        return self.tr(
            "This algorithm fill or create a new sedimental layer interface"
            " in target profiles from another profiles layer."
        )
