from qgis.PyQt.QtCore import (
  QT_TRANSLATE_NOOP, QVariant
  )
from qgis.core import (
  QgsCoordinateTransform,
  QgsVectorLayer,
  QgsCoordinateReferenceSystem,
  QgsProcessingUtils,
  QgsProcessingContext,
  QgsProcessingFeedback, 
  QgsRectangle,
  QgsGeometry,
  QgsFeature,
  QgsReferencedRectangle,
  QgsProject
  )
from qgis import processing

from qgis.utils import iface
import sys
import math
import itertools
import os
import concurrent.futures
import requests
import numpy as np
import uuid
from osgeo import gdal, osr

from ..algabstract import algabstract

# abstract class for fetching information from web
class fetchabstract(algabstract):
    
  # fetch area
  FETCH_AREA = None
  
  # fetched features
  FETCH_FEATURE = None
    
  # fetch type (vector or raster)
  FETCH_TYPE = "vector"
  
  # parameters for fetching data from map tile
  TILEMAP_ARGS = {
    "SET": False,
    "URL": None,
    "OUTPUT_RASTER": None,
    "N_RASTER_BANDS": None,
    "RASTER_DTYPE": None,
    "TILEMAP_PIXELS": None,
    "CRS": None,
    "GEOM_TYPE": None,
    "Z": None,
    "XMIN": None, "XMAX": None, "YMIN": None, "YMAX": None
    }
  
  # parameters for fetching data from OpenStreetMap
  OSM_ARGS = {
    "SET": False,
    "URL": "https://lz4.overpass-api.de/api/interpreter",
    "CRS": QgsCoordinateReferenceSystem("EPSG:4326"),
    "GEOM_TYPE": None,
    "QUICKOSM_ARGS": {
      "KEY": None,
      "VALUE": None,
      "FETCH_EXTENT": None,
      "TIMEOUT": 25
    }
  }
  
  # parameters for fetching data from other URL (e.g. SRTM)
  WEBFETCH_ARGS = {
    "SET": False,
    "API_METHOD": "get",
    "API_PARAMETERS": [],
    "DOWNLOADED_FILE": [],
    "CRS": None,
    "GEOM_TYPE": None,
    "LOGIN": None
  }
  
  # initialize extent and CRS using the canvas
  def initUsingCanvas(self) -> None:
    try:
      (rect, target_crs) = self.getExtentAndCrsUsingCanvas()

      self.PARAMETERS["FETCH_EXTENT"]["ui_args"]["defaultValue"] = ",".join(
        [str(rect.xMinimum()), str(rect.xMaximum()), str(rect.yMinimum()), str(rect.yMaximum())]
        ) + f" [{rect.crs().authid()}]"
      self.PARAMETERS["TARGET_CRS"]["ui_args"]["defaultValue"] = target_crs.authid()
    except:
      pass
      
    
  # set fetch_area
  # it is used mainly for fetching the data, 
  # so the crs for fetching the data should be used
  def setFetchArea(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback, webfetch_crs: QgsCoordinateReferenceSystem = None) -> None:
    
    # get target x-y CRS, to apply the buffer and determine the fetch area
    target_crs = self.parameterAsCrs(parameters, "TARGET_CRS", context)
    
    # check whether the target CRS is x-y coordinates
    if target_crs.isGeographic() or target_crs.authid() == "EPSG:3857":
      sys.exit(self.tr("The Target CRS is NOT a Cartesian Coordinate System"))
    
    # get the extent, using the target CRS
    fetch_extent = self.parameterAsExtent(
      parameters, "FETCH_EXTENT", context, 
      self.parameterAsCrs(parameters, "TARGET_CRS", context)
      )
    
    # get the buffer
    buffer = self.parameterAsDouble(parameters, "BUFFER",context)
    
    # get the fetch area, using the extent and buffer
    fetch_area = QgsReferencedRectangle(
      QgsRectangle(
        fetch_extent.xMinimum() - buffer,
        fetch_extent.yMinimum() - buffer,
        fetch_extent.xMaximum() + buffer,
        fetch_extent.yMaximum() + buffer
      ),
      target_crs
    )
    
    # if the crs is specified, transform the area
    if webfetch_crs is None:
      self.FETCH_AREA = fetch_area
    else:
      transform = QgsCoordinateTransform(target_crs, webfetch_crs, QgsProject.instance())
      fetch_area_tr = QgsReferencedRectangle(transform.transformBoundingBox(fetch_area), webfetch_crs)
      feedback.pushInfo(self.tr("Transform:") + transform.instantiatedCoordinateOperationDetails().name)
      
      if not webfetch_crs.isGeographic():
        self.FETCH_AREA = fetch_area_tr
      elif fetch_area_tr.xMinimum() >= -180 and fetch_area_tr.yMinimum() >= -90 and fetch_area_tr.xMaximum() <= 180 and fetch_area_tr.yMaximum() <= 90:
        self.FETCH_AREA = fetch_area_tr
      else:
        sys.exit(self.tr("Unsolved problem in transforming coordinates is occurred. Save features, restart QGIS and try again."))
  
  # get the fetch area as a polygon vector layer
  def fetchAreaAsVectorLayer(self) -> QgsVectorLayer:
    vec_layer = QgsVectorLayer("Polygon?crs=" + self.FETCH_AREA.crs().authid(), "fetch_area", "memory")
    ft = QgsFeature()
    ft.setGeometry(QgsGeometry.fromRect(self.FETCH_AREA))
    vec_layer.dataProvider().addFeatures([ft])    
    return(vec_layer)
      
  # set information about the map tile
  def setTileMapArgs(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback, geom_type: str = None, additional_args:dict = None) -> None:
    # set parameters (from UIs)
    self.TILEMAP_ARGS["URL"] = self.parameterAsString(parameters,"TILEMAP_URL", context)
    self.TILEMAP_ARGS["CRS"] = self.parameterAsCrs(parameters,"TILEMAP_CRS", context)
    z = self.parameterAsInt(parameters, "TILEMAP_ZOOM", context) #used later
    self.TILEMAP_ARGS["Z"] = z
    self.TILEMAP_ARGS["GEOM_TYPE"] = geom_type
    
    if additional_args is not None:
      for key, value in additional_args.items():
        self.TILEMAP_ARGS[key] = value
    
    # set the extent using self.FETCH_AREA
    if self.FETCH_AREA is not None:
      lng_min = self.FETCH_AREA.xMinimum()
      lng_max = self.FETCH_AREA.xMaximum()
      lat_min = self.FETCH_AREA.yMinimum()
      lat_max = self.FETCH_AREA.yMaximum()
      
      # Note that YMIN is obtained from lat_max / YMAX is from lat_min
      self.TILEMAP_ARGS["XMIN"] = int(2**(z+7) * (lng_min / 180 + 1) / 256)
      self.TILEMAP_ARGS["XMAX"] = int(2**(z+7) * (lng_max / 180 + 1) / 256)
      self.TILEMAP_ARGS["YMIN"] = int(2**(z+7) / math.pi * (-math.atanh(math.sin(math.pi/180*lat_max)) + math.atanh(math.sin(math.pi/180*85.05112878))) / 256)
      self.TILEMAP_ARGS["YMAX"] = int(2**(z+7) / math.pi * (-math.atanh(math.sin(math.pi/180*lat_min)) + math.atanh(math.sin(math.pi/180*85.05112878))) / 256)
    
    # set output vector/raster
    if self.FETCH_TYPE == "vector":
      self.FETCH_FEATURE = QgsVectorLayer(
        self.TILEMAP_ARGS["GEOM_TYPE"] + "?crs=" + self.TILEMAP_ARGS["CRS"].authid() + "&index=yes",
        baseName = "layer_from_tile", 
        providerLib = "memory"
        )
    
    elif self.FETCH_TYPE == "raster":   
      self.TILEMAP_ARGS["OUTPUT_RASTER"] = self.parameterAsOutputLayer(parameters, "OUTPUT_RASTER", context)
      if self.TILEMAP_ARGS["OUTPUT_RASTER"][-3:] != "tif":
        sys.exit(self.tr("The output raster should be a GeoTIFF file (.tif)"))          
      
      # set the output raster, CRS and coordinates
      self.TILEMAP_ARGS["TILEMAP_PIXELS"] = self.parameterAsInt(parameters, "TILEMAP_PIXELS", context)
      
      if self.TILEMAP_ARGS["N_RASTER_BANDS"] is None or self.TILEMAP_ARGS["RASTER_DTYPE"] is None:
        sys.exit(self.tr("The number of bands of output raster and the data type must be specified"))          
        
      driver = gdal.GetDriverByName("GTiff")
      self.FETCH_FEATURE = driver.Create(
        self.TILEMAP_ARGS["OUTPUT_RASTER"], 
        self.TILEMAP_ARGS["TILEMAP_PIXELS"] * (self.TILEMAP_ARGS["XMAX"] - self.TILEMAP_ARGS["XMIN"] + 1), 
        self.TILEMAP_ARGS["TILEMAP_PIXELS"] * (self.TILEMAP_ARGS["YMAX"] - self.TILEMAP_ARGS["YMIN"] + 1),
        self.TILEMAP_ARGS["N_RASTER_BANDS"], self.TILEMAP_ARGS["RASTER_DTYPE"]
        )
      
      n_pixels_all = self.TILEMAP_ARGS["TILEMAP_PIXELS"] * 2 ** self.TILEMAP_ARGS["Z"]
      EQUATOR_M = 40075016.68557849
      meter_per_tile  = EQUATOR_M / 2 ** self.TILEMAP_ARGS["Z"]
      meter_per_pixel = EQUATOR_M / n_pixels_all
      
      x_offset =    self.TILEMAP_ARGS["XMIN"] * meter_per_tile - EQUATOR_M / 2
      y_offset = -  self.TILEMAP_ARGS["YMIN"] * meter_per_tile + EQUATOR_M / 2   
       
      srs = osr.SpatialReference()
      srs.ImportFromEPSG(int(self.TILEMAP_ARGS["CRS"].authid()[-4:]))
      self.FETCH_FEATURE.SetProjection(srs.ExportToWkt())
      self.FETCH_FEATURE.SetGeoTransform([x_offset, meter_per_pixel, 0, y_offset, 0, -meter_per_pixel])
    
    
    # finally a flag is set
    self.TILEMAP_ARGS["SET"] = True
  
  
  # set information about the map tile
  def setOsmArgs(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback, geom_type: str=None) -> None:
    self.TILEMAP_ARGS["URL"] = self.parameterAsString(parameters, "OSM_URL", context)
    self.OSM_ARGS["GEOM_TYPE"] = geom_type
    self.OSM_ARGS["QUICKOSM_ARGS"]["KEY"] = self.parameterAsString(parameters, "OSM_KEY", context)
    self.OSM_ARGS["QUICKOSM_ARGS"]["VALUE"] = self.parameterAsString(parameters, "OSM_VALUE", context)
    self.OSM_ARGS["QUICKOSM_ARGS"]["TIMEOUT"] = self.parameterAsDouble(parameters, "OSM_TIMEOUT", context)
    
    if self.FETCH_AREA is not None:
      lng_min_str = str(self.FETCH_AREA.xMinimum())
      lng_max_str = str(self.FETCH_AREA.xMaximum())
      lat_min_str = str(self.FETCH_AREA.yMinimum())
      lat_max_str = str(self.FETCH_AREA.yMaximum())
      crs_str = self.FETCH_AREA.crs().authid()
      self.OSM_ARGS["QUICKOSM_ARGS"]["FETCH_EXTENT"] = f"{lng_min_str},{lng_max_str},{lat_min_str},{lat_max_str} [{crs_str}]"
      
    if self.OSM_ARGS["GEOM_TYPE"] is not None and self.OSM_ARGS["QUICKOSM_ARGS"]["FETCH_EXTENT"] is not None and \
      self.OSM_ARGS["QUICKOSM_ARGS"]["KEY"] is not None and self.OSM_ARGS["QUICKOSM_ARGS"]["VALUE"] is not None:
        self.OSM_ARGS["SET"] = True
  
  # to set the parameters for using data over the Internet
  def setWebFetchArgs(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback) -> None:
    pass
  
  # fetch features/rasters from the map tile
  def fetchFeaturesFromTile(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback) -> None:
    
    # if not all the parameters were set, stop
    if self.TILEMAP_ARGS["SET"] is not True:
      sys.exit(self.tr("Required parameters are NOT filled"))
    
    # iterator and the number of tiles
    iter_obj = enumerate(itertools.product(list(range(self.TILEMAP_ARGS["XMIN"], self.TILEMAP_ARGS["XMAX"]+1)), list(range(self.TILEMAP_ARGS["YMIN"],self.TILEMAP_ARGS["YMAX"]+1))))
    n_tiles = (self.TILEMAP_ARGS["XMAX"] - self.TILEMAP_ARGS["XMIN"] + 1) * (self.TILEMAP_ARGS["YMAX"] - self.TILEMAP_ARGS["YMIN"] + 1)

    # fetch features from each tile / txy is a tuple of (tx, ty)
    def fetchFromSingleTile(i, txy):
      tx, ty = txy
      url = self.TILEMAP_ARGS["URL"].replace("{z}", str(self.TILEMAP_ARGS["Z"])).replace("{x}", str(tx)).replace("{y}",str(ty))
      
      # ok, fetch a file from the Internet
      if self.FETCH_TYPE == "vector":
        data_from_tile = QgsVectorLayer(url, "v", "ogr")      
      elif self.FETCH_TYPE == "raster":
        data_from_tile = gdal.Open(url)        
      
      # give feedback
      feedback.pushInfo(f"({i+1}/{n_tiles}) Fetched from: {url}")
      return tx, ty, data_from_tile
    
    # fetch procedure is done in parallel
    with concurrent.futures.ThreadPoolExecutor() as executor:
      for tx, ty, data_from_tile in executor.map(lambda args: fetchFromSingleTile(*args), iter_obj):
        # check if the cancel button was pressed
        if feedback.isCanceled():
          sys.exit("Fetching was canceled")

        # output procedure of the fetched data
        if self.FETCH_TYPE == "vector":
          data_provider = self.FETCH_FEATURE.dataProvider()
          self.writeOutputFeature(tx, ty, data_from_tile, data_provider)      
        elif self.FETCH_TYPE == "raster":
          self.writeOutputRaster(tx, ty, data_from_tile)      
      
      # finalize, if necessary
      if self.FETCH_TYPE == "vector":
        pass
      elif self.FETCH_TYPE == "raster":
        self.FETCH_FEATURE.FlushCache()   
  
  # output procedure for the vector data
  def writeOutputFeature(self, tx, ty, data_from_tile, data_provider = None):
    if data_from_tile.featureCount() > 0:        
      # features added using the data provider
      for ft in data_from_tile.getFeatures():
        # set the fields if it is not set 
        if self.FETCH_FEATURE.fields().count() == 0:
          for idx in range(ft.fields().count()):
            fld = ft.fields().at(idx)
            if not fld.type() in [QVariant.Int, QVariant.Double, QVariant.String, QVariant.Date, QVariant.DateTime, QVariant.Time]:
              fld.setType(QVariant.String)
              fld.setTypeName("String")
            data_provider.addAttributes([fld])
          self.FETCH_FEATURE.updateFields()
        data_provider.addFeatures([ft])
  
  # output procedure for the raster data
  def writeOutputRaster(self, tx, ty, data_from_tile) -> None:
    pass
  
  # fetch features from the URL
  # note that it only downloads file(s)
  def fetchFeaturesFromWeb(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback) -> None:
    self.WEBFETCH_ARGS["DOWNLOADED_FILE"] = []
    
    if not self.WEBFETCH_ARGS["SET"]:
      return
    # if login is needed, session is also needed
    if self.WEBFETCH_ARGS["LOGIN"] is not None:
      try:
        session = self.WEBFETCH_ARGS["LOGIN"]["SESSION"]
      except:
        sys.exit(self.tr("Session object must be given for Log-in procedure"))
    else:
      session = requests.Session()
        
    # start downloading
    n_url = len(self.WEBFETCH_ARGS["API_PARAMETERS"])
    api_args = zip(itertools.count(), [self.WEBFETCH_ARGS["API_METHOD"] for _ in range(n_url)], self.WEBFETCH_ARGS["API_PARAMETERS"])    
    
    # a local function to fetch data using Web API
    def fetchUsingWebAPI(i, api_method, api_parameters):
      
      feedback.pushInfo(f"({i+1}/{n_url}) Downloading " + api_parameters["url"])
      response = getattr(session, api_method)(**api_parameters)
      
      # if download was succeeded, save as a file
      if response.status_code == 200:        
        # write the contents in a temporary file
        tmp_path = os.path.join(
          os.path.normpath(os.path.dirname(QgsProcessingUtils.generateTempFilename(""))), 
          str(uuid.uuid4())
          )
        with open(tmp_path, "wb") as f:
          f.write(response.content)
        
        self.WEBFETCH_ARGS["DOWNLOADED_FILE"].append(tmp_path)
      else:
        feedback.pushInfo(self.tr("... ERROR!") + f"{response.status_code}: {response.text}")
      return
      
    # fetch procedure is done in parallel
    with concurrent.futures.ThreadPoolExecutor() as executor:
      for _ in executor.map(lambda args : fetchUsingWebAPI(*args), api_args):
        if feedback.isCanceled():
          sys.exit("Fetching was canceled")
  
  # fetch features from the map tile
  def fetchFeaturesFromOsm(self, parameters:dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback) -> None:
    if self.OSM_ARGS["SET"]:
      
      quickosm_results = processing.run(
        "quickosm:downloadosmdataextentquery", 
        {
          "KEY": self.OSM_ARGS["QUICKOSM_ARGS"]["KEY"],
          "VALUE": self.OSM_ARGS["QUICKOSM_ARGS"]["VALUE"],
          "EXTENT": self.OSM_ARGS["QUICKOSM_ARGS"]["FETCH_EXTENT"],
          "TIMEOUT": self.OSM_ARGS["QUICKOSM_ARGS"]["TIMEOUT"],
          "SERVER": self.OSM_ARGS["URL"],
          "FILE": "TEMPORARY_OUTPUT"
        },
        # context = context, # not pass the context, so as not to show the warnings
        feedback = feedback
      )
      
      
      if self.OSM_ARGS["GEOM_TYPE"] == "Point":
        vec_layer = quickosm_results["OUTPUT_POINTS"]
      elif self.OSM_ARGS["GEOM_TYPE"] == "Linestring":
        vec_layer = quickosm_results["OUTPUT_LINES"]
      elif self.OSM_ARGS["GEOM_TYPE"] == "Multilinestring":
        vec_layer = quickosm_results["OUTPUT_MULTILINESTRINGS"]
      elif self.OSM_ARGS["GEOM_TYPE"] == "Polygon":
        vec_layer = quickosm_results["OUTPUT_MULTIPOLYGONS"]
      else:
        vec_layer = None
      
      self.FETCH_FEATURE = vec_layer
  

    
  # dissolve features
  def dissolveFeatures(self, fts: QgsVectorLayer) -> QgsVectorLayer:
    # Check the validation      
    fts_valid = processing.run(
      "qgis:checkvalidity",
      {
        "INPUT_LAYER": fts,
        "VALID_OUTPUT": "TEMPORARY_OUTPUT"
      }
    )["VALID_OUTPUT"]
  
    # Dissolve
    fts_dissolve = processing.run(
      "native:dissolve", 
      {
        "INPUT": fts_valid,
        "FIELD": fts_valid.fields().names(),
        "OUTPUT": "TEMPORARY_OUTPUT"
      }
    )["OUTPUT"]
    
    # Multipart to Single parts
    fts_single = processing.run(
      "native:multiparttosingleparts", 
      {
        "INPUT": fts_dissolve,
        "OUTPUT": "TEMPORARY_OUTPUT"
      }
    )["OUTPUT"]
    
    return fts_single
    
    
  # dissolve features
  def extractFeatures(self, fts: QgsVectorLayer) -> QgsVectorLayer:
      # Check the validation      
      fts_extract = processing.run(
        "native:extractbyextent",
        {
          "INPUT": fts,
          "EXTENT": self.FETCH_AREA,
          "OUTPUT": "TEMPORARY_OUTPUT"
        }
      )["OUTPUT"]
    
      return fts_extract
  
  # transform to the target CRS
  def transformToTargetCrs(self, parameters: dict, context: QgsProcessingContext, feedback: QgsProcessingFeedback, fts: QgsVectorLayer) -> QgsVectorLayer:
    target_crs = self.parameterAsCrs(parameters, "TARGET_CRS", context)
    fts_transformed = processing.run(
      "native:reprojectlayer", 
      {
        "INPUT": fts,
        "TARGET_CRS": target_crs,
        "OUTPUT": "TEMPORARY_OUTPUT"
      }
    )["OUTPUT"]      
    return fts_transformed
