"""
Written by KP_Hong <kphong16@daum.net>
2021, KP_Hong

General functions for cafle.

Attributes
----------
PY : the area unit used in Korea

Modules
-------
Area : transform between m2 and PY units. 
"""

import math
from datetime import datetime
from functools import wraps
from collections import OrderedDict
from pandas.api.types import is_numeric_dtype

__all__ = ['PY', 'Area', 'EmptyClass', 'is_iterable', 'limited', 'rounding', 
           'print_rounding', 'round_up', 'log10', 'extnddct']

PY = 1/3.305785
# PY("pyung") is the area unit used in Korea.

class Area:
    """
    Transform between m2 and PY units.
    
    Parameters
    ----------
    m2 : int, float, default None
    py : int, float, default None
    roundunit : int, default 2
    
    Attributes
    ----------
    area : tuple
        return a tuple of m2, py.
    m2 : float
        value of m2 unit
    py : float
        value of py unit
        
    Examples
    --------
    >>> ar = Area(1000)
    >>> ar.area
        (1000, 302.5)
    >>> ar.py
        302.5
    >>> ar.m2
        1000
    """
    def __init__(self, m2=None, py=None, roundunit=2):
        self._m2 = m2
        self._py = py
        self._roundunit = roundunit
        self._intlz()
        
    def _intlz(self):
        if all([self._m2 is not None, self._py is None]):
            self._py = round(self._m2 * PY, self._roundunit)
        if all([self._m2 is None, self._py is not None]):
            self._m2 = round(self._py / PY, self._roundunit)
            
    def __repr__(self):
        repr_tmp = f"{self.m2:,.0f}m2 ({self.py:,.0f}py)"
        return repr_tmp
    
    @property
    def area(self):
        return (self._m2, self._py)
        
    @property
    def m2(self):
        return self._m2
        
    @property
    def py(self):
        return self._py
        
        
class EmptyClass:
    def __init__(self):
        pass
        
    def __getattr__(self, attr):
        return self.__dict__[attr]
        
    def __getitem__(self, key):
        return self.__dict__[key]

    @property
    def dict(self):
        return self.__dict__

    @property
    def keys(self):
        return self.__dict__.keys()
        

def is_scalar(data):
    if isinstance(data, (str, int, float)):
        return True
    else:
        return False


def is_iterable(data):
    if type(data) == str:
        return False
    try:
        _ = iter(data)
        return True
    except TypeError:
        return False
        
        
def limited(val, upper=None, lower=None):
    """
    Adjust the input value between "upper" and "lower" values.
    
    Parameters
    ----------
    val : int, float
        input value
    upper : int, float, list, tuple
        the criteria for distinguishing upper values
    lower : int, float, list, tuple
        the criteria for distinguishing lower values
    
    Return
    ------
    Adjusted value between upper values and lower values.
        
    Examples
    --------
    >>> limited(100, upper=90, lower=50)
        90
    >>> limited(30, lower=[10, 40])
        40
    """

    tmp_val = val
    
    if upper is not None:
        if is_iterable(upper):
            for val_lmt in upper:
                tmp_val = min(tmp_val, val_lmt)
        else:
            tmp_val = min(tmp_val, upper)
            
    if lower is not None:
        if is_iterable(lower):
            for val_lmt in lower:
                tmp_val = max(tmp_val, val_lmt)
        else:
            tmp_val = max(tmp_val, lower)
    return tmp_val
    
    
def rounding(df, rate=None):
    rslt_df = df.copy()
    for key, item in rslt_df.items():
        if rate is not None and key in rate:
            rslt_df[key] = rslt_df[key] * 100
        if all([isinstance(val, (int, float)) for val in rslt_df[key]]):
            rslt_df[key] = rslt_df[key].fillna(0).apply(lambda x: f"{x:,.0f}")
    return rslt_df
    
    
def print_rounding(df):
    """
    Apply a separator to the number and round the number.
    
    Parameters
    ----------
    df : DataFrame
    
    Return
    ------
    DataFrame rounded and applied a separator to the number
        
    Examples
    --------
    >>> df = DataFrame({'a':[100000,     200000    ], 
                        'b':[100000.123, 200000.321], 
                        'c':['abc',      'cde'     ]})
    >>> print_rounding(df)
                 a        b    c
        0  100,000  100,000  abc
        1  200,000  200,000  cde
    """
    rslt_df = df.copy()
    for key, item in rslt_df.items():
        if is_numeric_dtype(item):
            rslt_df[key] = rslt_df[key].fillna(0).apply(lambda x: f"{x:,.0f}")
    return rslt_df


def margindf(iptval):
    """
    This method returns DataFrame or Series with sum value on the margin of it.

    Parameters
    ----------
    iptval : DataFrame, Series

    Returns
    -------
    DataFrame, Series
    """
    if type(iptval) == DataFrame:
        rsltdf = iptval.copy()
        sumdf = DataFrame(columns=rsltdf.columns, index=['Sum'])

        for key, item in rsltdf.items():
            if is_numeric_dtype(item):
                dtype = item.dtype
                sumval = item.sum()
                sumdf.loc['Sum', key] = sumval
                sumdf[key] = sumdf[key].astype(dtype)
            else:
                sumdf.loc['Sum', key] = '-'
        rsltdf = pd.concat([rsltdf, sumdf])
        return rsltdf

    if type(iptval) == Series:
        rsltsrs = iptval.copy()
        if is_numeric_dtype(rsltsrs):
            dtype = rsltsrs.dtype
            sumval = rsltsrs.sum()
        else:
            sumval = '-'
        sumsrs = Series(sumval, index=['Sum']).astype(dtype)
        rsltsrs = pd.concat([rsltsrs, sumsrs])
        return rsltsrs


def showdf(iptval, margins=False):
    """
    This method shows DataFrame or Series more easily understood.

    Parameters
    ----------
    iptval : DataFrame or Series
    margins : True or False

    Returns
    -------
    DataFrame or Series rounded and applied a separator to the number.
    And margins option makes sum row on the margin.

    Examples
    --------
    >>> df = DataFrame({'a':[100000,     200000    ],
                        'b':[100000.123, 200000.321],
                        'c':['abc',      'cde'     ]})
    >>> print_rounding(df)
                 a        b    c
        0  100,000  100,000  abc
        1  200,000  200,000  cde
    """
    if margins is True:
        iptval = margindf(iptval).copy()

    if type(iptval) == DataFrame:
        rsltdf = iptval.copy()
        for key, item in rsltdf.items():
            if is_numeric_dtype(item):
                rsltdf[key] = rsltdf[key].fillna(0).apply(lambda x: f"{x:,.0f}")
        return rsltdf

    if type(iptval) == Series:
        rsltsrs = iptval.copy()
        if is_numeric_dtype(rsltsrs):
            rsltsrs = rsltsrs.fillna(0).apply(lambda x: f"{x:,.0f}")
        return rsltsrs


def round_up(number:float, decimals:int=2):
    """
    Return a value rounded up to a specific number of decimal places.
    
    Parameters
    ----------
    number : int, float
    decimals : int
    
    Return
    ------
    float
        
    Examples
    --------
    >>> round_up(123.1231, 1)
        123.2
    """
    if not isinstance(decimals, int):
        raise TypeError("decimal places must be an integer")
    elif decimals == 0:
        return math.ceil(number)
        
    factor = 10 ** decimals
    return math.ceil(number * factor) / factor

def log10(val):
    tmpval = 0
    while True:
        val = val / 10
        if val > 0.9:
            tmpval += 1
        else:
            return tmpval

# Decorator
def listwrapper(func):
    @wraps(func)
    def wrapped(self, *args, **kwargs):
        ilen = 1
        for arg in args:
            if is_iterable(arg) is True:
                if ilen == 1:
                    ilen = len(arg)
                else:
                    if ilen != len(arg):
                        raise ValueError("All arguments should be of the same length.")
        for item in kwargs.values():
            if is_iterable(item) is True:
                if ilen == 1:
                    ilen = len(item)
                else:
                    if ilen != len(item):
                        raise ValueError("All arguments should be of the same length.")
        for i in range(ilen):
            new_args = []
            new_kwargs = {}
            for val in args:
                if is_iterable(val) is True:
                    new_args.append(val[i])
                else:
                    new_args.append(val)
            new_args = tuple(new_args)
            for key, item in kwargs.items():
                if is_iterable(item) is True:
                    new_kwargs[key] = item[i]
                else:
                    new_kwargs[key] = item
            func(self, *new_args, **new_kwargs)
    return wrapped

# Extend Dictionary
def extnddct(dct, *args):
    #dct = OrderedDict(dct)
    for val in args:
        #val = OrderedDict(val)
        dct.update(val)
    return dct

