"""
/***************************************************************************
 SeilaplanPlugin
                                 A QGIS plugin
 Seillinien-Layoutplaner
                              -------------------
        begin                : 2013
        copyright            : (C) 2015 by ETH Zürich
        email                : seilaplanplugin@gmail.com
 ***************************************************************************/

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

import json
from math import atan2, cos, pi, sin
import os
import traceback
from typing import List, Union

from qgis.core import Qgis, QgsDistanceArea, QgsPointXY, QgsRasterLayer

from SEILAPLAN import __version__ as version
from SEILAPLAN.tools.config_handler_abstract import AbstractConfHandler
from SEILAPLAN.tools.config_handler_params import ParameterConfHandler, ParameterError
from SEILAPLAN.tools.globals import PolesOrigin
from SEILAPLAN.tools.height_source import AbstractHeightSource
from SEILAPLAN.tools.output_geo import createVirtualRaster
from SEILAPLAN.tools.poles import Poles
from SEILAPLAN.tools.profile import Profile
from SEILAPLAN.tools.raster import Raster
from SEILAPLAN.tools.survey import SurveyData
from SEILAPLAN.utils.path_handler import (
    calculate_path_candidates,
    get_absolute_path_from_relative,
    get_relative_path,
    path_exists_or_is_remote,
)
from SEILAPLAN.utils.plugin_utils import versionAsInteger
from SEILAPLAN.utils.qgis_utils import log


def castToNum(formattedNum):
    if type(formattedNum) in [int, float]:
        return formattedNum
    try:
        num = float(formattedNum.replace("'", ""))
    except (ValueError, AttributeError):
        num = None
    return num


class ProjectConfHandler(AbstractConfHandler):

    # Order of pole types in drop down list
    POINT_TYPE = {0: "pole_anchor", 1: "pole", 2: "crane"}

    def __init__(self, params: ParameterConfHandler):
        """
        :type params: tools.configHandler_params.ParamConfHandler
        """
        AbstractConfHandler.__init__(self)

        self.params: ParameterConfHandler = params

        # Project data
        self.projectName = None
        self.heightSource: AbstractHeightSource = None
        self.heightSourceType = None
        self.surveyType = None
        self.virtRasterSource = []
        self.points = {"A": [None, None], "E": [None, None]}
        self.coordState = {"A": "yellow", "E": "yellow"}
        self.A_type = "pole_anchor"  # pole_anchor, pole, crane
        self.E_type = "pole_anchor"  # pole_anchor, pole,
        self.profileLength = None
        self.azimut = None
        self.fixedPoles = {"poles": [], "HM_fix_d": [], "HM_fix_h": []}
        self.noPoleSection = []
        self.prHeader = {}

        self.profile: Profile = None
        self.poles: Poles = None
        # Poles from a loaded project file
        self.polesFromFile = []

    def setConfigFromFile(self, settings, projectFilePath: str):
        """Load configuration from json file."""
        self.setProjectName(settings["projectname"])
        self.setPrHeader(settings["header"])

        heightSource = settings["heightsource"]
        currentProjectDir = os.path.dirname(projectFilePath)
        heightSourcePath = self.calculateAbsolutePath(
            heightSource["source"],
            [
                currentProjectDir,
                settings.get("projectDir", currentProjectDir),
            ],
            settings["version"],
        )
        self.resetHeightSource()
        if heightSourcePath is not None:
            self.setHeightSource(
                None,
                sourceType=heightSource["type"],
                sourcePath=heightSourcePath,
                surveySourceType=heightSource.get("surveyType"),
            )
            if self.heightSourceType == "survey":
                self.heightSource: SurveyData
                self.heightSource.reprojectToCrs(heightSource["crs"])

        self.setPoint("A", settings["profile"]["start"]["coordinates"])
        self.setPoint("E", settings["profile"]["end"]["coordinates"])
        self.A_type = settings["profile"]["start"]["type"]
        self.E_type = settings["profile"]["end"]["type"]

        self.setFixedPoles(settings["profile"]["fixedPoles"])
        self.setNoPoleSection(settings["profile"]["noPoleSection"])

        self.polesFromFile = settings["poles"]

    def calculateAbsolutePath(
        self,
        pathsFromFile: Union[List[str], str],
        basePathCandidates: List[str],
        fileVersion: str,
    ):
        if versionAsInteger(fileVersion) < versionAsInteger("3.8.0"):
            # Path from older project file, this is already an absolute path
            return pathsFromFile

        # From 3.8.0 onwards, height source paths are saved relatively to the project
        #  directory. We're looking for the height source relative to the current
        #  project dir first, then relative to the original project dir
        resolvedPaths = []
        nonResolvedPaths = []
        isListOfPaths = True
        if isinstance(pathsFromFile, str):
            pathsFromFile = [pathsFromFile]
            isListOfPaths = False

        for path in pathsFromFile:
            pathCandidates = calculate_path_candidates(
                path,
                basePathCandidates,
            )
            # Save non-resolved paths to inform the user later
            if len(pathCandidates) < 1:
                nonResolvedPath = get_absolute_path_from_relative(
                    path, basePathCandidates[0]
                )
                nonResolvedPaths.append(nonResolvedPath)
            else:
                resolvedPaths.append(pathCandidates[0])

        if len(nonResolvedPaths) > 0:
            self.onError(
                self.tr("Hoehendaten konnten nicht geladen werden.\n")
                + self.tr("_path_ ist nicht vorhanden.").replace(
                    "_path_", ",\n".join(nonResolvedPaths)
                )
            )
        if isListOfPaths:
            return resolvedPaths if len(resolvedPaths) > 0 else None
        else:
            return resolvedPaths[0] if len(resolvedPaths) == 1 else None

    def setConfigFromFileOld(self, property_name, value):
        """Load settings from old style text file."""
        if property_name == "Projektname":
            self.setProjectName(value)

        elif property_name == "Hoehenmodell":
            self.setHeightSource(None, sourceType="dhm", sourcePath=value)

        elif property_name == "Hoehnmodell-Liste":
            # More than one layer, saved as json array
            try:
                layerList = json.loads(value)
            except Exception:
                self.onError(self.tr("Fehler beim Laden des Rasters"))
            else:
                self.setHeightSource(None, sourceType="dhm_list", sourcePath=layerList)

        elif property_name == "Laengsprofil":
            self.setHeightSource(None, sourceType="survey", sourcePath=value)

        elif property_name == "KBS":
            if self.heightSource and self.heightSourceType == "survey":
                self.heightSource: SurveyData
                self.heightSource.reprojectToCrs(value)

        elif property_name in ["Anfangspunkt", "Endpunkt"]:
            point = property_name[0]
            [x, y] = value.split("/")
            self.setPoint(point, [x, y])

        elif property_name == "Anfangspunkt-Typ":
            if value in self.POINT_TYPE.values():
                self.A_type = value

        elif property_name == "Endpunkt-Typ":
            if value in self.POINT_TYPE.values():
                self.E_type = value

        elif property_name == "Fixe Stuetzen":
            polesStr = value.split("/")[:-1]
            poleArray = []
            for stue in polesStr:
                [key, values] = stue.split(":")
                [poled, polez, poleh] = [string.strip() for string in values.split(",")]
                poleArray.append(
                    {
                        "d": int(poled),
                        "z": float(polez),
                        "h": float(poleh),
                        "name": key.strip(),
                    }
                )

            self.setFixedPoles(poleArray)

        elif property_name == "Keine Stuetzen":
            sections = value.split(";")
            sectionsArray = []
            for section in sections:
                dist = section.split(" - ")
                if len(dist) == 2:
                    sectionsArray.append([float(s) for s in dist])
            self.setNoPoleSection(sectionsArray)

    def getProjectName(self):
        return "" if self.projectName is None else self.projectName

    def setProjectName(self, value):
        if not value:
            self.projectName = None
        else:
            self.projectName = value

    def generateProjectName(self):
        """Generate a unique project name."""
        import time

        now = time.time()
        timestamp = time.strftime("%Y%m%d_%H%M", time.localtime(now))
        self.projectName = "seilaplan_{}".format(timestamp)
        return self.projectName

    def getHeightSourceAsStr(self, source=False, formatting=None, relativeToDir=None):
        """Get path of height model. If source is requested, return original
        raster files of virtual raster instead. Paths can be calculated relative
        to another path."""
        if source and self.virtRasterSource:
            pathList = self.virtRasterSource
            if relativeToDir:
                pathList = [
                    get_relative_path(absPath, relativeToDir) for absPath in pathList
                ]
            if formatting == "comma":
                return ", ".join(pathList)
            elif formatting == "json":
                return pathList
            else:
                return pathList
        else:
            path = self.heightSource.getAsStr()
            if relativeToDir and path:
                path = get_relative_path(path, relativeToDir)
            return path

    def setHeightSource(
        self, layer, sourceType="dhm", sourcePath=None, surveySourceType=None
    ):
        """Raster can be set by providing the QGIS Raster Layer or by giving
        the path to the raster file.
        :param layer: QGIS layer object
        :param sourceType: dhm or survey
        :param sourcePath: path to file
        :param surveySourceType: Type of survey file to load
        """
        self.resetHeightSource()
        heights = None
        errorMsg = ""
        if sourceType == "dhm":
            heights = Raster(layer, sourcePath)
        elif sourceType == "dhm_list":
            if layer:
                rasterList = layer
            else:
                rasterList = sourcePath
            if isinstance(rasterList, list):
                virtLayer = None
                if isinstance(rasterList[0], QgsRasterLayer):
                    # List of QGIS raster layers is provided
                    try:
                        virtLayer = createVirtualRaster(rasterList)
                        self.virtRasterSource = [
                            lyr.dataProvider().dataSourceUri() for lyr in rasterList
                        ]
                    except RuntimeError:
                        errorMsg = self.tr("Fehler beim Kombinieren der Rasterkacheln")
                elif isinstance(rasterList[0], str):
                    # List of paths is provided because a project file is
                    #  loaded. We create layers first, then create virtual layer
                    layerList = []
                    for i, path in enumerate(rasterList):
                        if not path_exists_or_is_remote(path):
                            errorMsg = self.tr(
                                "Raster-Datei _path_ ist nicht vorhanden, Raster kann nicht geladen werden."
                            ).replace("_path_", path)
                            break
                        rasterLyr = QgsRasterLayer(path, f"Raster {i}")
                        if rasterLyr.isValid():
                            layerList.append(rasterLyr)
                    try:
                        if len(layerList) > 0:
                            virtLayer = createVirtualRaster(layerList)
                            self.virtRasterSource = [
                                lyr.dataProvider().dataSourceUri() for lyr in layerList
                            ]
                    except RuntimeError:
                        errorMsg = self.tr("Fehler beim Kombinieren der Rasterkacheln.")
                if virtLayer:
                    heights = Raster(virtLayer)
        elif sourceType == "survey":
            heights = SurveyData(sourcePath, surveySourceType)
            if heights.valid:
                self.points = {"A": [None, None], "E": [None, None]}
                self.surveyType = heights.sourceType
                if heights.prHeaderData:
                    self.setPrHeader(heights.prHeaderData["Header"])
                    self.params.setParameter(
                        "Anlagetyp", heights.prHeaderData["Anlagetyp"]
                    )

        if heights and heights.errorMsg:
            errorMsg = heights.errorMsg
            heights.errorMsg = ""

        if heights and heights.valid:
            self.heightSource = heights
            self.heightSourceType = sourceType

        if errorMsg or not heights or not heights.valid:
            self.onError(
                errorMsg or self.tr("Hoehendaten konnten nicht geladen werden.")
            )

        # Points are either empty (raster) or they are the first and last point
        # of survey data
        self.setPoint("A", self.points["A"])
        self.setPoint("E", self.points["E"])

    def getPoint(self, pointType):
        return self.points[pointType], self.coordState

    def getPointAsStr(self, pointType):
        x = ""
        y = ""
        if self.points[pointType][0]:
            x = self.formatCoordinate(self.points[pointType][0])
        if self.points[pointType][1]:
            y = self.formatCoordinate(self.points[pointType][1])
        return [x, y]

    def setPoint(self, pointType, coords):
        hasChanged = False
        x = castToNum(coords[0])
        y = castToNum(coords[1])

        self.coordState[pointType] = self.checkCoordinatePoint([x, y])

        # Check if coordinates have actually changed
        if not (x == self.points[pointType][0] and y == self.points[pointType][1]):
            hasChanged = True
            self.setFixedPoles(None)
            self.setNoPoleSection([])

        self.points[pointType] = [x, y]
        self.setAzimut()
        self.setProfileLen()
        return self.points[pointType], self.coordState, hasChanged

    def checkCoordinatePoint(self, coords):
        state = "yellow"
        if coords is None or coords == [None, None]:
            return state

        precision = 3
        [x, y] = [round(c, precision) for c in coords]

        if (
            self.heightSource
            and self.heightSource.extent
            and x is not None
            and y is not None
        ):
            [extLx, extHy, extHx, extLy] = [
                round(e, precision) for e in self.heightSource.extent
            ]

            # Round coordinates to avoid float imprecision
            if min(extLx, extHx) <= x <= max(extLx, extHx) and min(
                extLy, extHy
            ) <= y <= max(extLy, extHy):
                state = "green"
            else:
                state = "red"
        return state

    # noinspection PyTypeChecker
    def transform2MapCoords(self, distance):
        x = self.points["A"][0] + distance * sin(self.azimut)
        y = self.points["A"][1] + distance * cos(self.azimut)
        return QgsPointXY(x, y)

    def profilePointsAreValid(self):
        return self.coordState["A"] == self.coordState["E"] == "green"

    def getAzimut(self):
        return "" if self.azimut is None else self.azimut

    # noinspection PyUnresolvedReferences
    def setAzimut(self):
        azimut = None
        if self.profilePointsAreValid():
            azimut = atan2(
                self.points["E"][0] - self.points["A"][0],
                self.points["E"][1] - self.points["A"][1],
            )
            if self.points["E"][0] - self.points["A"][0] < 0:
                azimut += 2 * pi
        self.azimut = azimut

    def getProfileLenAsStr(self):
        return "" if self.profileLength is None else f"{self.profileLength:.0f}"

    # noinspection PyUnresolvedReferences
    def setProfileLen(self):
        length = None
        if self.profilePointsAreValid():
            crs = self.heightSource.spatialRef
            if crs and crs.isGeographic():
                # Create a measure object
                distance = QgsDistanceArea()
                ell = crs.ellipsoidAcronym()
                distance.setEllipsoid(ell)
                # Measure the distance
                length = distance.measureLine(
                    QgsPointXY(*tuple(self.points["A"])),
                    QgsPointXY(*tuple(self.points["E"])),
                )
            else:
                length = (
                    (self.points["E"][0] - self.points["A"][0]) ** 2
                    + (self.points["E"][1] - self.points["A"][1]) ** 2
                ) ** 0.5
        self.profileLength = length

    def getFixedPoles(self):
        return self.fixedPoles["poles"]

    def setFixedPoles(self, value):
        self.fixedPoles = {"poles": [], "HM_fix_d": [], "HM_fix_h": []}
        if not value:
            return

        for pole in value:
            d = pole["d"]
            z = pole["z"]
            h = pole["h"]
            name = pole["name"]
            if not h:
                h = -1
            self.fixedPoles["poles"].append({"d": d, "z": z, "h": h, "name": name})
            self.fixedPoles["HM_fix_d"].append(d)
            self.fixedPoles["HM_fix_h"].append(h)

    def setNoPoleSection(self, noPoles):
        self.noPoleSection = noPoles

    def getSettings(self, projectDir: str):
        """Return settings in a dictionary form to save to json file."""
        return {
            "projectname": self.getProjectName(),
            "projectDir": projectDir,
            "version": version,
            "header": self.prHeader,
            "heightsource": {
                "type": self.heightSourceType,
                "surveyType": self.surveyType,
                "source": self.getHeightSourceAsStr(
                    source=True, formatting="json", relativeToDir=projectDir
                ),
                "crs": self.heightSource.spatialRef.authid(),
            },
            "profile": {
                "start": {
                    "coordinates": self.points["A"],
                    "type": self.A_type,
                },
                "end": {"coordinates": self.points["E"], "type": self.E_type},
                "fixedPoles": self.fixedPoles["poles"],
                "noPoleSection": self.noPoleSection,
            },
            "poles": self.poles.getSettings() if self.poles else [],
        }

    def checkValidState(self):
        msg = ""
        if not self.projectName:
            msg = self.tr("Bitte definieren Sie einen Projektnamen")
        elif not self.heightSource:
            msg = self.tr("Bitte definieren Sie Terraindaten")
        elif not self.profilePointsAreValid():
            if self.heightSourceType == "survey":
                msg = self.tr(
                    "Bitte zeichnen Sie Start- und Endpunkt der Seillinie in die Karte ein (Schaltflaeche zeichnen)"
                )
            else:
                msg = self.tr(
                    "Bitte zeichnen Sie die Seillinie in die Karte (Schaltflaeche zeichnen) oder definieren sie Start- und Endkoordinaten manuell"
                )
        if msg:
            self.onError(msg, self.tr("Ungueltige Daten"))
        return self.profilePointsAreValid() and self.projectName

    def getPointTypeAsIdx(self, point):
        if point == "A":
            currentPtype = self.A_type
        elif point == "E":
            currentPtype = self.E_type
        else:
            return False
        for key, ptype in self.POINT_TYPE.items():
            if currentPtype == ptype:
                return key

    def setPointType(self, point, typeIdx):
        if isinstance(typeIdx, int):
            pType = self.POINT_TYPE[typeIdx]
        else:
            pType = typeIdx
        if point == "A":
            self.A_type = pType
        elif point == "E":
            self.E_type = pType

        # Reset in-between poles, so they don't hang around after start or
        #  end pole has been changed
        self.resetPoles()

    @staticmethod
    def formatCoordinate(number, digits=1):
        """Format coordinates to one digit except otherwise"""
        if number is None:
            return ""
        try:
            return str(round(number, digits))
        except ValueError:
            return ""

    def preparePreviewProfile(self):
        if not self.profilePointsAreValid():
            return False
        try:
            self.heightSource.prepareData(
                self.points, self.azimut, self.params.ANCHOR_LEN
            )
        except Exception as e:
            trace = traceback.format_exc()
            log(
                f"Error when preparing height source data: {e}\n{trace}",
                Qgis.MessageLevel.Warning,
            )
            self.onError(
                f"{self.tr('Unerwarteter Fehler bei der Erstellung des Profils.')}\n{e}"
            )
            return False
        try:
            profile = Profile(self)
        except Exception as e:
            trace = traceback.format_exc()
            log(f"Error when creating profile: {e}\n{trace}", Qgis.MessageLevel.Warning)
            self.onError(
                f"{self.tr('Unerwarteter Fehler bei der Erstellung des Profils.')}\n{e}"
            )
            return False
        if self.heightSource.errorMsg:
            self.onError(self.heightSource.errorMsg)
            return False
        return profile

    def setPrHeader(self, prHeaderData):
        self.prHeader = {}
        if not isinstance(prHeaderData, dict):
            return
        for propName, value in prHeaderData.items():
            try:
                self.prHeader[propName] = str(value)
            except ValueError:
                self.prHeader[propName] = ""

    def prepareForCalculation(self, runOptimization):
        success = True
        # Prepare raster (create subraster) or interpolate survey data
        try:
            self.heightSource.prepareData(
                self.points, self.azimut, self.params.ANCHOR_LEN
            )
        except Exception:
            self.onError(self.tr("Unerwarteter Fehler bei Erstellung des Profils"))
            return False

        # Anchor length is shortened in case the height source has not enough
        #  data to extract terrain data for anchor field. Also, anchor is
        #  0m when pole type is crane or anchor.
        self.params.updateAnchorLen(self.heightSource.buffer, self.A_type, self.E_type)

        # Create profile line from subraster or survey data
        try:
            self.profile = Profile(self)
        except Exception:
            self.onError(self.tr("Unerwarteter Fehler bei Erstellung des Profils"))
            return False
        if self.heightSource.errorMsg:
            self.onError(self.heightSource.errorMsg)
            return False
        # Now that height of point A and B is known, pull rope forces are
        #  calculated
        try:
            self.params.setPullRope(self.profile.direction)
        except ParameterError as e:
            self.onError(str(e))
            return False

        # Initialize pole data (start/end point and anchors)
        try:
            self.poles = Poles(self)
            if not runOptimization:
                self.updatePoles()
        except ValueError:
            self.onError(self.tr("Unerwarteter Fehler bei Erstellung der Stuetzen"))
            return False
        return success

    def updatePoles(self):
        """Create poles from a saved project or from fixed poles. Don't call
        this function if the optimization algorithm is going to run."""
        if self.polesFromFile:
            self.poles.updateAllPoles(PolesOrigin.SavedFile, self.polesFromFile)
        # If instead user has defined some fixed poles, add these to Poles()
        #  (if the saved file included fixed poles, they would already be part of self.poles)
        elif len(self.fixedPoles["poles"]) > 0:
            self.poles.updateAllPoles(
                PolesOrigin.OnlyStartEnd, self.fixedPoles["poles"]
            )

    def resetProfile(self):
        self.points = {"A": [None, None], "E": [None, None]}
        self.coordState = {"A": "yellow", "E": "yellow"}
        self.profileLength = None
        self.azimut = None
        self.fixedPoles = {"poles": [], "HM_fix_d": [], "HM_fix_h": []}
        self.resetPoles()

    def resetHeightSource(self):
        del self.heightSource
        self.heightSource = None
        self.heightSourceType = None
        self.surveyType = None
        self.virtRasterSource = None

    def resetPoles(self):
        self.poles = None
        self.polesFromFile = []
