"""
Author: Michel Peltriaux
Organization: Struktur- und Genehmigungsdirektion Nord, Rhineland-Palatinate, Germany
Contact: michel.peltriaux@sgdnord.rlp.de
Created on: 15.11.21

"""
import json

from django.contrib.gis.db.models import MultiPolygonField
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone
from django.contrib.gis.geos import MultiPolygon

from konova.models import BaseResource, UuidModel
from konova.sub_settings.lanis_settings import DEFAULT_SRID_RLP
from konova.utils.schneider.fetcher import ParcelFetcher


class Geometry(BaseResource):
    """
    Geometry model
    """
    parcel_update_start = models.DateTimeField(
        blank=True,
        null=True,
        db_comment="When the last parcel calculation started",
        help_text="When the last parcel calculation started"
    )
    parcel_update_end = models.DateTimeField(
        blank=True,
        null=True,
        db_comment="When the last parcel calculation finished",
        help_text="When the last parcel calculation finished",
    )
    geom = MultiPolygonField(null=True, blank=True, srid=DEFAULT_SRID_RLP)

    def __str__(self):
        return str(self.id)

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

    @property
    def geom_small_buffered(self):
        """
        Returns a smaller buffered version of the geometry.
        Can be used to shrink the geometry used for intersection purposes to avoid intersection detection on
        neighbouring geometries.

        Returns:

        """
        return self.geom.buffer(-0.001)

    def check_for_conflicts(self):
        """ Checks for new geometry overlaps

        Creates a new GeometryConflict entry for each overlap to another geometry, which has already been there before

        Returns:

        """
        # If no geometry is given or important data is missing, we can not perform any checks
        if self.geom is None:
            return None

        self.recheck_existing_conflicts()
        overlapping_geoms = Geometry.objects.filter(
            geom__intersects=self.geom_small_buffered,
        ).exclude(
            id=self.id
        ).distinct()

        for match in overlapping_geoms:
            # Make sure this conflict is not already known but in a swapped constellation
            conflict_exists_swapped = GeometryConflict.objects.filter(conflicting_geometry=match, affected_geometry=self).exists()
            if not conflict_exists_swapped:
                GeometryConflict.objects.get_or_create(conflicting_geometry=self, affected_geometry=match)

    def recheck_existing_conflicts(self):
        """ Rechecks GeometryConflict entries

        If a conflict seems to be resolved due to no longer intersection between the two geometries, the entry
        will be deleted.

        Returns:

        """
        all_conflicts_as_conflicting = self.conflicts_geometries.all()
        still_conflicting_conflicts = all_conflicts_as_conflicting.filter(
            affected_geometry__geom__intersects=self.geom_small_buffered
        )
        resolved_conflicts = all_conflicts_as_conflicting.exclude(id__in=still_conflicting_conflicts)
        resolved_conflicts.delete()

        all_conflicted_by_conflicts = self.conflicted_by_geometries.all()
        still_conflicting_conflicts = all_conflicted_by_conflicts.filter(
            conflicting_geometry__geom__intersects=self.geom_small_buffered
        )
        resolved_conflicts = all_conflicted_by_conflicts.exclude(id__in=still_conflicting_conflicts)
        resolved_conflicts.delete()

    def get_data_objects(self, limit_to_attrs: list = None):
        """ Getter for all objects which are related to this geometry

        Using the limit_to_attrs we can limit the amount of returned data directly onto the data object attributes
        we want to have. Reduces memory consumption and runtime.

        Returns:
            objs (list): The list of objects
        """
        objs = []
        stop_searching = False
        # Some related data sets can be processed rather easily
        regular_sets = [
            self.intervention_set,
            self.ema_set,
            self.ecoaccount_set,
        ]
        for _set in regular_sets:
            set_objs = _set.filter(
                deleted=None
            )
            if limit_to_attrs:
                objs += set_objs.values(*limit_to_attrs)
            else:
                objs += set_objs

            stop_searching = len(objs) > 0
            if stop_searching:
                break

        if not stop_searching:
            # ... but we need a special treatment for compensations, since they can be deleted directly OR inherit their
            # de-facto-deleted status from their deleted parent intervention
            comp_objs = self.compensation_set.filter(
                Q(deleted=None) | Q(intervention__deleted=None)
            )
            if limit_to_attrs:
                objs += comp_objs.values(*limit_to_attrs)
            else:
                objs += comp_objs
        return objs

    def get_data_object(self, limit_to_attrs: list = None):
        """
        Getter for the specific data object which is related to this geometry.

        !!! Only returns undeleted entries !!!

        Returns:
            result (str|None): Returns the desired attributes or None if the data object is marked as deleted

        """
        objs = self.get_data_objects(limit_to_attrs)
        assert (len(objs) <= 1)
        try:
            result = objs.pop()
        except IndexError:
            # If this happens, we just processed a GeometryConflict with an entry which is marked as deleted.
            # Therefore we return None
            result = None
        return result

    def update_parcels(self):
        """ Updates underlying parcel information

        Returns:

        """
        if self.geom.empty:
            # Nothing to do
            return

        self._set_parcel_update_start_time()
        self._perform_parcel_update()
        self._set_parcel_update_end_time()

    def _perform_parcel_update(self):
        """
        Performs the main logic of parcel updating.
        """
        from konova.models import Parcel, District, Municipal, ParcelGroup

        parcel_fetcher = ParcelFetcher(
            geometry=self
        )
        fetched_parcels = parcel_fetcher.get_parcels()
        _now = timezone.now()

        districts = {}
        municipals = {}
        parcel_groups = {}

        parcels_to_update = []
        parcels_to_create = []
        for result in fetched_parcels:
            # There could be parcels which include the word 'Flur',
            # which needs to be deleted and just keep the numerical values
            ## THIS CAN BE REMOVED IN THE FUTURE, WHEN 'Flur' WON'T OCCUR ANYMORE!
            flr_val = result["flur"].replace("Flur ", "")

            # Get district (cache in dict)
            try:
                district = districts["kreisschl"]
            except KeyError:
                district = District.objects.get_or_create(
                    key=result["kreisschl"],
                    name=result["kreis"],
                )[0]
                districts[district.key] = district

            # Get municipal (cache in dict)
            try:
                municipal = municipals["gmdschl"]
            except KeyError:
                municipal = Municipal.objects.get_or_create(
                    key=result["gmdschl"],
                    name=result["gemeinde"],
                    district=district,
                )[0]
                municipals[municipal.key] = municipal

            # Get parcel group (cache in dict)
            try:
                parcel_group = parcel_groups["gemaschl"]
            except KeyError:
                parcel_group = ParcelGroup.objects.get_or_create(
                    key=result["gemaschl"],
                    name=result["gemarkung"],
                    municipal=municipal,
                )[0]
                parcel_groups[parcel_group.key] = parcel_group

            # Preprocess parcel data
            flrstck_nnr = result['flstnrnen']
            match flrstck_nnr:
                case "":
                    flrstck_nnr = None

            flrstck_zhlr = result['flstnrzae']
            match flrstck_zhlr:
                case "":
                    flrstck_zhlr = None

            try:
                # Try to fetch parcel from db. If it already exists, just update timestamp.
                parcel_obj = Parcel.objects.get(
                    district=district,
                    municipal=municipal,
                    parcel_group=parcel_group,
                    flr=flr_val,
                    flrstck_nnr=flrstck_nnr,
                    flrstck_zhlr=flrstck_zhlr,
                )
                parcel_obj.updated_on = _now
                parcels_to_update.append(parcel_obj)
            except MultipleObjectsReturned:
                parcel_obj = Parcel.make_unique(
                    district=district,
                    municipal=municipal,
                    parcel_group=parcel_group,
                    flr=flr_val,
                    flrstck_nnr=flrstck_nnr,
                    flrstck_zhlr=flrstck_zhlr,
                )
                parcel_obj.updated_on = _now
                parcels_to_update.append(parcel_obj)
            except ObjectDoesNotExist:
                # If not existing, create object but do not commit, yet
                parcel_obj = Parcel(
                    district=district,
                    municipal=municipal,
                    parcel_group=parcel_group,
                    flr=flr_val,
                    flrstck_nnr=flrstck_nnr,
                    flrstck_zhlr=flrstck_zhlr,
                    updated_on=_now,
                )
                parcels_to_create.append(parcel_obj)

        # Create new parcels
        Parcel.objects.bulk_create(
            parcels_to_create,
            batch_size=500
        )
        # Update existing parcels
        Parcel.objects.bulk_update(
            parcels_to_update,
            [
                "updated_on"
            ],
            batch_size=500
        )

        # Update linking to geometry
        parcel_ids = [x.id for x in parcels_to_update] + [x.id for x in parcels_to_create]
        underlying_parcels = Parcel.objects.filter(id__in=parcel_ids)
        self.parcels.set(underlying_parcels)

    @transaction.atomic
    def _set_parcel_update_start_time(self):
        """
        Sets the current time for the parcel calculation begin
        """
        self.parcel_update_start = timezone.now()
        self.parcel_update_end = None
        self.save()

    @transaction.atomic
    def _set_parcel_update_end_time(self):
        """
        Sets the current time for the parcel calculation end
        """
        self.parcel_update_end = timezone.now()
        self.save()

    def get_underlying_parcels(self):
        """ Getter for related parcels and their districts

        Returns:
            parcels (QuerySet): The related parcels as queryset
        """
        parcels = self.parcels.prefetch_related(
            "district",
            "municipal",
        ).order_by(
            "municipal__name",
        )

        return parcels

    def get_underlying_municipals(self, parcels=None):
        """ Getter for related municipals

        If no QuerySet of parcels is provided, the parcels will be fetched

        Returns:
            municipals (QuerySet): The related municipals as queryset
        """
        from konova.models import Municipal

        if parcels is None:
            parcels = self.get_underlying_parcels()
        municipals = parcels.order_by("municipal").distinct("municipal").values("municipal__id")
        municipals = Municipal.objects.filter(id__in=municipals).order_by("name")
        return municipals

    def as_feature_collection(self, srid=DEFAULT_SRID_RLP):
        """ Returns a FeatureCollection structure holding all polygons of the MultiPolygon as single features

        This method is used to convert a single MultiPolygon into multiple Polygons, which can be used as separated
        features in the NETGIS map client.

        Args:
            srid (int): The spatial reference system identifier to be transformed to

        Returns:
            geojson (dict): The FeatureCollection json (as dict)
        """
        geom = self.geom
        if geom.srid != srid:
            geom.transform(srid)
        polygons = [p for p in geom]
        geojson = {
            "type": "FeatureCollection",
            "crs": {
                "type": "name",
                "properties": {
                    "name": f"urn:ogc:def:crs:EPSG::{geom.srid}"
                }
            },
            "features": [
                {
                    "type": "Feature",
                    "geometry": json.loads(p.json),
                    "properties": {},
                 }
                for p in polygons
            ]
        }
        return geojson

    @property
    def complexity_factor(self) -> float:
        """ Calculates a factor to estimate the complexity of a Geometry

        0 = very low complexity
        1 = very high complexity

        ASSUMPTION:
        The envelope is the bounding box of a geometry. If the geometry's area is similar to the area of it's  bounding
        box, it is considered as rather simple, since it seems to be a closer shape like a simple box.
        If the geometry has a very big bounding box, but the geometry's own area is rather small,
        compared to the one of the bounding box, the complexity can be higher.

        Example:
            geometry area similar to bounding box --> geometry / bounding_box ~ 1
            geometry area far smaller than bb     --> geometry / bounding_box ~ 0

        Result is being inverted for better understanding of 'low' and 'high' complexity.

        Returns:
            complexity_factor (float): The estimated complexity
        """
        if self.geom.empty:
            return 0

        geom_envelope = self.geom.envelope
        diff = geom_envelope - self.geom

        if diff.area == 0:
            complexity_factor = 1
        else:
            complexity_factor = self.geom.area / diff.area

        return complexity_factor

    @staticmethod
    def cast_to_multipolygon(input_geom):
        """ If input_geom is not a MultiPolygon, cast to MultiPolygon

        Args:
            input_geom ():

        Returns:
            output_geom
        """
        output_geom = input_geom
        if not isinstance(input_geom, MultiPolygon):
            try:
                output_geom = MultiPolygon(input_geom, srid=DEFAULT_SRID_RLP)
            except TypeError as e:
                raise AssertionError(f"Only (Multi)Polygon allowed! Could not convert {input_geom.geom_type} to MultiPolygon")
        return output_geom

    @staticmethod
    def cast_to_rlp_srid(input_geom):
        """ If input_geom is not of RLP SRID (25832), cast to RLP SRID

        Args:
            input_geom ():

        Returns:
            output_geom
        """
        output_geom = input_geom
        if output_geom.srid != DEFAULT_SRID_RLP:
            output_geom.transform(DEFAULT_SRID_RLP)
        return output_geom

    def get_conflict_geometries(self):
        """ Getter for geometry ids which conflict with this geometry or are conflicted by this one

        Returns:
            geom_ids (list): List of geometry ids
        """
        conflict_geoms_id = GeometryConflict.get_conflict_geometries(self)
        conflict_geoms = Geometry.objects.filter(id__in=conflict_geoms_id)
        return conflict_geoms

    @staticmethod
    def is_valid_4326(geometry):
        """ Checks whether a given geometry's coordinates are in a valid range to be of EPSG:4326

        Args:
            geometry: The geometry

        Returns:
            ret_val (bool): Whether the geometry is valid EPSG:4326
        """
        if not geometry.centroid.coords:
            # No coordinates at all found, therefore technically proper 4326
            return True

        try:
            lat,lon = geometry.centroid.coords
            return (-90.0 <= lat <= 90.0) and (-180.0 <= lon <= 180.0)
        except IndexError:
            return False


class GeometryConflict(UuidModel):
    """
    Geometry conflicts model

    If a new/edited geometry overlays an existing geometry, there will be a new GeometryConflict on the db
    """
    conflicting_geometry = models.ForeignKey(
        Geometry,
        on_delete=models.CASCADE,
        help_text="The geometry which came second",
        related_name="conflicts_geometries"
    )
    affected_geometry = models.ForeignKey(
        Geometry,
        on_delete=models.CASCADE,
        help_text="The geometry which came first",
        related_name="conflicted_by_geometries"
    )
    detected_on = models.DateTimeField(auto_now_add=True, null=True)

    def __str__(self):
        return f"{self.conflicting_geometry.id} conflicts with {self.affected_geometry.id}"

    @staticmethod
    def get_conflict_geometries(geometry: Geometry):
        """ Getter for geometries which conflict in one or another way with the given one

        Args:
            geometry (Geometry): The geometry which shall be checked

        Returns:
            conflict_geometries (QuerySet): QuerySet of geometries which have conflicts with the given geometry
        """
        conflict_geometries = GeometryConflict.objects.filter(
            affected_geometry=geometry.id,
        ).values_list("conflicting_geometry__id", flat=True)
        conflict_geometries = conflict_geometries.union(
            GeometryConflict.objects.filter(
                conflicting_geometry=geometry.id,
            ).values_list("affected_geometry__id", flat=True)
        )
        return conflict_geometries