import logging
from datetime import datetime, timezone
from typing import Optional, List
import pandas as pd
from zoneinfo import ZoneInfo
# Python's modern standard for timezone handling (Python 3.9+),
# replacing pytz with a cleaner, more reliable standard library approach.
import numpy as np  # used for calculations
from time import time
from byneuron import Entity
from enum import Enum
from re import findall
from functools import cached_property
from croniter import croniter

log = logging.getLogger('calc/conf')

class CHScope(Enum):
    DEFAULT = 0
    MULTI = 0
    SINGLE = 1



class CHEntitySelection(Enum):
    DEFAULT = 0
    GATEWAY_DEVICE = 0
    DEVICE_TYPE = 1
    ASSET_TYPE = 1
    PLACE_TYPE = 2
    SITE_TYPE = 2

    def __init__(self, value, tag:str=''):
        self.tag = tag

    def set_tag(self, tag:str=''):
        self.tag = f'{tag}'

    @property
    def entity_type(self):
        if self.value == 2:
            return 'Place'
        else:
            return 'Device'


class CHEventChange(Enum):
    DEFAULT = 1
    IGNORE = 0 # no checks, assumes there is no earlier data
    KEEP = 1 # write only new events
    UPDATE = 2 # write new event + updates existing events with new value when change is found

"""
class CEDJob(Enum):
    DEFAULT = 0
    ALL = 0
    POWER = 1
    ENERGY = 2
    IRRADIANCE = 3
    FORECAST = 4
    CUSTOM = 5


"""

class Aggr(Enum):
    """
    byneuron aggregations
    """
    AVG = 'avg'
    SUM = 'sum'
    MAX = 'max'
    MIN = 'min'
    CLOSE = 'close'
    DIFF = 'diff'
    OPEN = 'open'
    COUNT = 'count'
    CARD = 'cardinality'
    MAD = 'mad'


class CEDAggregation(Enum):
    """
    Set aggregation type and handle the data.
    """
    DEFAULT = {'a':Aggr.AVG}
    AVG = {'a':Aggr.AVG}
    AVG_ON = {'a':Aggr.AVG, 'z':True}
    AVG_7D = {'a': Aggr.AVG, 'e': '7d'}
    MAD_7D = {'a': Aggr.MAD, 'e': '7d'}
    SUM = {'a':Aggr.SUM}
    COUNT ={'a':Aggr.COUNT}
    CLOSE = {'a':Aggr.CLOSE}
    CLOSE_ON = {'a':Aggr.CLOSE, 'z':True}
    CLOSE_PUSH_ON = {'a':Aggr.CLOSE, 'p':True, 'z':True}
    CLOSE_CHANGE_ON = {'a':Aggr.CLOSE, 'p':True, 'c':True, 'z':True, 'e':1}
    # CLOSE_CHANGE_ON = a pushed timeseries that will output changes
    PEAK = {'a':[Aggr.MAX, Aggr.MIN]}
    MONOTONIC = {'a':[Aggr.CLOSE, Aggr.AVG, Aggr.COUNT, Aggr.MAX, Aggr.MIN], 'p':True, 'z':True, 'e':1}

    def __init__(self, conf):
        a = conf.get('a', Aggr.AVG)
        self.aggr_list : List[Aggr] = a if isinstance(a, list) else [a]
        self.extend_rows : int or str = conf.get('e', 0)
        self.gap_push: bool = conf.get('p', False) #True for _PUSH
        self.excl_zero: None or bool = conf.get('z', None)  # True for _ON; False for only zero
        self.on_change: bool = conf.get('c', False) # True for _CHANGE

    @property
    def options(self):
        return {'push':self.gap_push, 'zero':self.excl_zero}

    @property
    def aggrs(self) -> List[str]:
        return [aggr.value for aggr in self.aggr_list]

    @property
    def add_rows(self):
        return self.extend_rows

    @property
    def aggr(self):
        if len(self.aggr_list) == 1:
            return self.aggr_list[0].value

class CEDMerger(Enum):
    DEFAULT = 0
    MEAN = 0
    AVG = 0
    SUM = 1
    MAX = 2
    MIN = 3
    MEDIAN = 4 # middle value when sorted
    STD = 5 # spread from mean
    MODE = 6 # the value that appears most frequently
    COUNT = 7
    SUM_NAN = 8 # sums row; but maintain NAN if all row if NAN


    def merge(self, dfs: List[pd.Series], rounding: int) -> pd.Series:
        if not isinstance(dfs, list) or len(dfs)==0:
            return pd.Series(dtype=float)
        df = pd.concat(dfs,axis=1)
        log.debug('Merger merge df %s', df.head().to_string())
        if self == CEDMerger.MEAN:
            return df.mean(axis=1).round(rounding)
        elif self == CEDMerger.SUM:
            return df.sum(axis=1)
        elif self == CEDMerger.SUM_NAN:
            mask = df.isna().all(axis=1)
            sum = df.sum(axis=1)
            sum[mask] = np.nan # sum of nan row is 0; we need nan
            return sum
        elif self == CEDMerger.MAX:
            return df.max(axis=1)
        elif self == CEDMerger.MIN:
            return df.min(axis=1)
        elif self == CEDMerger.MEDIAN:
            return df.median(axis=1)
        elif self == CEDMerger.STD:
            return df.std(axis=1).round(rounding)
        elif self == CEDMerger.MODE:
            return df.mode(axis=1)[0]
        elif self == CEDMerger.COUNT:
            return df.count(axis=1)

class CHMonotonic(Enum):
    DEFAULT = 0
    ACCEPT = -1  # negative counters will be used too
    IGNORE = 0  # negative diffs are discarded
    BLOCK = 1  # negative diffs will block the bucket
    FIX = 2  # aggregation are handled to find a fix
    FIX_WITH_EVENTS = 3  # FIX, but additional events are handled to find a fix


class CEDUnit(Enum):
    WATT_HOUR = 'WATT_HOUR'
    KILOWATT_HOUR = 'KILOWATT_HOUR'
    WATT = 'WATT'
    KILOWATT = 'KILOWATT'
    HOUR = 'HOUR'
    VOLT = 'VOLT'
    DEFAULT = 'NUMBER'
    NUMBER = 'NUMBER'
    HERTZ = 'HERTZ'
    WATT_M2 = 'WATT_PER_SQUARE_METER'


class CEType(Enum):
    DEFAULT = 'Device'
    ASSET = 'Device'
    SITE = 'Place'


class CEDTag(Enum):
    DEFAULT = 'DEFAULT'
    VOLTAGE = 'VOLTAGE'
    POWER = 'POWER'
    ENERGY = 'ENERGY'

class CEDInterval(Enum):
    DEFAULT = ('15m', '15min')
    QUARTER_HOUR = ('15m', '15min')
    HALF_HOUR = ('30m', '30min')
    HOURLY = ('1h', 'h')
    DAILY = ('1d', 'D')
    WEEKLY = ('7d', 'W')
    MONTHLY = ('1M', 'MS')
    QUARTERLY = ('1q', 'QS')
    YEARLY = ('1y', 'YS')

    def floor(self, dt=None, tz=None):
        if tz is None:
            tz = dt.tzinfo if dt.tzinfo else timezone.utc
        t = pd.Timestamp(dt) if isinstance(dt, datetime) else pd.Timestamp.now(tz)
        if self.pd_interval in ['W', 'MS', 'QS', 'YS']:  # Fixed frequencies
            freq = self.pd_interval[0] # drop the 'S' or 'E'
            period = pd.Period(t, freq)
            # attention: period drops timezone - need to localize output
            return period.start_time.tz_localize(tz)
        else:
            return t.floor(self.pd_interval)

    @property
    def bn_interval(self):
        return self.value[0]

    @property
    def pd_interval(self):
        return self.value[1]

    def shift_datetime(self, dt:datetime, shift:int or str=1):
        if isinstance(shift, int):
            if shift > 0:
                dr = pd.date_range(
                    end=pd.Timestamp(dt),
                    periods=shift+1, # need 2 periods for 1 shift
                    freq=self.pd_interval
                )
                return dr[0].to_pydatetime()
            else:
                dr = pd.date_range(
                    start=pd.Timestamp(dt),
                    periods= 1 - shift,
                    freq=self.pd_interval
                )
                return dr[-1].to_pydatetime()
        if isinstance(shift, str):
            dr = pd.date_range(
                end=pd.Timestamp(dt),
                periods=1,  # need 2 periods for 1 shift
                freq=shift
            )
            return dr[0].to_pydatetime()

    def intervals_per_hour(self) -> int:
        if 'm' in self.bn_interval:
            return 60/int(self.bn_interval[:-1])
        elif self.bn_interval == '1h':
            return 1




class CustomJob():

    def __init__(
        self,
        label:str = 'default',
        interval: CEDInterval = CEDInterval.DEFAULT,
        cron:str = '* * * * *'
    ):
        self.label = label
        self.interval = interval
        self.cron = cron

    def __repr__(self):
        return f'Job {self.label}'

class CHTimeRange(Enum):
    DEFAULT = 0
    ACTUAL = 0 # small bucket include now
    RECENT = 1 # large bucket exclude now
    HISTORIC = 2 # 12 months exlude this month
    FORECAST = 3 # next 7 days inclusive today
    LAST_MONTHS = 4 # start of last month exclude today

    def __init__(self, value):
        self.local_tz = timezone.utc

    def set_timezone(self, tz:str = 'Africa/Nairobi'):
        if isinstance(tz, str):
            self.local_tz = ZoneInfo(tz)  #tenant attribute timeZone has Africa/Nairobi
        elif isinstance(tz, ZoneInfo):
            self.local_tz = tz
        elif tz is None:
            self.local_tz = timezone.utc

    def set_timezone_from_tenant(self, tenant:Entity):
        tz = tenant.time_zone
        if tz:
            self.set_timezone(tz)

    def get_date_range(self, job:CustomJob = None):
        if job is None:
            job = CustomJob()
        # important ! the end is handled as an exclusive edge in default byneuron aggregations
        freq = job.interval.pd_interval
        floor = job.interval.floor(tz=self.local_tz)
        # floor is now, but adjusted to interval of job
        this_month = CEDInterval.MONTHLY
        this_month = this_month.floor(tz=self.local_tz)
        this_month = job.interval.floor(dt=this_month, tz=self.local_tz)
        this_day = CEDInterval.DAILY
        this_day = this_day.floor(tz=self.local_tz)
        this_day = job.interval.floor(dt=this_day, tz=self.local_tz)
        now = pd.Timestamp.now(tz=self.local_tz)
        if self == CHTimeRange.ACTUAL:
            return pd.date_range( #### TODO
                end=now.ceil(freq),
                periods=50+1,
                freq=freq
            )
        if self == CHTimeRange.RECENT:
            return pd.date_range(
                end=now.floor(freq),
                periods=1000+1,
                freq=freq
            )
        if self == CHTimeRange.HISTORIC:
            return pd.date_range(
                start=this_month-pd.DateOffset(months=12),
                end=this_month,
                freq=freq
            )
        if self == CHTimeRange.FORECAST:
            return pd.date_range(
                start=this_day,
                end=this_day + pd.DateOffset(days=7),
                freq=freq
            )
        if self == CHTimeRange.LAST_MONTHS:
            return pd.date_range(
                start=this_month-pd.DateOffset(months=1),
                end=this_day,
                freq=freq
            )

    def iter_periods(self, job:CustomJob = None):
        chunk_size = 1500  # 3000 = 1 month with 15 minute period
        daterange = self.get_date_range(job)
        if self in [CHTimeRange.FORECAST]:
            # sorted from now -> future
            daterange.sort_values(ascending=True)
        else:
            # sorted from now -> history
            daterange.sort_values(ascending=False)
        for i in range(0, len(daterange), chunk_size):
            chunk = daterange[i: i+chunk_size+1] # +1 for maintaining an exclusive end_date
            if len(chunk) > 1:
                edges = [chunk[0].to_pydatetime(), chunk[-1].to_pydatetime()]
                yield min(edges), max(edges) # from_date (left), to_date (right)



class CEDTarget:
    MAP_TAGS = {
        CEDTag.VOLTAGE: [CEDUnit.VOLT],
        CEDTag.POWER: [CEDUnit.KILOWATT, CEDUnit.WATT],
        CEDTag.ENERGY: [CEDUnit.KILOWATT_HOUR, CEDUnit.WATT_HOUR]
    }


    def __init__(
        self,
        name: str,
        description: str,
        unit: CEDUnit,
        monotonic: bool = True,
        # interval: CEDInterval = CEDInterval.DEFAULT, <- is defined in job
        # cron:str = '* * * * *',
        ced_cron: str= '0 * * * *',
        job: CustomJob = CustomJob(),
        calculate: Optional[str] = None,
        # entity: Optional[Entity] = None,
        rounding: int = 2, # rounding in the specified unit -> different from source'rounding
        clipping: dict = None #{'lower': None, 'upper': None}  # clipping will replace values outside range with the boundairy
    ):
        self.name = name
        self.description = description
        self.unit: CEDUnit = self.set_unit(unit)
        self.tag = self.set_type(self.unit)
        self.monotonic = monotonic # needed for energy series, default is True = incremental series
        self.entity: Entity or None = None
        self.calculate = calculate
        self.rounding = int(rounding)
        self.clipping = {
            k: clipping[k] for k in ['lower', 'upper'] if k is None or isinstance(clipping[k], (float, int))
        } if isinstance(clipping, dict) else {}
        # self.df: pd.Series = pd.Series([])
        # self.events: list[Numberevent] = []
        self.job : CustomJob = job
        self.interval = job.interval
        # make sure we load the expression in the initial stage
        # when adding the targets to the definition
        self.expr = self.init_expression
        # self.job_cron = cron
        self.ced_cron = ced_cron # this will be shared from CEDefinition

    def __repr__(self):
        return f'Target {self.name} is {self.entity}'

    def match_cron(self, reference: datetime = datetime.now(timezone.utc)):
        #todo set timezone in tenant's local time
        log.debug('match_cron job %s with ced %s', self.job.cron, self.ced_cron)
        if self.job.cron in ['* * * * *', '', None]:
            return True
        # time the job should have started
        job_time = croniter(self.job.cron,reference).get_prev()
        # time the service should have started
        def_time = croniter(self.ced_cron,reference).get_prev()
        log.debug('match_cron job %s with ced %s', job_time, def_time)
        return job_time == def_time

    @property
    def scalar(self) -> float:
        return scale_to_metric(self.entity)

    def set_entity(self, e: Entity):
        self.entity = e

    def has_entity(self):
        return isinstance(self.entity, Entity) and self.entity.entity_type == 'Item'
    @cached_property
    def source_labels(self):
        return set(findall(r'\{(.*?)}', self.calculate))

    # @property
    # def source_concat_labels(self):
    #    return set(findall(r'\{(.*):(.*?)\}', self.calculate))

    @cached_property
    def valid_calculation(self):
        if self.calculate:
            if isinstance(self.calculate, str):
                if self.calculate.lower() not in ['np.nan','']:
                    return True
        return False

    @cached_property
    def init_expression(self):
        if not self.valid_calculation:
            return
        expr = self.calculate
        columns = self.source_labels
        for c in columns:
            expr = expr.replace('{'+c+'}', f"row['{c}']")
        # expr = f'lambda row: np.nan if False in row.values else {expr}'
        expr = f'lambda row: {expr}'
        log.debug('calculation expression: %s', expr)
        return eval(expr)

    def calculation(self, dfs:List[pd.Series]) -> pd.Series:
        result = pd.Series(dtype=float)
        if not isinstance(dfs, list) or len(dfs)==0:
            return result
        if not self.valid_calculation:
            log.warning('invalid calculation')
            return result
        # note; rows with any 'False' object are removed.
        log.debug('calculation from dfs %s', dfs)
        # if single series, pass the origin
        if self.calculate == '{'+''.join(self.source_labels)+'}':
            df = dfs[0]
            if df.dtype == 'object':
                mask = df.apply(lambda x: x is False)
                df = df[~mask]
                df = pd.to_numeric(df, errors='coerce')
            result = df
        else:
            # else merge data and apply expression
            try:
                df = pd.concat(dfs, axis=1)
                for c in df.columns:
                    if df[c].dtype == 'object':
                        log.warning('inspect column %s with wrong datatype in \ndf %s', c, df.head())
                        mask = df[c].apply(lambda x: x is False)
                        df = df[~mask]
                        df[c] = pd.to_numeric(df[c], errors='coerce')
                log.debug('calculation %s sources: \n%s', self.calculate, df.head().to_string())
                df = df.apply(self.expr, axis=1)
                df = df.replace([np.inf, -np.inf], np.nan)
                log.debug('calculation %s result: \n%s', self.calculate, df.head())
                result = df
            except (ValueError, TypeError, ZeroDivisionError) as e:
                log.warning('failed calculate Target %s for %s', self.name, e)
        # ATTENTION; the .div scalar will transform booleans in numbervalues 0 or 1 ; might affect expected behaviour of clipping
        # result = result.dropna().div(self.scalar).round(self.rounding).rename(self.name)
        # note - removed dropna so if a calculation itentionally creates nan (eg to remove existing events) they are persisted
        result = result.div(self.scalar).round(self.rounding).rename(self.name)
        # clip values
        if self.clipping:
            result = result.clip(**self.clipping)
            # note: NaN is not clipped
        return result


    @staticmethod
    def set_unit(u: str or CEDUnit):
        if isinstance(u, CEDUnit):
            return u
        elif isinstance(u, str):
            for ceu in CEDUnit:
                if ceu.value == u:
                    return ceu
        return CEDUnit.DEFAULT

    def set_type(self, u: CEDUnit):
        for k, v in self.MAP_TAGS.items():
            if u in v:
                return k
        return CEDTag.DEFAULT


class CEDSource:
    _SOURCE_IGNORE = ['placeholder']

    def __init__(
        self,
        label: str,
        aggregation: CEDAggregation = CEDAggregation.DEFAULT, # note that aggregation is not used for metaValues
        merger: CEDMerger = CEDMerger.DEFAULT,
        rounding: int = 3,
        from_feature_name: str = '',
        from_feature_meta: str = '',
        from_related_code: str = '',
        from_definition_regex: List[str] or None = None,
        from_definition_meta: str = '',
        #from_relation_meta: str = '',  #example 'electricDevice#ACTIVE_POWER'
        #from_target:str = '', # example use target P_nom as source for other calculations
        #from_definition: str or List[str] = '', # the label used in source_definitions.features_regex.key
        #from_entity_meta_values: str = '', # example use moduleCount from the entity
        #from_asset_model_meta_values: str = '' # example use nominalPower from the entities'assetModel

    ):
        self.label = label
        self.from_entity_meta_values = ''
        # meta_value concept:
        # Entity > we select the meta from the entity
        # Definition > from linked entity (Device) that has matching Definition (entity_regex) with a defined meta_values
        # if that (linked) entity has no matching meta_value > if assetModel; than metaValue is used from assetModel
        # the relation is either permanent (None) for enity; or the linked relation

        # naming?
        # from
        # A/ entity
        #       - metaValues
        #       - Targets (features)
        #       - assetModel's meta
        #       - definition ?
        # B/ relation -> source / target
        #       - with definition
        #           - regex exid
        #           - ?
        #       - with meta
        #           - code
        #           - boolean ?
        #       - assetModel
        #
        # from_related_with_definition
        # from_related_with_code
        # from_entity
        # get_feature_with_name #name of feature
        # get_feature_with_regex_label #from definition

        # get_item_ # default, entity is item ?
        # get_meta_value_
        #
        self.from_related_with_definition = bool(from_definition_regex) or bool(from_definition_meta)
        self.from_related_with_code = from_related_code
        self.from_entity = bool(from_feature_name) or bool(from_feature_meta)
        self.get_feature_with_name = from_feature_name
        self.get_feature_with_regex_labels = from_definition_regex if isinstance(from_definition_regex, list) else []
        self.get_meta_value = from_feature_meta or from_definition_meta

        #self.from_asset_model_meta_values = ''
        #self.from_relation_meta = from_relation_meta
        #self.from_target = from_target
        #self.from_definition = from_definition if isinstance(from_definition, list) else [from_definition]
        self.aggregation = aggregation
        self.merger = merger
        self.entities: list[tuple[Entity or None, Entity]] = []  # note: the scalar for Sources is added to the Entity
        self.meta_values: list[tuple[Entity or None, float or int]] = []
        # self.df = None
        # self.valid = True  # Flag to indicate that no targets can be build from this data
        self.rounding = int(rounding)  # rounding on metric unit > eg round 0 and 12.3456kW ==> 12345.W
        # use to reduce errors with floats
    def __repr__(self):
        return f'Source {self.label} from {[e.public_id for r, e in self.entities] if self.entities else ""}{self.meta_values}'

    def append_entity(self, serie: Entity, relation: Entity = None):
        # Note that, when no relation is set; only one serie can be appended
        if not isinstance(serie, Entity) or serie.entity_type != 'Item':
            log.warning('Source > append_entity > expected Item, not %s in %s', serie, self.label)
            return

        if serie.active is False:
            return
        if serie.name in self._SOURCE_IGNORE:
            return
        #serie.scalar = scale(serie)
        if isinstance(relation, Entity) and relation.entity_type == 'ItemDeviceRelation':
            self.entities.append((relation, serie))
        else:
            self.entities.append((None, serie))

    def append_meta_value(self, number_value:int or float = None, relation: Entity = None):
        if isinstance(number_value, (int, float)):
            self.meta_values.append((relation if isinstance(relation, Entity) else None, number_value))

    def iter_entities(self):
        log.debug('iter_entities from %s', self.entities)
        for r, s in self.entities:
            if r is None:
                yield s, datetime.min.replace(tzinfo=timezone.utc), datetime.max.replace(tzinfo=timezone.utc)
            else:
                yield s, r.from_date, r.to_date

    def iter_meta_values(self):
        for r, s in self.meta_values:
            if r is None:
                yield s, datetime.min.replace(tzinfo=timezone.utc), datetime.max.replace(tzinfo=timezone.utc)
            else:
                yield s, r.from_date, r.to_date


    def merge(self, dfs:List[pd.Series]):
        # added rounding for case of avg and std; which can create larger accuracy than expected
        log.debug(f'Source merge dfs %s', [df.name for df in dfs])
        return self.merger.merge(dfs, self.rounding)

    def scalar(self, entity:Entity):
        return scale_to_metric(entity)
def scale_to_metric(entity) -> float:
    if isinstance(entity, Entity) and entity.unit:
        if entity.unit.startswith('KILO'):
            return 1000.
    else:
        log.warning('item without unit: %s', entity)
    return 1.

class SourceDefinition():

    def __init__(self, entity_regex: str, features_regex: dict, meta_values: dict = None):
        self.entity_type = 'DeviceDefinition'
        self.regex_attribute = 'externalId'
        self.entity_regex = entity_regex
        self.features_regex = features_regex
        self.entity_keys = []
        self.meta_values = meta_values if isinstance(meta_values, dict) else {}

    def __repr__(self):
        return f"Source {self.entity_type} {self.entity_regex} with {len(self.entity_keys)} keys"

    def test_entity(self, entity: Entity) -> bool:
        if isinstance(entity, Entity):
            return entity.definition in self.entity_keys


"""
class CalculationHandlerDefinition:
    def __init__(self):
        self.ce_type = type
        self.targets: List[CEDTarget] = []
        self.sources: List[CEDSource] = []
        self.jobs: List[CustomJob] = [CustomJob()]  # preload the defaultjob
        self.ced_cron: str = '0 * * * *'  # should be cron used to activate this service; used for shedule of jobs

"""

class CalculationEntityDefinition:

    def __init__(
        self,
        #endpoint_type: CEType = CEType.DEFAULT,
        #endpoint_tag: str = '' , # to pair with the CEType
        #gateway_exid_regex = '', # to pair with
        #cron: str = '0 * * * *'
    ):
        self.ch_entity_selection = CHEntitySelection.DEFAULT
        self.targets: List[CEDTarget] = []
        self.sources: List[CEDSource] = []
        self.jobs:List[CustomJob] = [CustomJob()] # preload the defaultjob
        self.ced_cron:str = '0 * * * *'  # should be cron used to activate this service; used for shedule of jobs
        #tenant related
        self.source_definitions: List[SourceDefinition] = [] # The source definitions will be changed dependent on the tenant
        #self.tenant: Entity = None
        #self.timezone = None <- timezone is a tenant based thing
        self.gateway_devices: List[Entity] = []
        self.ch_gateway: dict ={}

    @property
    def entity_type(self):
        return self.ch_entity_selection.value

    def set_cron(self, cron:str):
        # required with option cron_jobs_enabled
        self.ced_cron = cron

    def set_gateway_exid(self, regex:str=''):
        self.ch_gateway={'exid':f'{regex}'} # for init of Connector object (name=name, exid=exid, profile=profile, **kwargs)

    def set_entity_site(self, tag: str):
        self.ch_entity_selection = CHEntitySelection.SITE_TYPE
        self.ch_entity_selection.set_tag(tag)

    def add_target(
        self,
        name: str,
        description: str,
        unit: CEDUnit,
        monotonic: bool = True,
        # only need to be explicit False if unit is (KILO)WATT_HOUR and not incremental serie (e.g. dE_fwd)
        # needed for energy series, default is True = incremental series
        calculate: Optional[str] = None,
        job: CustomJob = CustomJob(),
        rounding: Optional[int] = 2,  # rounding in the specified unit -> different from source'rounding
        clipping: Optional[dict] = None # {'lower': None, 'upper': None}
    ):
        # note on jobs.
        # if no job is set in the kwargs > a default job is created, which is already set in the jobs list
        # the next lines of code are redundant, but emphasises the assumption that every target has a job
        t = CEDTarget(
            ced_cron= self.ced_cron,
            name= name,
            description=description,
            unit=unit,
            monotonic=monotonic,
            calculate=calculate,
            job= job,
            rounding = rounding,  # rounding in the specified unit -> different from source'rounding
            clipping = clipping,

        )

        if any([t.name == name for t in self.targets]):
            log.warning('duplicated target name %s', name)
        else:
            self.targets.append(t)


    def add_source(
        self,
        label: str,
        aggregation: CEDAggregation = CEDAggregation.DEFAULT, # default to average
        merger: CEDMerger = CEDMerger.DEFAULT,  # default to average
        rounding: int = 3,  # rounding on the metric unit
        from_feature_name: str = '',
        from_feature_meta: str = '',
        from_related_code: str = '',
        from_definition_regex: List[str] = '',
        from_definition_meta: str = '',
        #from_relation_meta: str = '',
        #from_target: str = '',
        #from_definition: str or List[str] = '',
        #from_entity_meta_values: str = '',  # example use moduleCount from the entity
        #from_asset_model_meta_values: str = ''  # example use nominalPower from the entities'assetModel
    ):
        s = CEDSource(
            label = label,
            aggregation = aggregation,  # default to average
            merger = merger,  # default to average
            rounding = rounding,  # rounding on the metric unit
            from_feature_name = from_feature_name,
            from_feature_meta = from_feature_meta,
            from_related_code = from_related_code,
            from_definition_regex = from_definition_regex,
            from_definition_meta = from_definition_meta
            #from_relation_meta = from_relation_meta,
            #from_target = from_target,
            #from_definition = from_definition,
            #from_entity_meta_values = from_entity_meta_values,
            #from_asset_model_meta_values = from_asset_model_meta_values
        )
        if any([t.label == label for t in self.sources]):
            log.warning('duplicated source name %s', label)
        else:
            self.sources.append(s)

    def add_source_definitions(self, **kwargs):
        s = SourceDefinition(**kwargs)
        self.source_definitions.append(s)

    def add_job(self, label: str = 'default', interval:CEDInterval=CEDInterval.DEFAULT, cron:str = '* * * * *') -> CustomJob:
        j = CustomJob(label, interval, cron)
        if any([j.label == label for j in self.jobs]):
            log.warning('job with label %s already exists', label)
        else:
            self.jobs.append(j)
        return j

    def get_job(self, label='default') -> CustomJob or None:
        for job in self.jobs:
            if job.label == label:
                return job

    def get_definition(self, entity: Entity) -> SourceDefinition or None:
        # test if entity has a definition that has been defined
        definition_key = entity.definition
        if definition_key is None:
            return
        for sd in self.source_definitions:
            if sd.test_entity(entity):
                return sd



class CHECounter:

    def __init__(self, create:int = 0, update: int = 0, delete: int = 0):
        self.count_creates = create
        self.count_updates = update
        self.count_deletes = delete
        self.runtimes = []
        self.start = self.now
    @property
    def runtime(self):
        return round(100 * sum([sum(t.values()) for t in self.runtimes])) #ms
    def add(self, c):
        if isinstance(c, CHECounter):
            self.count_creates += c.count_creates
            self.count_updates += c.count_updates
            self.count_deletes += c.count_deletes
            self.runtimes.extend(c.runtimes)

    @property
    def now(self):
        return time()

    def time_start(self):
        self.start = self.now

    def time_stop(self, nameit):
        self.runtimes.append({nameit: self.now - self.start})

    def repr_runtimes(self):
        return ', '.join([f'{k} {round(100*v)}ms'for i in self.runtimes for k, v in i.items()])

    def __repr__(self):
        return f'Counter: create {self.count_creates}, update {self.count_updates}, delete {self.count_deletes},{self.runtime}ms, {self.repr_runtimes()}'
