from ast import literal_eval
from configparser import ConfigParser
import numpy as np

from pyaerocom import logger, print_log, var_groups
from pyaerocom.mathutils import make_binlist
from pyaerocom.obs_io import OBS_WAVELENGTH_TOL_NM
from pyaerocom.exceptions import VariableDefinitionError
from pyaerocom._lowlevel_helpers import list_to_shortstr, dict_to_str

#: helper vor checking if variable name contains str 3d or 3D
from pyaerocom.variable_helpers import parse_variables_ini, parse_aliases_ini, \
    _read_alias_ini, get_aliases, _check_alias_family
from pyaerocom.varnameinfo import VarNameInfo

class Variable(object):
    """Interface that specifies default settings for a variable

    See `variables.ini <https://github.com/metno/pyaerocom/blob/master/
    pyaerocom/data/variables.ini>`__ file for an overview of currently available
    default variables.

    Parameters
    ----------
    var_name : str
        string ID of variable (see file variables.ini for valid IDs)
    init : bool
        if True, input variable name is attempted to be read from config file
    cfg : ConfigParser
        open config parser that holds the information in config file available
        (i.e. :func:`ConfigParser.read` has been called with config file as
        input)
    **kwargs
        any valid class attribute (e.g. map_vmin, map_vmax, ...)

    Attributes
    ----------
    var_name : str
        input variable name
    var_name_aerocom : str
        AEROCOM variable name (see e.g. `AEROCOM protocol
        <http://aerocom.met.no/protocol_table.html>`__ for a list of
        available variables)
    is_3d : bool
        flag that indicates if variable is 3D
    is_dry : bool
        flag that is set based on filename that indicates if variable data
        corresponds to dry conditions.
    units : str
        unit of variable (None if no unit)
    default_vert_code : str, optional
        default vertical code to be loaded (i.e. Column, ModelLevel, Surface).
        Only relevant during reading and in case conflicts occur (e.g.
        abs550aer, 2010, Column and Surface files)
    aliases : list
        list of alternative names for this variable
    minimum : float
        lower limit of allowed value range
    upper_limit : float
        upper limit of allowed value range
    obs_wavelength_tol_nm : float
        wavelength tolerance (+/-) for reading of obsdata. Default is 10, i.e.
        if this variable is defined at 550 nm and obsdata contains measured
        values of this quantity within interval of 540 - 560, then these data
        is used
    scat_xlim : float
        x-range for scatter plot
    scat_ylim : float
        y-range for scatter plot
    scat_loglog : bool
        scatter plot on loglog scale
    scat_scale_factor : float
        scale factor for scatter plot
    map_cmap : str
        name of default colormap (matplotlib) of this variable.
    map_vmin : float
        data value corresponding to lower end of colormap in map plots of this
        quantity
    map_vmax : float
        data value corresponding to upper end of colormap in map plots of this
        quantity
    map_c_under : str
        color used for values below :attr:`map_vmin` in map plots of this
        quantity
    map_c_over : str
        color used for values exceeding :attr:`map_vmax` in map plots of this
        quantity
    map_cbar_levels : :obj:`list`, optional
        levels of colorbar
    map_cbar_ticks : :obj:`list`, optional
        colorbar ticks
    """
    literal_eval_list = lambda val: list(literal_eval(val))
    str2list = lambda val: [x.strip() for x in val.split(',')]
    str2bool = lambda val: val.lower() in ('true', '1', 't', 'yes')

    _TYPE_CONV={
        'wavelength_nm': float,
        'minimum': float,
        'maximum': float,
        'dimensions' : str2list,
        'obs_wavelength_tol_nm': float,
        'scat_xlim': literal_eval_list,
        'scat_ylim': literal_eval_list,
        'scat_loglog': str2bool,
        'scat_scale_factor': float,
        'dry_rh_max':float,
        'map_cmap' : str,
        'map_vmin': float,
        'map_vmax': float,
        'map_cbar_levels': literal_eval_list,
        'map_cbar_ticks': literal_eval_list,
        '_is_rate'      : bool
        }

    #maybe used in config
    ALT_NAMES = {'unit' : 'units'}

    plot_info_keys = ['scat_xlim',
                      'scat_ylim',
                      'scat_loglog',
                      'scat_scale_factor',
                      'map_vmin',
                      'map_vmax',
                      'map_cmap',
                      'map_c_under',
                      'map_c_over',
                      'map_cbar_levels',
                      'map_cbar_ticks']
    VMIN_DEFAULT = -np.inf
    VMAX_DEFAULT = np.inf

    @staticmethod
    def _check_input_var_name(var_name):
        if '3d' in var_name:
            var_name = var_name.replace('3d','')
        elif '3D' in var_name:
            var_name = var_name.replace('3D', '')
        elif '_' in var_name:
            raise ValueError(f'invalid variable name {var_name}. Must '
                             f'not contain underscore')
        return var_name

    def __init__(self, var_name=None, init=True, cfg=None, **kwargs):
        if var_name is None:
            var_name = 'od550aer'
        elif not isinstance(var_name, str):
            raise ValueError(f'Invalid input for variable name, need str '
                             f'type, got {type(var_name)}')
        # save orig. input for whatever reason
        self._var_name_input = var_name

        self.var_name = self._check_input_var_name(var_name)
        self._var_name_aerocom = None

        self.standard_name = None
        # Assume variables that have no unit specified in variables.ini are
        # unitless.
        self.units = '1'
        self.default_vert_code = None

        self.wavelength_nm = None
        self.dry_rh_max = 40
        self.dimensions = None
        self.minimum = self.VMIN_DEFAULT
        self.maximum = self.VMAX_DEFAULT

        self.description = None
        self.comments_and_purpose = None

        #wavelength tolerance in nm
        self.obs_wavelength_tol_nm = None

        self.scat_xlim = None
        self.scat_ylim = None
        self.scat_loglog = None
        self.scat_scale_factor = 1.0

        # settings for map plotting
        self.map_cmap = 'coolwarm'
        self.map_vmin = None
        self.map_vmax = None
        self.map_c_under = None
        self.map_c_over = 'r'
        self.map_cbar_levels = None
        self.map_cbar_ticks = None

        self._is_rate = False

        # imports default information and, on top, variable information (if
        # applicable)
        if init:
            self.parse_from_ini(self.var_name, cfg=cfg)

        self.update(**kwargs)
        if self.obs_wavelength_tol_nm is None:
            self.obs_wavelength_tol_nm = OBS_WAVELENGTH_TOL_NM

    @property
    def var_name_aerocom(self):
        """AeroCom variable name of the input variable"""
        vna = self._var_name_aerocom
        return self.var_name if vna is None else vna

    @property
    def var_name_input(self):
        """Input variable"""
        return self._var_name_input

    @property
    def is_3d(self):
        """True if str '3d' is contained in :attr:`var_name_input`"""
        return True if '3d' in self.var_name_input.lower() else False

    @property
    def is_wavelength_dependent(self):
        """Indicates whether this variable is wavelength dependent"""
        return True if self.wavelength_nm is not None else False

    @property
    def is_at_dry_conditions(self):
        """Indicate whether variable denotes dry conditions"""
        var_name = self.var_name_aerocom
        if var_name.startswith('dry'): # dry deposition
            return False
        return True if 'dry' in var_name else False

    @property
    def is_deposition(self):
        """
        Indicates whether input variables is a deposition rate

        Note
        ----
        This funtion only identifies wet and dry deposition based on the variable
        names, there might be other variables that are deposition variables but
        cannot be identified by this function.

        Parameters
        ----------
        var_name : str
            Name of variable to be checked

        Returns
        -------
        bool
            If True, then variable name denotes a deposition variables

        """
        var_name = self.var_name_aerocom
        if var_name.startswith(var_groups.drydep_startswith):
            return True
        elif var_name.startswith(var_groups.wetdep_startswith):
            return True
        elif var_name.startswith(var_groups.totdep_startswith):
            return True
        elif var_name in var_groups.dep_add_vars:
            return True
        return False

    @property
    def is_emission(self):
        """
        Indicates whether input variables is an emission rate

        Note
        ----
        This funtion only identifies wet and dry deposition based on the variable
        names, there might be other variables that are deposition variables but
        cannot be identified by this function.

        Parameters
        ----------
        var_name : str
            Name of variable to be checked

        Returns
        -------
        bool
            If True, then variable name denotes a deposition variables

        """
        var_name = self.var_name_aerocom
        if var_name.startswith(var_groups.emi_startswith):
            return True
        elif var_name in var_groups.emi_add_vars:
            return True
        return False

    @property
    def is_rate(self):
        """Indicates whether variable name is a rate

        Rates include e.g. deposition or emission rate variables but also
        precipitation

        Returns
        -------
        bool
            True if variable is rate, else False
        """
        if self.is_emission:
            return True
        elif self.is_deposition:
            return True
        elif self._is_rate:
            return True
        return False

    @property
    def is_alias(self):
        return True if self.var_name != self.var_name_aerocom else False

    @property
    def unit(self):
        """Unit of variable (old name, deprecated)"""
        from warnings import warn
        warn(DeprecationWarning('Attr. name unit in Variable '
                                'class is deprecated. Please '
                                'use units instead'))
        return self.units

    @property
    def plot_info(self):
        """Dictionary containing plot information"""
        d = {}
        for k in self.plot_info_keys:
            d[k] = self[k]
        return d

    def update(self, **kwargs):
        for key, val in kwargs.items():
            self[key] = val

    @property
    def has_unit(self):
        """Boolean specifying whether variable has unit"""
        return True if not self.units in (1, None) else False

    @property
    def lower_limit(self):
        """Old attribute name for :attr:`minimum` (following HTAP2 defs)"""
        logger.warning(DeprecationWarning('Old name for attribute minimum'))
        return self.minimum

    @property
    def upper_limit(self):
        """Old attribute name for :attr:`minimum` (following HTAP2 defs)"""
        logger.warning(DeprecationWarning('Old name for attribute minimum'))
        return self.maximum

    @property
    def unit_str(self):
        """string representation of unit"""
        if self.units is None:
            return ''
        else:
            return '[{}]'.format(self.units)

    @staticmethod
    def read_config():
        return parse_variables_ini()

    @property
    def var_name_info(self):

        return VarNameInfo(self.var_name)


    @property
    def aliases(self):
        """Alias variable names that are frequently found or used

        Returns
        -------
        list
            list containing valid aliases
        """
        return get_aliases(self.var_name)

    @property
    def long_name(self):
        """Wrapper for :attr:`description`"""
        return self.description

    def keys(self):
        return list(self.__dict__.keys())

    @staticmethod
    def _check_aliases(var_name):
        ap = parse_aliases_ini()
        aliases = _read_alias_ini(ap)
        if var_name in aliases:
            return aliases[var_name]
        return _check_alias_family(var_name, ap)

    def get_default_vert_code(self):
        """Get default vertical code for variable name"""
        if self.default_vert_code is not None:
            return self.default_vert_code
        try:
            return VarNameInfo(self.var_name_aerocom).get_default_vert_code()
        except ValueError:
            print_log.warning('default_vert_code not set for {} and '
                           'could also not be inferred'
                           .format(self.var_name_aerocom))
            return None

    def get_cmap(self):
        """
        Get cmap str for var

        Returns
        -------
        str

        """
        return self.map_cmap

    def _cmap_bins_from_vmin_vmax(self):
        """
        Calculate cmap discretisation bins from :attr:`vmin` and :attr:`vmax`

        Sets value of :attr:`map_cbar_levels`

        Raises
        ------
        AttributeError
             if :attr:`vmin` and :attr:`vmax` are not defined

        """
        if self.minimum == self.VMIN_DEFAULT or self.maximum == \
                self.VMAX_DEFAULT:
            raise AttributeError(f'need minimum and maximum to be specified '
                                 f'for variable {self.var_name} in '
                                 f'order to retrieve cmap_bins')
        self.map_cbar_levels = make_binlist(self.minimum, self.maximum)

    def get_cmap_bins(self, infer_if_missing=True):
        """
        Get cmap discretisation bins

        Parameters
        ----------
        infer_if_missing : bool
            if True and :attr:`map_cbar_levels` is not defined, try to infer
            using :func:`_cmap_bins_from_vmin_vmax`.

        Raises
        ------
        AttributeError
             if unavailable

        Returns
        -------
        list
            levels

        """
        if self.map_cbar_levels is None:
            if infer_if_missing:
                self._cmap_bins_from_vmin_vmax()
            else:
                raise AttributeError(f'map_cbar_levels is not defined for '
                                     f'variable {self.var_name}')
        return self.map_cbar_levels

    def parse_from_ini(self, var_name=None, cfg=None):
        """Import information about default region

        Parameters
        ----------
        var_name : str
            variable name
        var_name_alt : str
            alternative variable name that is used if variable name is not
            available
        cfg : ConfigParser
            open config parser object

        Returns
        -------
        bool
            True, if default could be loaded, False if not
        """
        if cfg is None:
            cfg = self.read_config()
        elif not isinstance(cfg, ConfigParser):
            raise ValueError(f'invalid input for cfg, need config parser got '
                             f'{type(cfg)}')
        if not var_name in cfg:
            try:
                var_name = self._check_aliases(var_name)
            except VariableDefinitionError:
                logger.info('Unknown input variable {}'.format(var_name))
                return
            self._var_name_aerocom = var_name

        var_info = cfg[var_name]
        # this variable should import settings from another variable
        if 'use' in var_info:
            use = var_info['use']
            if not use in cfg:
                raise VariableDefinitionError('Input variable {} depends on {} '
                                              'which is not available in '
                                              'variables.ini.'
                                              .format(var_name, use))
            self.parse_from_ini(use, cfg)

        for key, val in var_info.items():
            if key in self.ALT_NAMES:
                key = self.ALT_NAMES[key]
            self._add(key, val)

    def _add(self, key, val):
        if key in self._TYPE_CONV:
            try:
                val = self._TYPE_CONV[key](val)
            except:
                pass
        elif key == 'units' and val == 'None':
            val = '1'
        if val == 'None':
            val = None
        self[key] = val

    def __setitem__(self, key, val):
        self.__dict__[key] = val

    def __getitem__(self, key):
        return self.__dict__[key]

    def __repr__(self):
       return ("{}\nstandard_name: {}; Unit: {}"
               .format(self.var_name, self.standard_name,
                       self.units))

    def __eq__(self, other):
        if isinstance(other, str):
            other = Variable(other)
        elif not isinstance(other, Variable):
            raise TypeError('Can only compare with str or other Variable instance')
        return True if other.var_name_aerocom == self.var_name_aerocom else False

    def __str__(self):
        head = "Pyaerocom {}".format(type(self).__name__)
        s = "\n{}\n{}".format(head, len(head)*"-")

        plot_s = '\nPlotting settings\n......................'

        for k, v in self.__dict__.items():
            if k in self.plot_info_keys:
                if v is None:
                    continue
                if isinstance(v, dict):
                    plot_s += "\n{} (dict)".format(k)
                    plot_s += dict_to_str(v, indent=3,
                                         ignore_null=True)
                elif isinstance(v, list):
                    plot_s += "\n{} (list, {} items)".format(k, len(v))
                    plot_s += list_to_shortstr(v)
                else:
                    plot_s += "\n%s: %s" %(k,v)
            else:
                if isinstance(v, dict):
                    s += "\n{} (dict)".format(k)
                    s += dict_to_str(v, indent=3, ignore_null=True)
                elif isinstance(v, list):
                    s += "\n{} (list, {} items)".format(k, len(v))
                    s += list_to_shortstr(v)
                else:
                    s += "\n%s: %s" %(k,v)

        s += plot_s
        return s