import numpy as np
import pandas as pd


def dms2dd(degrees, minutes, seconds, direction):

    '''convert degrees, minutes, seconds to degree decimal'''

    dd = float(degrees) + float(minutes)/60 + float(seconds)/(60*60);
    if direction == 'W' or direction == 'S': 
        dd *= -1
    return dd

def utm2dd(utm_east, utm_north, utm_zone, hemisphere):
   
    '''convert utm coordinates to lon/lat (assuming WGS84 ellipsoid and horizontal datum)'''

    from pyproj import Proj

    if hemisphere == 'S':
        myProj = Proj(proj='utm', ellps='WGS84', datum='WGS84', zone=str(utm_zone), south=True)
    elif hemisphere == 'N':
        myProj = Proj(proj='utm', ellps='WGS84', datum='WGS84', zone=str(utm_zone))
    lon, lat = myProj(utm_east, utm_north, inverse=True)
    return lon, lat

def check_measurement_positions(metadata):

    '''Check if measurement position metadata is sensible

       Check if longitude/latitude outside valid bounds: -180 <-> +180 for longitude, -90 <-> +90 for latitude;
       or both longitude/latitude == 0.0 --> in the middle of the ocean. If so, the variables are set as NaN
       
       If altitude/measurement_altitude < -413 (lower than the lowest exposed land on Earth --> Dead Sea Shore), set them to be NaN
       If sampling_height < -50 (stange to be so far below station altitude), set it to be NaN
    '''
        
    #define measurement_pos_vars
    measurement_pos_vars = ['longitude','latitude','altitude','sampling_height','measurement_altitude']
    
    #iterate through all metadata columns
    for ii, _ in enumerate(metadata['longitude']['value']):

        #create dictionary for placing all current column measurement position vars
        meas_pos_vals = {}

        #iterate through measurement_pos_vars
        for measurement_pos_var in measurement_pos_vars:
        
            #try to convert all measurement position variable to floats, if cannot, overwrite variable as NaN
            try:
                meas_pos_vals[measurement_pos_var] = np.float64(metadata[measurement_pos_var]['value'][ii])
            except:
                meas_pos_vals[measurement_pos_var] = np.NaN

        #check if longitude is inside valid bounds (-180 <-> +180)
        if (meas_pos_vals['longitude'] < -180) or (meas_pos_vals['longitude'] > 180):
            meas_pos_vals['longitude'] = np.NaN

        #check if latitude is inside valid bounds (-90 <-> +90)
        if (meas_pos_vals['latitude'] < -90) or (meas_pos_vals['latitude'] > 90):
            meas_pos_vals['latitude'] = np.NaN

        #check if longitude and latitude are not both == 0.0, --> in the middle of the ocean
        if (meas_pos_vals['longitude'] == 0.0) & (meas_pos_vals['latitude'] == 0.0):
            meas_pos_vals['longitude'] = np.NaN
            meas_pos_vals['latitude'] = np.NaN

        #check if altitude <-413m (lower than lowest exposed land on Earth --> Dead Sea Shore)
        if meas_pos_vals['altitude'] < -413:
            meas_pos_vals['altitude'] = np.NaN

        #check if sampling height <-50  
        if meas_pos_vals['sampling_height'] < -50:
            meas_pos_vals['sampling_height'] = np.NaN

        #check if measurement altitude <-413m (lower than lowest exposed land on Earth --> Dead Sea Shore)
        if meas_pos_vals['measurement_altitude'] < -413:
            meas_pos_vals['measurement_altitude'] = np.NaN
    
        #overwrite metadata column with modified measurement position vars
        for measurement_pos_var in measurement_pos_vars:
            metadata[measurement_pos_var]['value'][ii] = meas_pos_vals[measurement_pos_var]

    return metadata


def get_stations_in_region(station_references, region, station_longitudes, station_latitudes):

    '''from a list of station references get all stations within that region's defined rectangular boundaries'''

    #define region specific rectangular bounds
    if region == 'EU':
        min_lon = -25.0
        max_lon = 48.0
        min_lat = 30.0
        max_lat = 75.0
    elif region == 'IP':
        min_lon = -9.60
        max_lon = 3.20
        min_lat = 35.85
        max_lat = 43.80 
    elif region == 'NA':
        min_lon = -25.0
        max_lon = 60.0
        min_lat = 0.0
        max_lat = 40.0 

    #initialise list to store relevant station references within region bounds
    relevant_station_references = []

    #iterate through station_references
    for station_ii, station_reference in enumerate(station_references):
        #append any station reference within defined region bounds to relevant_station_references
        if (station_longitudes[station_ii] >= min_lon) & (station_longitudes[station_ii] <= max_lon) & (station_latitudes[station_ii] >= min_lat) & (station_latitudes[station_ii] <= max_lat):
            relevant_station_references.append(station_reference)

    #return relevant_station_references
    return relevant_station_references


def get_unique_longitude_latitude_pairs(metadata, data):

    '''get unique longitude and latitude pairs across metadata, and return associated metadata/data indices of each pair'''

    import bisect

    #get unique longitude/latitude pairs across metadata
    all_measurement_pairs = np.array([[metadata[ii]['longitude'], metadata[ii]['latitude']] for ii in range(len(metadata))])
    unique_measurement_pairs = np.unique(all_measurement_pairs, axis=0)
    
    #get associated metadata/data indices with each unique longitude/latitude pair
    unique_pair_dictionary = {}
    for ii, unique_measurement_pair in enumerate(unique_measurement_pairs):
        unique_pair_dictionary[ii] = {'metadata_indices':[], 'data_indices':[], 'longitude':unique_measurement_pair[0], 'latitude':unique_measurement_pair[1]} 
        for jj, measurement_pair in enumerate(all_measurement_pairs):   
            if np.array_equal(unique_measurement_pair, measurement_pair):
                unique_pair_dictionary[ii]['metadata_indices'].append(jj)

        for metadata_index in unique_pair_dictionary[ii]['metadata_indices']: 
            start_data_ind = bisect.bisect_left(data['time'], metadata['meta_update_stamp'][metadata_index])
            if (metadata_index + 1) == len(metadata['meta_update_stamp']):
                end_data_ind = len(data['time'])
            else: 
                end_data_ind = bisect.bisect_left(data['time'], metadata['meta_update_stamp'][metadata_index+1])
            if (start_data_ind == end_data_ind) & (start_data_ind == len(data['time'])):
                pass
            elif start_data_ind == end_data_ind:
                unique_pair_dictionary[ii]['data_indices'].extend([start_data_ind]) 
            else:    
                unique_pair_dictionary[ii]['data_indices'].extend(range(start_data_ind, end_data_ind))
        
        unique_pair_dictionary[ii]['data_indices'] = sorted(unique_pair_dictionary[ii]['data_indices'])
        
    #return dictionary of all unique longitude/latitude pairs with associated metadata/data indices
    return unique_pair_dictionary


def get_coordinate_gridcell_indices(longitude,latitude,grid_centre_lons,grid_centre_lats):

    '''based on a specific longitude/latitude coordinate, get the gridcell indices that this point lies in.'''
    
    #get index of gridcell centre longitudes/latitudes closest to respective longitude or latitude coordinate
    lon_i = (np.abs(grid_centre_lons - longitude)).argmin() 
    lat_i = (np.abs(grid_centre_lats - latitude)).argmin() 

    return lon_i,lat_i 


def get_neighbouring_gridcell_indices(lon_i,lat_i,n_lons,n_lats,projection_crs=None,grid_centre_lons=None,grid_centre_lats=None):

    '''get the longitude/latitude indices of all grid cells which surround a defined grid cell (i.e. share a boundary, including diagonally)
       makes correction for projected grids
    '''

    import cartopy.crs as ccrs

    #if are using projected coordinates get geographic_crs for coordinates transformation
    if projection_crs is not None:
        geographic_crs = ccrs.PlateCarree()
    
    neigbouring_lon_indices = []
    neigbouring_lat_indices = []

    neighbour_lon_inds = np.arange(lon_i-1,lon_i+1.5,dtype=np.int32)
    neighbour_lat_inds = np.arange(lat_i-1,lat_i+1.5,dtype=np.int32)

    for neighbour_lat_ind in neighbour_lat_inds:
        for neighbour_lon_ind in neighbour_lon_inds:
            #do not append indices for centre gridcell
            if (neighbour_lon_ind == lon_i) & (neighbour_lat_ind == lat_i):
                continue

            #make sure lat indices are valid (handling poles convergence)
            if (neighbour_lat_ind < 0) or (neighbour_lat_ind >= n_lats):
                continue        
     
            #handle longitude wrap around
            if neighbour_lon_ind < 0:
                neighbour_lon_ind = int((n_lons - np.abs(neighbour_lon_ind)))

            if neighbour_lon_ind >= n_lons:
                neighbour_lon_ind = int((neighbour_lon_ind - n_lons))

            #if map coordinates are projected, check the current neighbour gridcell is within projection 
            #(by testing if gridbox centre coordinates can be converted to valid geographic coordinates)
            #if not get wrapped corrected gridcell instead
            if projection_crs is not None:
                geolon, geolat = geographic_crs.transform_point(grid_centre_lons[neighbour_lon_ind], grid_centre_lats[neighbour_lat_ind], projection_crs)
                if (not np.isfinite(geolon)) or (not np.isfinite(geolat)): 
                    #wrap west edge to east edge
                    if neighbour_lon_ind < (n_lons/2.):
                        neighbour_lon_ind = ((n_lons-1)-neighbour_lon_ind)-1
                    #wrap east edge to west edge
                    else:
                        neighbour_lon_ind = ((n_lons-1)-neighbour_lon_ind)+1
                    
            #append neighbour gridbox indices
            neigbouring_lon_indices.append(neighbour_lon_ind)
            neigbouring_lat_indices.append(neighbour_lat_ind)
        
    return neigbouring_lon_indices, neigbouring_lat_indices


def geodesic_destination_given_distance_and_bearing(start_point, bearing, distance_to_travel):

    '''calculate the destination latitude/longitude from an initial latitude/longitude and a given bearing and distance geodesically.
       latitude/longitude are given in decimal degrees, and distance is given in kilometres.
       the geodesic distance is the shortest distance on the surface of an ellipsoidal model of the earth ('WGS-84' is the model used in this instance).
       the algorithm uses the method given by Karney (2013): https://link.springer.com/article/10.1007%2Fs00190-012-0578-z 
       this is accurate to round-off and always converges. This improves on Vincenty (1975), which is only accurate to 0.2 mm, and for which the distance calculation fails to converge for nearly antipodal points.
    '''

    from geopy import distance
    
    d_instance = distance.geodesic(kilometers = distance_to_travel, ellipsoid='WGS-84')
    destination_point = d_instance.destination(point=start_point, bearing=bearing)  

    return [destination_point.longitude, destination_point.latitude]


def great_circle_destination_given_distance_and_bearing(start_point, bearing, distance_to_travel):

    '''calculate the destination latitude/longitude from an initial latitude/longitude and a given bearing and distance using the great-circle distance.
       latitude/longitude are given in decimal degrees, and distance is given in kilometres.
       the great-circle distance uses a spherical model of the earth, using the mean earth radius as defined by the International Union of Geodesy and Geophysics, (2a + b)/3 = 6371.0087714150598 kilometers approx 6371.009 km (for WGS-84), resulting in an error of up to about 0.5%.
    '''

    from geopy import distance

    d_instance = distance.great_circle(kilometers = distance_to_travel)
    destination_point = d_instance.destination(point=start_point, bearing=bearing)  

    return [destination_point.longitude, destination_point.latitude]


def calculate_destination_points_perimeter(lon1,lat1,distance_to_travel,distance_calculation='geodesic'):

    '''given an initial longitude/latitude point, and a given distance, iteratively find destination longitude/latitude points 360 degrees around the initial point. 
       latitude/longitude are given in decimal degrees, and distance is given in kilometres.
       the distance calculation used can be 'great-circle' or 'geodesic' (default is 'geodesic').
    '''
    
    from geopy import Point

    #create geopy Point instance for an initial latitude and longitude
    start_point = Point(lat1, lon1)

    #lists for storing points around 360 degree perimeter
    perimeter_points = []
    coordinates_NS = []
    coordinates_EW = []

    #iterate through 360 degrees
    for bearing in np.arange(0,360,1.0):
        #calculate destination point, using chosen distance calculation algorithm
        if distance_calculation == 'geodesic':
            destination_point = geodesic_destination_given_distance_and_bearing(start_point, bearing, distance_to_travel)
        elif distance_calculation == 'great-circle':
            destination_point = great_circle_destination_given_distance_and_bearing(start_point, bearing, distance_to_travel)

        #save coordinates
        coordinates_NS.append(destination_point[1])
        coordinates_EW.append(destination_point[0])
        perimeter_points.append(destination_point)

    #test for longitude wrapping (max difference between sequential non-sorted EW points >= 180)
    if np.max(np.abs(np.diff(coordinates_EW))) >= 180:
        wrapping = True
    else:
        wrapping = False

    #get N/E/S/W extremites (handling wrapping and longitudinal extents >= 180 degs.)
    if wrapping:
        sorted_coordinates_EW = np.sort(coordinates_EW)
        max_diff_ind = np.argmax(np.diff(sorted_coordinates_EW))
        perimeter_extreme_points = {'N':np.max(coordinates_NS),'E':sorted_coordinates_EW[max_diff_ind],'S':np.min(coordinates_NS),'W':sorted_coordinates_EW[max_diff_ind+1]}
    else:
        perimeter_extreme_points = {'N':np.max(coordinates_NS),'E':np.max(coordinates_EW),'S':np.min(coordinates_NS),'W':np.min(coordinates_EW)}

    return perimeter_points, perimeter_extreme_points


def get_relevant_gridbox_indices(perimeter_points,perimeter_extreme_points,grid_centre_lons,grid_edge_lons,grid_centre_lats,grid_edge_lats,projection_crs=None):

    '''taking a list of lists containing longitude/latitude points of a certain geodesic distance surrounding a fixed longitude/latitude point, get indices of gridcells which intersect the perimeter of the points. 
       a gridbox is taken to be inside the point extents if any part of the gridbox intersects with the perimeter of points, an gridbox intersection fraction is also returned. 
       a simple reduction in operations is done first by reducing the grid to a square/rectangle of grid cells surrounding the perimeter of points (done by using the most extreme located points N/E/W/S).
       makes assumption grid values run south to north, west to east.
    '''

    import cartopy.crs as ccrs
    from shapely.geometry import Polygon

    #if are using projected coordinates get geographic_crs for coordinates transformation
    if projection_crs is not None:
        geographic_crs = ccrs.PlateCarree()

    #make shapely polygon
    perimeter_polygon = Polygon(perimeter_points)

    #test validity of perimeter polygon
    #if the initial coordinates are very close to poles, there can be some self-intersections. 
    #in these very rare cases, all the gridcells in the square/rectangle surrounding perimeter points are taken as intersecting with 100% fraction 
    #it also breaks down when crossing antimeridian (-180/180)
    perimter_validity = perimeter_polygon.is_valid

    #reduce grid to solely square/rectangle surrounding perimeter points (done by finding closest grid centre longitudes/latitudes to perimeter point extremities N/E/W/S).
    #explicitly handle longitude wrap around
    
    #find closest northerly latitude gridbox centre
    closest_lat_north_index = (np.abs(grid_centre_lats - perimeter_extreme_points['N'])).argmin()  
    #find closest southerly latitude gridbox centre
    closest_lat_south_index = (np.abs(grid_centre_lats - perimeter_extreme_points['S'])).argmin() 
    #find closest easterly longitude gridbox centre
    closest_lon_east_index = (np.abs(grid_centre_lons - perimeter_extreme_points['E'])).argmin()
    #find closest westerly longitude gridbox centre
    closest_lon_west_index = (np.abs(grid_centre_lons - perimeter_extreme_points['W'])).argmin() 

    #create arrays of longitude/latitude indices for relevant gridboxes 
    relevant_centre_lat_indices = np.arange(closest_lat_south_index,closest_lat_north_index+0.5,1,dtype='int')
    if closest_lon_east_index < closest_lon_west_index:
        relevant_centre_lon_indices = np.arange(closest_lon_west_index,len(grid_centre_lons),1,dtype='int')
        relevant_centre_lon_indices = np.insert(relevant_centre_lon_indices,0,np.arange(0,closest_lon_east_index+0.5,1,dtype='int'))
    else:
        relevant_centre_lon_indices = np.arange(closest_lon_west_index,closest_lon_east_index+0.5,1,dtype='int')

    #determine which gridcells intersect with points perimeter
    #define list for storing intersecting longitude/latitude grid indices, and a gridbox intersection fraction with perimeter (as lists of indices: [lon_i,lat_i,intersect_fraction])
    relevant_grid_indices = [] 
    #iterate through gridcells
    for lat_i in relevant_centre_lat_indices:
        for lon_i in relevant_centre_lon_indices:
            #if coordinates are projected, test if gridbox centre coordinates can be converted to valid geographic coordinates, if not continue to next gridbox
            if projection_crs is not None:
                geolon, geolat = geographic_crs.transform_point(grid_centre_lons[lon_i], grid_centre_lats[lat_i], projection_crs)
                if (not np.isfinite(geolon)) or (not np.isfinite(geolat)):
                    continue
            #if the perimeter polygon is not valid then assume all gridcells in the square/rectangle surrounding the perimeter points all intersect with perimeter with 100% fraction 
            if not perimter_validity:      
                relevant_grid_indices.append([lon_i,lat_i,1.0])                
            else:
                #create Shapely Polygon for current gridcell iterating over   
                gridbox = Polygon([[grid_edge_lons[lon_i],grid_edge_lats[lat_i]], [grid_edge_lons[lon_i],grid_edge_lats[lat_i+1]], [grid_edge_lons[lon_i+1],grid_edge_lats[lat_i+1]], [grid_edge_lons[lon_i+1],grid_edge_lats[lat_i]]])          
                #get area of gridcell
                gridbox_area = gridbox.area
                #get area of intersection inside gridcell with perimeter
                gridbox_intersection_area = perimeter_polygon.intersection(gridbox).area

                #write out if gridbox_intersection_area not equal to 0.0 (i.e. have some intersection)
                if gridbox_intersection_area != 0.0:
                    #get fraction of intersection inside gridcell
                    gridbox_intersection_fraction = 1./(gridbox_area/gridbox_intersection_area)
                    #write out
                    relevant_grid_indices.append([lon_i,lat_i,gridbox_intersection_fraction])

    return relevant_grid_indices


def reverse_geocode_station_information(longitude=np.NaN, latitude=np.NaN):

    '''return station information found using reverse geocoding (i.e. with longitude/latitude coordinate information)
       uses reverse_geocoder module to do this.'''

    import reverse_geocoder as rg
    from GHOST_standards import country_ISO3166_to_name
    
    #if one of longitude/latitude == np.NaN, return empty dict
    if (pd.isnull(longitude)) or (pd.isnull(latitude)):
        return {} 

    #define station coordinates for reverse geocoding search
    coords = (latitude, longitude)
    #do search
    results = rg.search(coords,verbose=False)[0]

    #ensure search results for station are not massively different location than the one searched for (i.e. desired and returned coordinates within 5.0 decimal degrees =~ 555km)
    if (np.abs(longitude - float(results['lon'])) > 5.0) or (np.abs(latitude - float(results['lat'])) > 5.0):
    #if significantly different, return empty dict
        return {}

    #define dictionary to store standardised reverse geocode information 
    geocode_info = {}

    #get country name from ISO3166 country code
    if results['cc'] != '':
        geocode_info['country'] = country_ISO3166_to_name[results['cc']]
        
    #get city name
    if results['name'] != '':
        geocode_info['city'] = results['name']
        
    #get administrative country division 1 
    if results['admin1'] != '':
        geocode_info['administrative_country_division_1'] = results['admin1']

    #get administrative country division 2 
    if results['admin2'] != '':
        geocode_info['administrative_country_division_2'] = results['admin2']
    
    return geocode_info


def local_timezone_name(longitude=np.NaN,latitude=np.NaN):

    '''get local timezone region as string, given a longitude/latitude coordinate pairing
       this is done using the TimezoneFinder module
       if a local timezone cannot be found the tolerances in the function are modified iteratively until one is found.
       this times out after 1 minute, returning NaN.
    '''

    import copy
    import pytz
    import time
    from timezonefinder import TimezoneFinder
    timezone_finder_obj = TimezoneFinder()

    #if one of longitude/latitude == np.NaN, return NaN
    if (pd.isnull(longitude)) or (pd.isnull(latitude)):
        return np.NaN

    #get local tz using timezonefinder module
    #if coordinates are not over land, an ocean timezone is returned
    #if no timezone has been matched, None is returned
    local_tz = timezone_finder_obj.timezone_at(lat=latitude,lng=longitude)

    #try get pytz timezone object from local timezone string
    if local_tz is not None:
        try:
            pytz_local_tz = pytz.timezone(local_tz)
        except:
            local_tz = None

    #if don't have local_tz or pytz has had a fit and will not recognise a timezone,
    #then force it to find different timezones until it recognises one.
    #iteratively change coordinates by +-1 degree lng and +-1 degree lat, until get match
    #force this evaluation to at a maximum take 1 minute before timing out.

    if local_tz is None:

        #define start search radius (1.0 degree)
        search_radius = 1.0
        counter =  0

        #get end timer point
        end_time_point = time.time() + 60
        while local_tz is None:

            #try to find the local timezone for 1 minute, otherwise break
            if time.time() > end_time_point:
                local_tz = None
                break

            #if counter has reached 4, then reset it to 0, and increase search radius by 1 degree
            if counter == 4:
                counter = 0
                search_radius += 1.0

            #define search coordinates
            if counter == 0:
                search_longitude = longitude + search_radius
                search_latitude = copy.deepcopy(latitude)
            elif counter == 1:
                search_longitude = copy.deepcopy(longitude)
                search_latitude = latitude + search_radius
            elif counter == 2:
                search_longitude = longitude - search_radius
                search_latitude = copy.deepcopy(latitude)
            elif counter == 3:
                search_longitude = copy.deepcopy(longitude)
                search_latitude = latitude - search_radius

            #iterate counter
            counter+=1

            #do not permit invalid latitudes to be searched
            if np.abs(search_latitude) > 90.0:
                continue 
            
            #wrap longitudes
            if search_longitude < -180.0:
               search_longitude = 180 - (np.abs(search_longitude) - 180.0)
            elif search_longitude > 180.0:
                search_longitude = -180 + (search_longitude - 180.0)

            #get local timezone string
            local_tz = timezone_finder_obj.timezone_at(lat=search_latitude,lng=search_longitude)

            #get pytz timezone object from local timezone string
            #occasionally pytz will have a fit and not recognise a timezone
            #force it to run until it recognises one
            if local_tz is not None:
                try:
                    pytz_local_tz = pytz.timezone(local_tz)
                except:
                    local_tz = None
                
    #have valid local timezone string?
    if local_tz is not None:
        return local_tz
    #if not valid, return NaN
    else:
        return np.NaN