import logging
from re import match
from datetime import datetime
from copy import copy, deepcopy
# hints
from typing import List, Dict
from collections.abc import Callable
# packages
from connector import Connector
from byneuron import Entity, Numberevent
from interactx import select_from_list
# locals
from .configurator import CalculationEntityDefinition, CHScope, CHTimeRange, CHEntitySelection
from .configurator import CEDAggregation, CEDTarget, CEDSource, CEDMerger, scale_to_metric, CEDTag, CEDInterval
from .configurator import CHEventChange, CHMonotonic, CHECounter
from .configurator import CustomJob

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

class CalculationHandler(Connector):
    # Handling:
    # A/ Scope: Multiple (async) or Single
    # B/ TimeRange: Actual (for cron based Task intervals), Recent (periodic updates), Historic (init/edit)

    def __init__(self, ced = CalculationEntityDefinition or None): #name=None, exid=None, profile=False, **kwargs):
        if not isinstance(ced, CalculationEntityDefinition):
            ced = CalculationEntityDefinition()
        self.ced = ced

        # kwargs.update({'exid': "(?i).*electric.*device.*"})
        self.ch_scope = CHScope.DEFAULT
        self.ch_timerange = CHTimeRange.DEFAULT
        # self.ch_timezone = timezone.utc
        self.ch_event_change = CHEventChange.DEFAULT
        self.ch_monotonic = CHMonotonic.DEFAULT
        self.ch_use_cron = False
        self.ch_export_xls = False
        self.ch_allow_create_target = True
        self.ch_allow_create_events = True
        self.gatewaydevice_buffer = []
        super(CalculationHandler, self).__init__(**self.ced.ch_gateway)
        # self.gw_name_regex = name
        #         self.gw_exid_regex = exid
        #         self.gw_active = True
        #         self.gd_active = True
        #         self.pf_required = bool(profile)

    def set_scope(self, scope:CHScope):
        # if single: select device of site
        # if multiple: iterate indexsets and set up async handler per site or device
        self.ch_scope = scope

    def set_scope_single(self):
        self.set_scope(CHScope.SINGLE)

    def set_timerange(self, tr:CHTimeRange): #todo set the timeranges to the timezone of the tenant
        # if timerange is larger than thershold of 50 days; we'll split the aggregations in chunks
        self.ch_timerange = tr

    def set_timerange_recent(self):
        self.set_timerange(CHTimeRange.RECENT) #7days

    def set_timerange_last_month(self):
        self.set_timerange(CHTimeRange.LAST_MONTHS) #7days

    def set_timerange_historic(self):
        self.set_timerange(CHTimeRange.HISTORIC) #1year

    def set_event_change(self, event_change:CHEventChange):
        self.ch_event_change = event_change

    def set_event_updates(self):
        self.set_event_change(CHEventChange.UPDATE)

    def set_event_all(self):
        self.set_event_change(CHEventChange.IGNORE)

    def set_demo_mode(self, demo=True):
        self.ch_allow_create_target = False if demo else True
        self.ch_allow_create_events = False if demo else True

    def set_monotonic_fix_basic(self):
        self.ch_monotonic = CHMonotonic.FIX

    def set_monotonic_fix_deep(self):
        self.ch_monotonic = CHMonotonic.FIX_WITH_EVENTS

    def set_cron_jobs_enabled(self):
        self.ch_use_cron = True
        # attention for ced.set_con(); defaults to 0 * * * * [Hourly]

    def set_export_xls(self):
        self.ch_export_xls = True

    def iter_entity(self):
        # this is the main entrypoint
        # first load the configuration for this calculation service
        # based on the config, iterate the tenants and create a local copy with tenant based details
        # than navigate between the calculation endponts (devices/places)

        ches: CHEntitySelection = self.ced.ch_entity_selection
        ches_tag: str = ches.tag
        ches_type: str = ches.entity_type

        gateway_devices = []
        indexsets = []
        active_ce_with_tag = []

        if ches == CHEntitySelection.GATEWAY_DEVICE:
            gateway_devices = [gd for gw, gd, pf in self.iter_gateways()]
            indexsets = list({self.get_indexset(gd.indexset) for gd in gateway_devices if gd})
        elif ches.tag:
            active_ce_with_tag = [e for e in self.get_entities(indexset=True, entitytype=ches_type, type=ches_tag) if e.active]
            indexsets = list({self.get_indexset(d.indexset) for d in active_ce_with_tag})
        else:
            log.warning('missing tag to select entities of type', ches_type)
            return
        indexsets.sort(key=lambda x: x.name, reverse=False)
        if self.ch_scope == CHScope.SINGLE:
            indexsets = [select_from_list(indexsets, required=True, allow_skip=False)]
        for indexset in indexsets:
            log.info(f'iter_entity > iterate indexset {indexset}')
            ch_local = deepcopy(self)
            ch_local.set_indexset(indexset)
            # tenant related settings
            tenant_timezone_string = ch_local.tenant_time_zone(indexset)
            ch_local.ch_timerange.set_timezone(tenant_timezone_string) # timezone is set in timerange; wich has deepcopy from calculation handler
            gateway_devices_local = [gd for gd in gateway_devices if gd.indexset == indexset.key]
            ced_local = deepcopy(self.ced)
            ch_local.set_asset_models()
            for sd in ced_local.source_definitions:
                for e in self.get_entities(sd.entity_type, indexset=indexset, **{sd.regex_attribute: f'regex:{sd.entity_regex}'}):
                    sd.entity_keys.append(e.key)
                    log.info('found matching %s for sourceDefinition %s', e, sd)
            # endpoints in this tenant
            endpoints = {}
            if ches == CHEntitySelection.GATEWAY_DEVICE:
                for gd in gateway_devices_local:
                    log_serie = self.get_log_series(gd)
                    for e in self._iter_devices(gd):
                        endpoints.update({e: log_serie})
            elif ches_tag:
                log_serie = {}
                for e in active_ce_with_tag:
                    if e.indexset == indexset.key:
                        endpoints.update({e: log_serie})
                        endpoints.update({e: log_serie})
            endpoints_sorted = [e for e in endpoints]
            endpoints_sorted.sort(key=lambda x: f'{x}')
            if self.ch_scope == CHScope.SINGLE:
                endpoints_sorted = [select_from_list(endpoints_sorted, required=True, allow_skip=False)]
            for e in endpoints_sorted:
                log_serie = endpoints.get(e)
                yield CalculationHandlerEntity(
                    entity = e,
                    handler = ch_local,
                    ced = ced_local,
                    log_series = log_serie
                )

    def get_log_series(self, gd):
        log.info(f'init log_series for gd {gd}')
        result = {}
        counters = ['create','update','delete']
        if not isinstance(gd, Entity):
            return {}
        q = [
            f'IndexSet:?is entity:key {gd.indexset}.',
            f'Device:?e entity:key {gd.key}; link:hasFeature Item:?i.',
            f'Item:?i attribute:name regex:".*_counter"; sort+ attribute:publicId.',
        ]
        data = self.query(q, 'i')
        log.info(f'log_series data {data}')
        if data is not None:
            data = {e.name: e for e in data.values()}
            for c in counters:
                name = f'{c}_counter'
                if name in data:
                   result.update({c:data.get(name)})
                else:
                    #create
                    config = {
                        'name': name,
                        'externalId': f'{gd.public_id}/{name}',
                    }
                    e, d = self._create_linked_item(config, gd)
                    if e:
                        result.update({c: e})
        log.info('get_log_series %s', result)
        return result #{'update':Entity}

    def _create_linked_item(self, config: dict, device: Entity):
        # create target
        default = None, None
        log.info("item_creation request %s", config)
        if self.ch_allow_create_target:
            self.set_indexset(device.indexset)
            # test if externalId already exists?
            if not 'externalId' in config:
                log.warning('item creation requires externalId in config %s', config)
                return default
            e = self.get_entity('Item', external_id=config.get('externalId'))
            if e:
                log.warning('existing item with externalId %s; try to link', config.get('externalId'))
            else:
                e = self.create_item(**config)
                if not e:
                    log.warning('failed creation of item with config %s', config)
                    return default
                log.info('created item %s', e)
            device = self.set_features(parent=device, children=[e])
            if not device:
                log.warning('could not link item %s with device %s', e, device)
                return e, None
            return e, device
        return default


class CalculationHandlerEntity:
    # this base class focusses on the handler/connector/byneuron parts,
    # the extended CalculationEntity will do the handling of calculations

    map_mergers = {
        'sum': CEDMerger.SUM,
        'mean': CEDMerger.MEAN,
        'avg': CEDMerger.MEAN,
        'max': CEDMerger.MAX,
        'min': CEDMerger.MIN
    }
    def __init__(
        self,
        entity: Entity,
        handler: CalculationHandler,
        ced: CalculationEntityDefinition,
        log_series: Dict[str,Entity]
    ):
        # we recreated these object for clarity were they originate
        self.counter = CHECounter()
        self.log_series = log_series
        log.info('log_series in CalculationHandlerEntity %s', log_series)
        self.handler_jobs = ced.jobs
        self.handler_entity_type = ced.entity_type
        self.ced = ced
        # self.handler_source_definitions = ced.source_definitions
        self.handler_sources : List[CEDSource] = ced.sources
        self.handler_targets : List[CEDTarget] = ced.targets
        self.handler_event_change = handler.ch_event_change
        self.handler_allow_create_target = handler.ch_allow_create_target
        self.handler_allow_create_events = handler.ch_allow_create_events
        self.handler_monotonic = handler.ch_monotonic
        self.handler_timerange: CHTimeRange = handler.ch_timerange
        self.handler_use_cron_jobs = handler.ch_use_cron
        self.handler_ch_export_xls = handler.ch_export_xls

        self.datamodel_loaded = False
        self.datamodel_entity = ' attribute:active "true";'
        self.datamodel_relation = ' attribute:active "true";'
        self.datamodel_source = ' attribute:active "true";'
        #self.target_a = Target('key', 'name', 'descr', 'unit', 'tag', 'calculation', 'job')
        #self.source_x = Source('key', 'retrieval avg sum ex_zero', 'query meta or definitiontag')
        self.entity: Entity = entity
        self.bn:CalculationHandler = handler  # handler is an extended byneuron object
        self.targets: List[CEDTarget] = []
        self.sources: List[CEDSource] = []
        #strictly we need only ralationDates and itemKeys

    def __repr__(self):
        return f'Entity in CalulationHandler for {self.entity}'

    def _query(self):
        if self.datamodel_loaded:
            return
        self.datamodel_loaded = True
        self.counter.time_start()
        if not self.entity:
            log.warning('missing entity for query')
            return
        qt = self.entity.entity_type
        q = [
            f'IndexSet:?is entity:key {self.entity.indexset}.',
            f'{qt}:?e entity:key {self.entity.key};{self.datamodel_entity} link:hasFeature Item:?t.',
            f'Device{qt}Relation:?sdr {self.datamodel_relation} link:hasTarget #{qt}:?e; link:hasSource Device:?sd.',
            f'Device:?sd {self.datamodel_source} sort+ attribute:publicId; link:hasFeature Item:?sdi.'
            # f'Device:?sd {self.datamodel_source} sort+ attribute:publicId; link:isImplementationOf DeviceDefinition:?sdd.'
            # f'Device:?meter {active}; link:hasReference AssetModel:?model.',
            f'Item{qt}Relation:?sir {self.datamodel_relation} link:hasTarget #{qt}:?e; link:hasSource Item:?si.',
            f'Item:?si {self.datamodel_source} sort+ attribute:publicId.'
            # f'{qt}PlaceRelation:?dpr {self.datamodel_relation}; link:hasSource #{qt}:?gdd; link:hasTarget Place:?place.',
        ]
        data = self.bn.query(q, ['t', 'sd', 'sdi', 'si', 'sdr', 'sir'])  # , 'sdd'])
        log.info('query data %s', data)
        # features  = targets
        t = data.get('t',{})
        # relation hasSource Item
        si = data.get('si', {})
        sir = data.get('sir', {})
        # relation hasSource Device
        sdi = data.get('sdi', {})
        sd = data.get('sd', {})
        sdr = data.get('sdr', {})

        featured_items = [e for e in t.values()]
        related_items = {r: si.get(r.source) for r in sir.values()}
        related_devices = {r: sd.get(r.source) for r in sdr.values()}
        related_device_featured = [e for e in sdi.values()]

        self._query_map_targets(
            featured_items=featured_items
        )
        self._query_map_sources(
            featured_items=featured_items,
            related_items=related_items,
            related_devices=related_devices,
            related_device_featured = related_device_featured
        )
        self.counter.time_stop('query')

    def _query_map_targets(self, featured_items: List[Entity]):
        # iterate targets
        # query
        # select target key from target_entities based on name
        # if not found create target
        self.targets=[]
        targets_name = {e.name: e for e in featured_items}
        for t in self.handler_targets:
            target = copy(t)
            if t.name in targets_name:
                target.set_entity(targets_name[t.name])
            elif target.has_entity():
                # option to have target assigned manually on declaration ?
                continue
            else:
                item_creation = {
                    'name': t.name,
                    'description': t.description,
                    'unit': t.unit.value,
                    'type': t.tag.value,
                    'externalId': f'{self.entity.public_id}/{t.name}',
                    'metaBooleans': {'monotonic': t.monotonic} if t.tag == CEDTag.ENERGY else {}
                }
                #create target
                e, d = self.bn._create_linked_item(item_creation, self.entity)
                if e:
                    target.set_entity(e)
                if d:
                    self.entity = d
            self.targets.append(target)
        log.info('targets set %s', self.targets)

    def _query_map_sources(
        self,
        featured_items: List[Entity],
        related_items: Dict[Entity, Entity],
        related_devices: Dict[Entity, Entity],
        related_device_featured: List[Entity],
    ):

        def get_meta_value(entity, meta_value_key):
            if not isinstance(entity, Entity):
                return
            if not meta_value_key:
                return
            meta_value = (entity.meta_values or {}).get(meta_value_key)
            # Catch both None (no metaValue) and 0.0 (metaValue created, but not set)
            if not meta_value and entity.asset_model:
                asset_model = self.bn.get_asset_model(key=entity.asset_model)
                meta_value = (asset_model.meta_values or {}).get(meta_value_key)
            return meta_value

        def iter_related_features(entity):
            if not isinstance(entity, Entity):
                return
            for feature_item_key in entity.items:
                for e in related_device_featured:
                    if e.key == feature_item_key:
                        yield e

        self.sources = []
        # iterate sources
        # if item_meta iterate sources based on relation from source_items
        # if device_tag iterate for labels in source_devices
        # Source.keys = [{key, from, to}]
        # 0. initialise
        for s in self.handler_sources:
            self.sources.append(deepcopy(s))

        for s in self.sources:
            if s.from_entity:  # relation is None
                if s.get_feature_with_name:
                    for e in featured_items:
                        if e.name == s.get_feature_with_name:
                            s.append_entity(serie=e, relation=None)
                if s.get_meta_value:
                    s.append_meta_value(get_meta_value(self.entity, s.get_meta_value), None)
            if s.from_related_with_code:
                for r, e in related_items.items():
                    if s.from_related_with_code in (r.meta_indexset_codes or {}).values():
                        # note: we ignore the keys assigned to the codes
                        # example > {'electricDevice': 'electricDevice#FREQUENCY'}
                        s.append_entity(serie=e, relation=r)
            if s.from_related_with_definition:
                for r, e in related_devices.items():
                    sd = self.ced.get_definition(entity=e)
                    if sd is None:
                        continue
                    for label in s.get_feature_with_regex_labels:
                        regex = sd.features_regex.get(label)
                        if regex:
                            for e_feature in iter_related_features(e):
                                # note external_id assumption
                                if match(regex, e_feature.external_id):
                                    s.append_entity(serie=e_feature, relation=r)
                    meta_value_key = sd.meta_values.get(s.get_meta_value)
                    if meta_value_key:
                        s.append_meta_value(get_meta_value(e, meta_value_key), r)

        log.info('sources set %s', self.sources)

    def aggregation_records(
        self,
        entity:Entity,
        aggregation:CEDAggregation,
        interval:CEDInterval,
        from_date:datetime,
        to_date:datetime
    ):
        for i in self.bn.get_aggregation_dates(
            item=entity,
            aggrs=aggregation.aggrs,
            interval=interval.bn_interval,
            from_date=from_date,
            to_date=to_date,
            **aggregation.options
        ):
            yield i

    def event_records_dates(self, entity, from_date, to_date, size):
        log.info('event_records_dates %s', entity)
        for e in self.bn.get_numberevents_dates(entity, from_date, to_date, extend=True, size=max(1000,size)):
            yield e

    def event_record_keys(self, keys):
        for e in self.bn.get_numberevent_keys(keys=keys, indexset=self.entity.indexset):
            yield e

    @staticmethod
    def scalar(entity):
        return scale_to_metric(entity)

    def events_create_with_counter(self, events:List[Numberevent], log_tag:str=None):
        if len(events)==0:
            return

        for _ in self.bn.datamodel(events):  # iterator!
            pass
        #counters = ['create','update','delete']
        self.counter.count_creates += len(events)
        self.events_logger('create', len(events), log_tag)

    def events_update_with_counter(self, events:List[Numberevent], log_tag: str = None):
        if len(events)==0:
            return
        self.bn.update(events, delete=None)  #delete=None to trigger update, false to undelete
        self.counter.count_updates+= len(events)
        self.events_logger('update', len(events), log_tag)

    def events_delete_with_counter(self, events:List[Numberevent], log_tag: str = None):
        if len(events)==0:
            return
        self.bn.update(events, delete=True)
        self.counter.count_deletes += len(events)
        self.events_logger('delete', len(events), log_tag)

    def events_logger(self, name, count, tag):
        log_serie = self.log_series.get(name)
        if log_serie:
            e = log_serie.create_event(count, self.bn.now)
            if tag:
                e = e.set_tag(tag)
            self.bn.create_event(e)

    def handle(
        self,
        handle_function: Callable[[CustomJob, datetime, datetime], None],
        jobs:List[CustomJob] or None = None
    ):
        """
        this 'handle' splits periods and jobs towards the passed handle function
        it handles any required prerequisites like the initial query and catches errors
        :param handle_function:
        :param jobs:
        :return:
        """
        # when the jobs are not specified, all jobs will be done
        #
        log.info('handle %s', self.entity)
        self._query()
        if jobs is None: # Job.ALL
            jobs = [job for job in self.handler_jobs]
        elif isinstance(jobs, list):
            jobs = [job for job in jobs if job in self.handler_jobs]
        else: #Job.None
            log.warning('no valid jobs to handle')
            return self.counter

        for job in jobs:
            # the job has the interval influencing the lenght of the period
            try:
                for from_date, to_date in self.handler_timerange.iter_periods(job):
                    log.debug('handle function %s with job %s and range %s -> %s', handle_function, job, from_date, to_date)
                    handle_function(job, from_date, to_date)
            except:
                log.exception('handle_function %s for entity %s with job %s', handle_function, self.entity, job)
                continue
        return self.counter

    @property
    def is_active(self) -> bool:
        # devices are considered inactive when
        # no sources with relation to_date > lastMonth
        self._query()
        last_month = self.bn.recent('M',1)
        to_dates = [to_date for source in self.sources for e, from_date, to_date in source.iter_entities()]
        #todo check if this behaves for devices with sources from meta_values or targets and no related items/devices
        if len(to_dates) == 0:
            # no entities linked == considered not active
            return False
        elif max(to_dates) < last_month:
            return False
        return True

