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 --config-file ./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)

    def validate(self, config=None, test_connectivity=False):
        """Validate the configuration file without running a full sync.

        This command validates the config.yaml file by:
        1. Validating the config structure (Pydantic validation)
        2. Checking that custom connection modules can be imported
        3. Running preflight checks
        4. Optionally testing connectivity to data sources

        :param config: Path to the config.yaml file (or use -c/--config)
        :param test_connectivity: If True, test actual connections to dbs/APIs.
                                 Defaults to False for faster validation.
        :returns: Tuple of (is_valid, messages)

        Example:
            oaa custom_app -c ./config.yaml validate

            # With connectivity testing:
            oaa custom_app -c ./config.yaml validate --test_connectivity=True
        """
        from oaa.hooks.preflight_validate import validate_config

        config_file = config or self._config_file_yaml or self._config_file

        is_valid, messages = validate_config(
            config_file, test_connectivity=test_connectivity
        )

        for msg in messages:
            print(msg)

        return is_valid, messages

    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():
            self._run_transform(source)
            # 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.

        # import ipdb; ipdb.set_trace()  # noqa: E702
        header_list = petl.header(source.stream)
        # 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

            deferred_converts = []

            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':
                        if fieldname != destination:
                            # Defer to run on destination after source→dest copy
                            # so the source field is not modified in-place and
                            # other mappings sharing the same source field are
                            # not corrupted.
                            deferred_converts.append(func)
                        else:
                            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:
                # When source and destination differ, the destination column
                # is populated via fmapper + any deferred transforms, so read
                # from destination at object-creation time instead of the raw
                # source field.
                if fieldname != destination:
                    source.attributes[destination] = destination
                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
                )

                for func in deferred_converts:
                    source._stream = source.stream.convert(destination, func, 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 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}"
            )
