import json
import timeit
import time
import logging
import traceback
import petl
from .runner_base import BaseRunner
from oaa.settings import config
from oaa import utils
from oaa import oaa_utils
from oaa.models import VezaObjectType
from oaa.settings_config import DestinationType
# from oaa.models import DynamicTranformOptions_Enum
from oaa.hooks.decorators import OAAHookEvent, run_hooks
from oaa.hooks.preflight_checks import run_preflights
from oaaclient.templates import (
    CustomApplication,
    CustomIdPProvider,
    HRISProvider,
    # CustomPermission,
    # OAAPermission,
    OAAPropertyType,
    # LocalRole
)
# from oaaclient.templates import HRISProvider  # , IdPProviderType
petl.config.failonerror = True

logger = logging.getLogger(__name__)


def fmapper(source_f):
    '''Take source field, then return rec[source_f]'''

    return lambda val, rec: rec[source_f]


def add_missing_fields(stream, should_be_list, current_list):
    # TODO Test this function
    missing_list =  set(should_be_list) - set(current_list)

    for destination_field in missing_list:
        stream = stream.addfield(destination_field, '')

    return stream

# def add_field_if_missing(stream, field):
#     def add_p(r):

#         return ""

#     # I need to remove this. It is causing the stream to be read, and parsed,
#     # every time it is called. 
#     # TODO make a addfieldifmissing function

#     if field not in stream.fieldnames():
#         logger.debug('adding field %s', field)

#         stream = stream.addfield(field, add_p)
#     else:
#         logger.debug('%s already in fields', field)

#     return stream


class LCMMixin:
    def lcm_create_user(self, user, action=None, **kwargs):
        return run_hooks(event=OAAHookEvent.LCM_CREATE_USER,
                         config=config,
                         sources=config.sources,
                         provider=self._provider_object, user=user,
                         action=action)

    def lcm_modify_user(self, user, action=None, **kwargs):
        return run_hooks(event=OAAHookEvent.LCM_MODIFY_USER,
                         config=config,
                         sources=config.sources,
                         provider=self._provider_object, user=user,
                         action=action)

    def lcm_get_metadata(self, **kwargs):
        return run_hooks(event=OAAHookEvent.LCM_MODIFY_USER,
                         config=config,
                         sources=config.sources,
                         provider=self._provider_object)


class CustomAppRunner(BaseRunner, LCMMixin):

    """
    A Runner for doing pushes of Custom app data to Veza

    If you want to omit custom attributes, even if mapped in the file, pass
    in the argument --do-custom-attributes=False.

    Include --dry-run=True to make this do all of the parsing but not send the
    payload to Veza.

    >>> oaa custom_app -c ./path/to/config.yaml run


    """
    runner_type = 'Custom'

    def _post_init(self,
                   *args, **kwargs,
                   ):

        if not config.valid():
            logger.error('configuration is not valid')
            exit(1)
        else:

            if config.app:
                self._provider_name = config.app.name

                self._datasource_name = f"{config.app.name} Datasource"  # noqa E501

        self._set('provider', None)
        self._set('_created_provider', None)
        self._set('_created_oaa_objects', None)
        self._get_logic_modules()

    def preflight(self):
        '''
        Run preflight checks.

        This function will run several preflight checks on the configuration.
        It should be a pretty good indication of the readiness of the script to
        run.

        '''
        problems = run_preflights(self)
        logger.debug('preflight problems: %s', problems)
        # problems = []

        # logger.debug('#### Configuration')
        # logger.debug(self.show_config())

        # for pf in oaa_utils.preflights:
        #     problems.extend(pf(self))

        # if problems:
        #     for problem in problems:
        #         print(f'⚠️  {problem}')
        # else:
        #     print('🥳 It appears you are all set to go')

    def run(self, push_to_oaa=True):
        return_value = {"status": True, "report": []}
        try:
            # process application
            # get or create provider
            self._get_or_create_provider(**config.app.model_dump())
            # assert provider_obj
            self._get_or_create_datasource()

            assert self._datasource

            self._run_transforms()

            self._create_oaa_objects()

            prepush_responses = run_hooks(event=OAAHookEvent.PRE_PUSH_OAA,
                                          config=config,
                                          sources=config.sources,
                                          provider=self._provider_object)

            do_push = True

            if any((r is False for r in prepush_responses)):
                do_push = False

            # RECORD transforms
            run_hooks(event=OAAHookEvent.RECORD_TRANSFORM,
                      config=config,
                      sources=config.sources,
                      provider=self._provider_object)

            self._hook_post_oaa()

            response = None
            datasource_id = None

            if push_to_oaa and do_push:
                datasource_id, response = self._push_to_oaa()

            # import bpdb; bpdb.set_trace()  # noqa: E702
            run_hooks(event=OAAHookEvent.POST_PUSH_OAA,
                      response=response,
                      config=config,
                      provider=self._provider_object,
                      datasource_id=datasource_id)

        except Exception as _err:
            logger.error('error running %s', _err)
            logger.error(traceback.format_exc())
            return_value['status'] = 'exception'
            return_value['report'].append(_err)

        return return_value 


    def _hook_post_oaa(self):

        # Get the logic modules here and run there

        for module in self._get_logic_modules():
            if hasattr(module, 'run'):
                module.run(self._provider_object,
                           sources=config.sources,
                           config=config)

    def _run_transforms(self):
        for name, source in config.sources.items():

            runtime = timeit.timeit(lambda: self._run_transform(source), number=1)

            if runtime >= 2:
                logger.warning('running transforms took a long time (%s seconds).'
                               'This is something to look into but not uncommon, '
                               'especially if the dataset is very large.',
                               runtime)

            run_hooks(event=OAAHookEvent.POST_TRANSFORM,
                      config=config,
                      provider=self._provider_object,
                      sources=config.sources,
                      source=source)

    def _run_transform(self, source):
        '''
        Run the Transforms on the source fields. This function also populates
        the source->destination mapping.
        '''

        # Run the OAAHookEvent.PRE_TRANSFORM hooks
        logger.debug('run transforms for source %s', source.source_name)
        run_hooks(event=OAAHookEvent.PRE_TRANSFORM,
                  config=config,
                  provider=self._provider_object,
                  source=source)

        run_hooks(event=OAAHookEvent.RECORD_TRANSFORM_STREAM,
                  config=config,
                  source=source,
                  provider=self._provider_object)


        # if not source.stream:
        #     logger.error('unable to fetch any data')

        fields_to_cutout = set()

        try:
            field_mappings = self.field_mappings(source=source)
        except Exception as e:
            print(source.source_name)
            print(e)
            # import bpdb; bpdb.set_trace()  # noqa: E702
            # exit()


        # TODO look at using a fieldmap here instead of individual actions
        # see https://petl.readthedocs.io/v1.7.3/transform.html#petl.transform.maps.fieldmap 
        # It might be more efficient but would require reworking the utility
        # functions

        # How can I know what fields exists in teh data source without reading
        # from it at least once? I need to add fields sometimes so that later
        # they can be mapped to.

        # then = time.time()

        # Let's get the current list of fieldnames. This incurs a cost upfront,
        # but we can re-use this list later, instead of reading the fieldnames
        # over and over.
        # Let's make this a set so it handles uniques all by itself.

        if isinstance(source.stream.header, list):
            header_list = source.stream.header
        elif callable(source.stream.header):
            header_list = source.stream.header()
        else:
            header_list = source.stream.fieldnames()

        initial_fieldnames_list = set(header_list)

        for fieldname_label, field in field_mappings.items():
            logger.debug('checking %s [%s]', fieldname_label, field)

            if not field.include:
                continue

            fieldname = field.source_field or fieldname_label

            destination = field.destination_field

            for transform_obj in field.transforms:
                transform = transform_obj.name
                field_options = transform_obj.options
                func_obj = utils.transformers.get(transform, None)

                if func_obj:
                    func = func_obj['func']
                    # some functions require special treatment. This usually
                    # means passing in to config value(s) to the function then
                    # returning the inner function to the petl convert/addfield
                    # method.

                    mode = func_obj['mode']

                    # func = func(options=field.field_options)
                    func = func(options=field_options)

                    if mode == 'convert':
                        source._stream = source.stream.convert(fieldname, func, pass_row=True)

                    elif mode == 'add':
                        source._stream = source.stream.addfield(destination, func)
                        initial_fieldnames_list.add(destination)
                        # The field was added, so we need to reference the new
                        # fieldname here
                        fieldname = destination
                    elif mode == 'select':
                        source._stream = source.stream.select(fieldname, func)
                # elif field.transform == 'ignore':
                elif transform == 'ignore':
                    fields_to_cutout.add(fieldname)
                elif transform == 'distinct':
                    source._stream = source.stream.distinct(fieldname)

            # source.stream = add_field_if_missing(source.stream, destination)

            logger.debug('transforming field %s', destination)

            if field.is_custom:
                source.custom_attrs_map[destination] = field  # fieldname
            else:
                source.attributes[destination] = fieldname

            # ----------------
            # do source -> destination mapping

            if fieldname != destination:
                # the source field is different from the destination

                if destination not in initial_fieldnames_list:
                    source._stream = source.stream.addfield(destination, '')

                source._stream = source.stream.convert(destination,
                                                       fmapper(fieldname),
                                                       pass_row=True)

                # import bpdb; bpdb.set_trace()  # noqa: E702

            # Normally the actual mapping happens just before the OAA object is
            # pushed. I want to make that happen here instead, as part of the data
            # stream.
            # end source -> destination mapping

        necessary_destinations = [f.destination_field for idx, f in field_mappings.items()]
        initial_fieldnames_list.update(necessary_destinations)
        final_fieldnames_list = list(initial_fieldnames_list)
        
        # add missing fields
        source._stream = add_missing_fields(source.stream,
                                            necessary_destinations,
                                            initial_fieldnames_list)

        # cutout fields we don't want anymore. They aren't removed before
        # this moment because they may be needed in join_fields

        filtered_fieldnames = filter(lambda f: f in final_fieldnames_list, fields_to_cutout)  # noqa E501

        for fieldname in filtered_fieldnames:
            source._stream = source.stream.cutout(fieldname)

        null_to_blank_func = utils.transformers.get('null_to_blank', None)['func']

        # for f in final_fieldnames_list:
        #     source.stream = source.stream.convert(f, null_to_blank_func(options={}))
        source._stream = source.stream.convertall(null_to_blank_func(options={}))

    def show_field_mapping(self):
        for n, s in config.sources.items():
            print('=' * 25)
            print('source', n)
            print('=' * 25)

            for fm, f in s.field_mappings().items():
                print('mapping name: ', fm)
                print('source_field: ', f.source_field or fm)
                print('destination_field: ', f.destination_field)
                print('transforms: ', [t.name for t in f.transforms])
                print('')
                print('-' * 25)

    # Consider caching this method. It's only used once, for now, but it may be
    # called multiple times in the future
    def field_mappings(self,
                       output_format=None,
                       source=None):
        '''
        Field Mappings moved to the settings.Source object.
        '''


        if isinstance(source, str):
            source = config.sources[source]
        mappings = source.field_mappings()

        if output_format == 'table':

            return mappings

        elif output_format == 'json':

            return json.dumps(mappings, indent=2)

        return mappings

    def _get_provider_type(self):

        if config.destination_type == DestinationType.CUSTOM_APP:
            return CustomApplication
        elif config.destination_type == DestinationType.IDP:
            return CustomIdPProvider
        elif config.destination_type == DestinationType.HRIS:
            return HRISProvider

        if not config.destination_type:
            return CustomApplication

    def _custom_provider_actions(self, provider_object=None):
        # Multiple types can be added by running `add_idp_type` multiple times
        # with different IdPProviderType enums
        # provider_object.system.add_idp_type(config.IDP_PROVIDER_TYPE)
        pass

    def _create_oaa_objects(self):

        if config.is_csv_push:
            self._set('_created_oaa_objects', False)

            return

        for source_name, source in config.sources.items():
            responses = run_hooks(event=OAAHookEvent.PRE_CREATE_OAA,
                                  provider=self._provider_object,
                                  config=config,
                                  sources=config.sources,
                                  source=source)

            # set do_oaa_create to False if any responses are False
            do_oaa_create = not any((r is False for r in responses))

            # if any((r is False for r in responses)):
            #     do_oaa_create = False

            # if do_create:
            #     self._create_oaa_objects()

            logger.info(f'Loading {source.destination} records '
                        f'for {source.source_name}')

            if source.destination != VezaObjectType.none:
                for prop_name, field in source.custom_attrs_map.items():

                    # Map type to an actual type
                    destination = field.destination_field

                    logger.debug('destination: %s', destination)
                    logger.debug('field: %s', field)
                    logger.debug('creating custom_property %s as type: %s',
                                 destination, field.veza_field_type.upper())
                    upper_field_type = field.veza_field_type.upper()
                    try:
                        field_type = getattr(OAAPropertyType, upper_field_type)
                    except AttributeError as err:

                        valid_types = [p.name for p in OAAPropertyType]
                        logger.error(err)
                        logger.error('Invalid custom property type %s:  '
                                     'Must be one of %s',
                                     upper_field_type,
                                     valid_types)
                        exit()
                    definer_map = {
                        VezaObjectType.LocalUser: 'define_local_user_property',
                        VezaObjectType.LocalGroup: 'define_local_group_property',
                        VezaObjectType.LocalRole: 'define_local_role_property',
                        VezaObjectType.Resource: 'define_resource_property',
                        VezaObjectType.User: 'define_user_property',
                        VezaObjectType.Group: 'define_group_property',
                        VezaObjectType.Employee: 'define_employee_property',
                    }
                    attr = definer_map.get(source.destination)

                    if attr:
                        definer = getattr(self._provider_object.property_definitions,
                                          definer_map.get(source.destination),
                                          None)

                        if definer:
                            if source.destination == VezaObjectType.Resource:

                                definer(property_type=field_type, name=prop_name,
                                        resource_type=source.resource_type)
                            else:
                                definer(property_type=field_type, name=prop_name)
                            # resource_type=source.resource_type)

            if 'row' not in petl.header(source.stream):
                source._stream = source._stream.addrownumbers()

            if source.destination != VezaObjectType.none:
                # import bpdb; bpdb.set_trace()  # noqa: E702

                for r in source.records:
                    logger.debug('%s: working on row %s', source.source_name, r.row)

                    # for f_name in r.flds:
                    #     assert not callable(r[f_name])

                    object_with_required_fields = {}
                    required_fields_tmp = []

                    temp_oaa_obj = None

                    # if source.destination != VezaObjectType.LocalUser:
                    #     import bpdb; bpdb.set_trace()  # noqa: E702

                    #if source.destination != VezaObjectType.none:
                    required_fields_tmp = oaa_utils.REQUIRED_FIELDS[source.destination.name]  # noqa E501

                    for req_field in required_fields_tmp:
                        # req_field_to_map = req_field

                        if isinstance(req_field, tuple):
                            # one of these fields is required, not both
                            found_in_mapping = 0

                            for f in req_field:
                                if f in source.attributes and source.attributes[f] in r.flds:  # noqa E501
                                    found_in_mapping += 1
                                    req_field = f

                            if found_in_mapping > len(req_field):
                                logger.error('only one of these %s can be mapped',  # noqa E501
                                             req_field)
                                exit()

                            if found_in_mapping == 0:
                                logger.error('one of %s must be mapped', req_field)  # noqa E501
                                exit()

                        else:

                            if not source.attributes:
                                logger.error('something is wrong, source.attributes is empty')  # noqa E501

                            logger.debug('attributes in mapping: %s', source.attributes.keys())

                            req_field_dest = source.attributes.get(req_field)

                            if not req_field_dest:
                                logger.error('required field %s is not mapped',
                                             req_field)
                                exit()

                            if req_field_dest not in r.flds:
                                # print(source.attributes)
                                logger.error('%s not in attributes on record: %s',
                                             req_field_dest, r.flds)
                                exit()

                                # exit()
                        object_with_required_fields[req_field] = r[source.attributes[req_field]]  # noqa E501

                    if do_oaa_create:
                        # change this based on the source.destination

                        if source.destination == VezaObjectType.LocalUser:
                            # print(object_with_required_fields)
                            user_id = object_with_required_fields['unique_id']
                            temp_oaa_obj = self._provider_object.local_users.get(user_id)

                            if not temp_oaa_obj:
                                temp_oaa_obj = self._provider_object.add_local_user(**object_with_required_fields)  # noqa E501
                            # required_fields = oaa_utils.REQUIRED_FIELDS['LocalUser']
                        elif source.destination == VezaObjectType.User:
                            user_id = object_with_required_fields['identity']
                            temp_oaa_obj = self._provider_object.users.get(user_id)

                            if not temp_oaa_obj:
                                # import bpdb; po = self._provider_object; bpdb.set_trace()  # noqa: E702
                                temp_oaa_obj = self._provider_object.add_user(**object_with_required_fields)  # noqa E501
                        elif source.destination == VezaObjectType.Group:
                            temp_oaa_obj = self._provider_object.groups.get(object_with_required_fields['unique_id'])

                            if not temp_oaa_obj:
                                temp_oaa_obj = self._provider_object.add_group(**object_with_required_fields)  # noqa E501
                                # import bpdb; bpdb.set_trace()  # noqa: E702
                            # required_fields = oaa_utils.REQUIRED_FIELDS['LocalUser']

                        elif source.destination == VezaObjectType.LocalGroup:
                            temp_oaa_obj = self._provider_object.local_groups.get(object_with_required_fields['unique_id'])

                            if not temp_oaa_obj:
                                temp_oaa_obj = self._provider_object.add_local_group(**object_with_required_fields)  # noqa E501
                                # import bpdb; bpdb.set_trace()  # noqa: E702
                            # required_fields = oaa_utils.REQUIRED_FIELDS['LocalUser']
                        elif source.destination == VezaObjectType.Employee:

                            if object_with_required_fields['unique_id'] not in self._provider_object.employees:
                                # print(object_with_required_fields)
                                # import bpdb; bpdb.set_trace()  # noqa: E702
                                temp_oaa_obj = self._provider_object.add_employee(**object_with_required_fields)  # noqa E501

                        elif source.destination == VezaObjectType.LocalRole:

                            if object_with_required_fields['unique_id'] not in self._provider_object.local_roles:
                                temp_oaa_obj = self._provider_object.add_local_role(**object_with_required_fields)  # noqa E501
                        elif source.destination == VezaObjectType.Resource:
                            if object_with_required_fields['unique_id'] not in self._provider_object.resources:
                                temp_oaa_obj = self._provider_object.add_resource(resource_type=source.resource_type,
                                                                                  **object_with_required_fields)  # noqa E501
                        # Add other attributes

                        # These are those not already added as required
                        filtered_fields = filter(lambda f: f not in required_fields_tmp, source.attributes)  # noqa E501

                        if temp_oaa_obj:
                            for update_field in filtered_fields:
                                attr_source = source.attributes[update_field]

                                setattr(temp_oaa_obj, update_field, r[attr_source])

                            # custom properties are set using the `set_property` method
                            # with the name of the property and value

                            for property_name, f in source.custom_attrs_map.items():
                                source_field = f.source_field or f.destination_field

                                if r.get(source_field) in [None, '']:
                                    logger.debug('field [%s] is blank on %s',
                                                 source_field,
                                                 r.row)
                                else:
                                    logger.debug('adding %s=%s to object [%s]',
                                                 source_field,
                                                 r[source_field],
                                                 r.row)

                                    temp_oaa_obj.set_property(property_name.lower(),
                                                              r[source_field])   # noqa E501

            logger.info(f'Finished loading {source.destination} for {source.source_name}')

