#!/usr/bin/env python
# -*- coding: utf-8 -*-

# intensity.py
# definitons of intensity characters

from tqdm import tqdm  # progress bar
import pandas as pd
import numpy as np
import collections


def object_area_ratio(left, right, left_areas, right_areas, unique_id=None, left_unique_id=None, right_unique_id=None):
    """
    Calculate covered area ratio or floor area ratio of objects.

    Either unique_id or left_unique_id and right_unique_id are required.

    .. math::
        \\textit{covering object area} \\over \\textit{covered object area}

    Parameters
    ----------
    left : GeoDataFrame
        GeoDataFrame containing objects being covered (e.g. land unit)
    right : GeoDataFrame
        GeoDataFrame with covering objects (e.g. building)
    left_areas : str, list, np.array, pd.Series
        the name of the left dataframe column, np.array, or pd.Series where is stored area value.
    right_areas : str, list, np.array, pd.Series
        the name of the right dataframe column, np.array, or pd.Series where is stored area value
        representing either projected or floor area.
    unique_id : str (default None)
        name of the column with unique id shared amongst left and right gdfs.
        If there is none, it could be generated by :py:func:'momepy.unique_id()'.
    left_unique_id : str, list, np.array, pd.Series (default None)
        the name of the left dataframe column, np.array, or pd.Series where is stored shared unique ID
    right_unique_id : str, list, np.array, pd.Series (default None)
        the name of the left dataframe column, np.array, or pd.Series where is stored shared unique ID

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ---------
    Schirmer PM and Axhausen KW (2015) A multiscale classiﬁcation of urban morphology.
    Journal of Transport and Land Use 9(1): 101–130.

    Examples
    --------
    >>> tessellation_df['CAR'] = mm.object_area_ratio(tessellation_df, buildings_df, 'area', 'area', 'uID')
    Calculating object area ratio...
    Merging DataFrames...
    Calculating OAR...
    Object area ratio calculated.
    """
    left = left.copy()
    right = right.copy()

    if unique_id:
        left_unique_id = unique_id
        right_unique_id = unique_id
    else:
        if left_unique_id is None and right_unique_id is not None:
            raise ValueError('left_unique_id not set.')
        if left_unique_id is not None and right_unique_id is None:
            raise ValueError('right_network_id not set.')
        if left_unique_id is None and right_unique_id is None:
            raise ValueError('Unique ID not set. Use either network_id or left_unique_id and right_unique_id.')

    print('Calculating object area ratio...')
    if not isinstance(left_areas, str):
        left['mm_a'] = left_areas
        left_areas = 'mm_a'
    if not isinstance(right_areas, str):
        right['mm_a'] = right_areas
        right_areas = 'mm_a'

    print('Merging DataFrames...')
    look_for = right[[right_unique_id, right_areas]]  # keeping only necessary columns
    look_for.rename(index=str, columns={right_areas: 'lf_area'}, inplace=True)
    objects_merged = left[[left_unique_id, left_areas]].merge(look_for, left_on=left_unique_id, right_on=right_unique_id)

    print('Calculating OAR...')

    series = objects_merged['lf_area'] / objects_merged[left_areas]

    print('Object area ratio calculated.')
    return series


def elements_count(left, right, left_id, right_id, weighted=False):
    """
    Calculate the number of elements within aggregated structure.

    Aggregated structure can be typically block, street segment or street node (their snapepd objects). Right gdf has to have
    unique id of aggregated structure assigned before hand (e.g. using :py:func:`momepy.get_network_id`).
    If weighted=True, number of elements will be divided by the area of lenght (based on geometry type) of aggregated
    element, to return relative value.

    .. math::
        \\sum_{i \\in aggr} (n_i);\\space \\frac{\\sum_{i \\in aggr} (n_i)}{area_{aggr}}

    Parameters
    ----------
    left : GeoDataFrame
        GeoDataFrame containing aggregation to analyse
    right : GeoDataFrame
        GeoDataFrame containing objects to analyse
    left_id : str
        name of the column where is stored unique ID in left gdf
    right_id : str
        name of the column where is stored unique ID of aggregation in right gdf
    weighted : bool (default False)
        if weighted=True, count will be divided by the area or length

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ----------
    1. Hermosilla T, Ruiz LA, Recio JA, et al. (2012) Assessing contextual descriptive features
    for plot-based classification of urban areas. Landscape and Urban Planning, Elsevier B.V.
    106(1): 124–137.
    2. Feliciotti A (2018) RESILIENCE AND URBAN DESIGN:A SYSTEMS APPROACH TO THE
    STUDY OF RESILIENCE IN URBAN FORM. LEARNING FROM THE CASE OF GORBALS. Glasgow.

    Examples
    --------
    >>> blocks_df['buildings_count'] = mm.elements_count(blocks_df, buildings_df, 'bID', 'bID', weighted=True)
    """
    count = collections.Counter(right[right_id])
    df = pd.DataFrame.from_dict(count, orient='index', columns=['mm_count'])
    joined = left[[left_id, 'geometry']].join(df['mm_count'], on=left_id)
    joined['mm_count'][np.isnan(joined['mm_count'])] = 0

    if weighted:
        if left.geometry[0].type in ['Polygon', 'MultiPolygon']:
            joined['mm_count'] = joined['mm_count'] / left.geometry.area
        elif left.geometry[0].type in ['LineString', 'MultiLineString']:
            joined['mm_count'] = joined['mm_count'] / left.geometry.length
        else:
            raise TypeError('Geometry type does not support weighting.')

    return joined['mm_count']


def courtyards(gdf, block_id, spatial_weights=None):
    """
    Calculate the number of courtyards within the joined structure.

    Parameters
    ----------
    gdf : GeoDataFrame
        GeoDataFrame containing objects to analyse
    block_id : str, list, np.array, pd.Series
        the name of the dataframe column, np.array, or pd.Series where is stored block ID
    spatial_weights : libpysal.weights, optional
        spatial weights matrix - If None, Queen contiguity matrix will be calculated
        based on objects. It is to denote adjacent buildings (note: based on index).

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ---------
    Schirmer PM and Axhausen KW (2015) A multiscale classiﬁcation of urban morphology.
    Journal of Transport and Land Use 9(1): 101–130.

    Examples
    --------
    >>> buildings_df['courtyards'] = mm.courtyards(buildings_df, 'bID')
    Calculating courtyards...
    Calculating spatial weights...
    Spatial weights ready...
    Courtyards calculated.

    Notes
    -----
    Script is not well optimised, so it is currently slow.
    """
    # define empty list for results
    results_list = []

    print('Calculating courtyards...')
    if not isinstance(block_id, str):
        gdf['mm_bid'] = block_id
        block_id = 'mm_bid'

    # if weights matrix is not passed, generate it from objects
    if spatial_weights is None:
        print('Calculating spatial weights...')
        from libpysal.weights import Queen
        spatial_weights = Queen.from_dataframe(gdf, silence_warnings=True)
        print('Spatial weights ready...')

    # dict to store nr of courtyards for each uID
    courtyards = {}
    components = pd.Series(spatial_weights.component_labels, index=gdf.index)
    for index, row in tqdm(gdf.iterrows(), total=gdf.shape[0]):
        # if the id is already present in courtyards, continue (avoid repetition)
        if index in courtyards:
            continue
        else:
            comp = spatial_weights.component_labels[index]
            to_join = components[components == comp].index
            joined = gdf.loc[to_join]
            dissolved = joined.geometry.buffer(0.01).unary_union  # buffer to avoid multipolygons where buildings touch by corners only
            try:
                interiors = len(list(dissolved.interiors))
            except(ValueError):
                print('Something unexpected happened.')
            for b in to_join:
                courtyards[b] = interiors  # fill dict with values
    # copy values from dict to gdf
    for index, row in tqdm(gdf.iterrows(), total=gdf.shape[0]):
        results_list.append(courtyards[index])

    series = pd.Series(results_list, index=gdf.index)
    print('Courtyards calculated.')
    return series


def blocks_count(gdf, block_id, spatial_weights, unique_id):
    """
    Calculates the weighted number of blocks

    Number of blocks within `k` topological steps defined in spatial_weights weighted by the analysed area.

    .. math::


    Parameters
    ----------
    gdf : GeoDataFrame
        GeoDataFrame containing morphological tessellation
    block_id : str, list, np.array, pd.Series
        the name of the objects dataframe column, np.array, or pd.Series where is stored block ID.
    spatial_weights : libpysal.weights
        spatial weights matrix
    unique_id : str
        name of the column with unique id used as spatial_weights index.

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ----------
    Dibble J, Prelorendjos A, Romice O, et al. (2017) On the origin of spaces: Morphometric foundations of urban form evolution.
    Environment and Planning B: Urban Analytics and City Science 46(4): 707–730.

    Examples
    --------
    >>> sw4 = mm.sw_high(k=4, gdf='tessellation_df', ids='uID')
    >>> tessellation_df['blocks_within_4'] = mm.blocks(tessellation_df, 'bID', sw4, 'uID')
    Calculating blocks...
    Blocks calculated.
    """
    # define empty list for results
    results_list = []
    gdf = gdf.copy()
    if not isinstance(block_id, str):
        gdf['mm_bid'] = block_id
        block_id = 'mm_bid'

    print('Calculating blocks...')

    for index, row in tqdm(gdf.iterrows(), total=gdf.shape[0]):
        neighbours = spatial_weights.neighbors[row[unique_id]].copy()
        if neighbours:
            neighbours.append(row[unique_id])
        else:
            neighbours = row[unique_id]
        vicinity = gdf.loc[gdf[unique_id].isin(neighbours)]

        results_list.append(len(set(list(vicinity[block_id]))) / sum(vicinity.geometry.area))

    series = pd.Series(results_list, index=gdf.index)

    print('Blocks calculated.')
    return series


def reached(left, right, unique_id, spatial_weights=None, mode='count', values=None):
    """
    Calculates the number of objects reached within topological steps on street network

    Number of elements within topological steps defined in spatial_weights. If
    spatial_weights are None, it will assume topological distance 0 (element itself).
    If mode='area', returns sum of areas of reached elements. Requires unique_id
    of streets assigned beforehand (e.g. using :py:func:`momepy.get_network_id`).

    .. math::


    Parameters
    ----------
    left : GeoDataFrame
        GeoDataFrame containing streets (either segments or nodes)
    right : GeoDataFrame
        GeoDataFrame containing elements to be counted
    unique_id : str, list, np.array, pd.Series (default None)
        the name of the right dataframe column, np.array, or pd.Series where is
        stored ID of streets (segments or nodes).
    spatial_weights : libpysal.weights (default None)
        spatial weights matrix
    mode : str (default 'count')
        mode of calculation. If `'count'` function will return the count of reached elements.
        If `'sum'`, it will return sum of `'values'`. If `'mean'` it will return mean value
        of `'values'`. If `'std'` it will return standard deviation
        of `'values'`. If `'values'` not set it will use of areas
        of reached elements.
    values : str (default None)
        the name of the objects dataframe column with values used for calculations

    Returns
    -------
    Series
        Series containing resulting values.

    Examples
    --------
    >>> streets_df['reached_buildings'] = mm.reached(streets_df, buildings_df, 'uID')
    Calculating reached count...
    Reached count calculated.

    """
    # define empty list for results
    results_list = []

    print('Calculating reached {}...'.format(mode))

    if not isinstance(unique_id, str):
        right = right.copy()
        right['mm_id'] = unique_id
        unique_id = 'mm_id'

    if mode == 'count':
        count = collections.Counter(right[unique_id])

    # iterating over rows one by one
    for index, row in tqdm(left.iterrows(), total=left.shape[0]):
        if spatial_weights is None:
            ids = [row.nID]
        else:
            neighbours = spatial_weights.neighbors[index].copy()
            neighbours.append(index)
            ids = left.iloc[neighbours].nID
        if mode == 'count':
            counts = []
            for nid in ids:
                counts.append(count[nid])
            results_list.append(sum(counts))
        elif mode == 'sum':
            if values:
                results_list.append(sum(right.loc[right[unique_id].isin(ids)][values]))
            else:
                results_list.append(sum(right.loc[right[unique_id].isin(ids)].geometry.area))
        elif mode == 'mean':
            if values:
                results_list.append(np.nanmean(right.loc[right[unique_id].isin(ids)][values]))
            else:
                results_list.append(np.nanmean(right.loc[right[unique_id].isin(ids)].geometry.area))
        elif mode == 'std':
            if values:
                results_list.append(np.nanstd(right.loc[right[unique_id].isin(ids)][values]))
            else:
                results_list.append(np.nanstd(right.loc[right[unique_id].isin(ids)].geometry.area))

    series = pd.Series(results_list, index=left.index)

    print('Reached {} calculated.'.format(mode))
    return series


def node_density(left, right, spatial_weights, weighted=False, node_degree=None, node_start='node_start', node_end='node_end'):
    """
    Calculate the density of nodes within topological steps on street network defined in spatial_weights.

    Calculated as number of nodes within k steps / cummulative length of street network within k steps.
    node_start and node_end is standard output of :py:func:`momepy.nx_to_gdf` and is compulsory.

    .. math::


    Parameters
    ----------
    left : GeoDataFrame
        GeoDataFrame containing nodes of street network
    right : GeoDataFrame
        GeoDataFrame containing edges of street network
    spatial_weights : libpysal.weights, optional
        spatial weights matrix capturing relationship between nodes within set topological distance
    weighted : bool
        if True density will take into account node degree as k-1
    node_degree : str
        name of the column of left gdf containing node degree
    node_start : str (default 'node_start')
        name of the column of right gdf containing id of starting node
    node_end : str (default 'node_end')
        name of the column of right gdf containing id of ending node

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ---------
    Dibble J, Prelorendjos A, Romice O, et al. (2017) On the origin of spaces: Morphometric foundations of urban form evolution.
    Environment and Planning B: Urban Analytics and City Science 46(4): 707–730.

    Examples
    --------
    >>> nodes['density'] = mm.node_density(nodes, edges, sw)
    Calculating node density...
    Node density calculated.

    """
    # define empty list for results
    results_list = []

    print('Calculating node density...')

    # iterating over rows one by one
    for index, row in tqdm(left.iterrows(), total=left.shape[0]):

        neighbours = spatial_weights.neighbors[index].copy()
        neighbours.append(index)
        if weighted:
            neighbour_nodes = left.iloc[neighbours]
            number_nodes = sum(neighbour_nodes[node_degree] - 1)
        else:
            number_nodes = len(neighbours)

        edg = right.loc[right['node_start'].isin(neighbours)].loc[right['node_end'].isin(neighbours)]
        length = sum(edg.geometry.length)

        if length > 0:
            results_list.append(number_nodes / length)
        else:
            results_list.append(0)

    series = pd.Series(results_list, index=left.index)
    print('Node density calculated.')
    return series


def density(gdf, values, spatial_weights, unique_id, areas=None):
    """
    Calculate the gross density

    .. math::


    Parameters
    ----------
    gdf : GeoDataFrame
        GeoDataFrame containing objects to analyse
    values : str, list, np.array, pd.Series
        the name of the dataframe column, np.array, or pd.Series where is stored character value.
    spatial_weights : libpysal.weight
        spatial weights matrix
    unique_id : str
        name of the column with unique id used as spatial_weights index
    areas :  str, list, np.array, pd.Series
        the name of the dataframe column, np.array, or pd.Series where is stored area value. If None,
        gdf.geometry.area will be used.

    Returns
    -------
    Series
        Series containing resulting values.

    References
    ---------
    Dibble J, Prelorendjos A, Romice O, et al. (2017) On the origin of spaces: Morphometric foundations of urban form evolution.
    Environment and Planning B: Urban Analytics and City Science 46(4): 707–730.

    Examples
    --------
    >>> tessellation_df['floor_area_dens'] = mm.density(tessellation_df, 'floor_area', sw, 'uID')
    Calculating gross density...
    Gross density calculated.
    """
    # define empty list for results
    results_list = []
    gdf = gdf.copy()

    print('Calculating gross density...')
    if values is not None:
        if not isinstance(values, str):
            gdf['mm_v'] = values
            values = 'mm_v'
    if areas is not None:
        if not isinstance(areas, str):
            gdf['mm_a'] = areas
            areas = 'mm_a'
    # iterating over rows one by one
    for index, row in tqdm(gdf.iterrows(), total=gdf.shape[0]):
        neighbours = spatial_weights.neighbors[row[unique_id]].copy()
        if neighbours:
            neighbours.append(row[unique_id])
        else:
            neighbours = row[unique_id]
        subset = gdf.loc[gdf[unique_id].isin(neighbours)]
        values_list = subset[values]
        if areas is not None:
            areas_list = subset[areas]
        else:
            areas_list = subset.geometry.area

        results_list.append(sum(values_list) / sum(areas_list))

    series = pd.Series(results_list, index=gdf.index)
    print('Gross density calculated.')
    return series
