import os
import hashlib
import re
import inspect
import logging
import json

# import petl
from datetime import datetime, date
from functools import wraps
from email_validator import validate_email, EmailNotValidError
from dateutil import parser as date_parser
from datetime import timezone
from jinja2 import Environment, BaseLoader, FileSystemLoader

# jinja_env = Environment(loader=BaseLoader())
# jinja_env = Environment(loader=BaseLoader())

from pathlib import Path

BASE_DIR = Path(os.path.dirname(os.path.realpath(__file__)))

jinja_env = Environment(loader=FileSystemLoader([BASE_DIR / "templates"]))

# from oaa.settings import config
logger = logging.getLogger(__name__)

# pattern for NON_NUMBERS
NON_NUMBER = re.compile(r"[^0-9]+")

# TODO: change the logic to lowercase string values before evaluation
# (eliminate need for explicit "TRUE" vs "true" comparison)
VALID_BOOL_VALUES = {
    "true": ("true", True, "yes", "y", "1", "active", "enabled", "TRUE"),
    "false": ("false", False, "inactive", "disabled0", "f", "no", "n", "FALSE"),
}
transformers = {}


def transformer(*args, mode="convert", has_inner=True, **kwargs):
    """
    Decorators that support optional arguments have to be implemented with
    a hack. If the decorator function is called with a single argument and this
    argument is a callable, then the decorator assumes it was called without
    arguments. In any other case it assumes it was called with arguments.
    """

    def decorator(func):

        @wraps(func)
        def wrapper(*args2, **kwargs2):
            if has_inner:
                return func(*args2, **kwargs2)

            # the function is a simple transform that doesn't need and inner
            # to handle options
            def inner(value, *args3, **kwargs3):
                # import bpdb; bpdb.set_trace()  # noqa: E702

                return func(value, *args3, **kwargs3)

            return inner

        transformers[func.__name__] = {"func": wrapper, "mode": mode}

        return wrapper

    if args:
        function = args[0]

        if callable(function):
            # A function was provided, so we're being used as a decorator

            return decorator(function)

    # otherwise, this was called with optional arguments, in which case the
    # actual decorator function needs be returned so that it can decorate the
    # function itself

    return decorator


def transformer_simple(*args, mode="convert", **kwargs):
    return transformer(*args, mode=mode, has_inner=False, **kwargs)


@transformer_simple
def make_hash(value, *args, **kwargs):
    """
    This transform will hash the value for security:

    .. code:: yaml

        transforms:
          - name: make_hash

    """

    if value is not None:
        return hashlib.sha256(str(value).encode("utf-8")).hexdigest()

    return value


@transformer_simple
def to_string(value, *args, **kwargs):

    if value is not None:
        return str(value)

    return value


@transformer_simple
def static_value(value, *args, **kwargs):

    if value is not None:
        return str(value)

    return value


# def date_convert(fieldname, self, *args, **kwargs):
@transformer(has_inner=True)
def date_convert(**kwargs):
    options = kwargs.get("options")  # noqa F841

    def inner(value, *args, **kwargs):
        """My schema had the date fields already mapped as data types. I need
        to ask if they are that way in their system.
        """

        # works
        # return '2023-04-12T15:34:56.123456789Z'

        # if value is None:
        #     logger.debug('fieldname %s: (%s) is null', fieldname, value)

        #     return ''

        # if not value:
        #     import bpdb; bpdb.set_trace()  # noqa: E702

        if isinstance(value, str):
            value = value.strip()

        if not value:
            return value

        if type(value) not in (date, datetime):
            # Try parsing it
            value = date_parser.parse(value)

            if type(value) not in (date, datetime):
                logger.warning(
                    "Invalid date value: %s, %s - returning blank", value, type(value)
                )

                return ""

        # Add time to the date if it isn't a datetime
        # Note: don't test if is date instance, datetime is a subclass of date

        if not isinstance(value, datetime):
            value = datetime.combine(value, datetime.min.time())
            # 2006-01-02T15:04:05Z07:00

        if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
            # datetimes is naive, add timezone=UTC
            value = value.replace(tzinfo=timezone.utc)

        # new_value = f"{value.isoformat()}{config.OAA_TIME_ZONE}"
        # logger.debug('%s converted to string %s', value, new_value)

        # convert '+00:00' to 'Z' Is this really necessary?

        return value.isoformat().replace("+00:00", "Z")

    return inner


@transformer_simple
def email_convert(email, *args, **kwargs):
    """The value can be either null or a valid email address. Log an error if
    value is invalid email.

    :value: TODO
    :returns: TODO

    """

    try:
        # Check that the email address is valid. Turn on check_deliverability
        # for first-time validations like on account creation pages (but not
        # login pages).
        emailinfo = validate_email(email, check_deliverability=False)

        # After this point, use only the normalized form of the email address,
        # especially before going to a database query.

        # if email == 'KAJIGGA@gmail.com':
        #     import bpdb; bpdb.set_trace()  # noqa: E702
        email = emailinfo.normalized.lower()

    except EmailNotValidError as e:
        # The exception message is human-readable explanation of why it's
        # not a valid (or deliverable) email address.

        if email in (None, "null"):
            email = ""
        else:
            logger.warning("%s", e)

    return email


@transformer_simple
def remove_diacritic(value):
    """TODO: Docstring for remove_diacritic.

    :value: TODO
    :returns: TODO

    """
    # Do we really need to remove diacritics? Is this an AD restriction?

    # return unicodedata.normalize('NFKD', value)

    return value


@transformer_simple
def make_number(value, *args, **kwargs):
    """Format the value as only digits, stripping everything else out

    remove non-numbers, null -> blank

    :value: TODO
    :returns: TODO

    """

    return int(NON_NUMBER.sub("", str(value)))


@transformer(has_inner=True)
def replace(*args, options={}, **kwargs):
    string = options["options"]["string"]

    def inner(value, rec=None):
        if isinstance(value, str):
            return value.replace(string)

        return value

    return inner


@transformer(has_inner=True)
def join_fields(*args, options=None, **kwargs):
    options = options or {}

    join_char, fields_to_join = options["options"]  # noqa E501

    def inner(value, rec):
        """Inner function to do join_fields with context

        :value: current value of field, not used when joining fields
        :rec: the current record. the fields_to_join should be there
        :returns: string

        """

        # all of the values in fields_to_join must be strings and they must
        # exist in the record.

        for f in fields_to_join:
            if f not in rec.flds:
                logger.error(
                    f"the field {f} is not a valid field. The record"
                    f" only has the following fields {rec.flds}"
                    " available"
                )

        # valid_fields = list(filter(lambda f: bool(f), fields_to_join))

        return_value = join_char.join(
            [rec[f] for f in filter(lambda f: bool(f), fields_to_join)]
        )  # noqa E501

        return return_value

    return inner


def simple_jinja_render(template_str, context={}):

    template = jinja_env.from_string(template_str)

    return template.render(**context)


@transformer(mode="add", has_inner=True)
def jinja_render(*args, options=None, **kwargs):

    options = options or {}

    if isinstance(options, dict):
        template_str = options["template"]
    else:
        template_str = options
    try:
        template = jinja_env.from_string(template_str)

        def inner(rec):

            val = template.render(row=rec)
            # import bpdb; bpdb.set_trace()  # noqa: E702

            return val

    except TypeError as err:
        logger.error(err)
        logger.error("cannot compile the template: [%s]", template_str)
        # import bpdb; bpdb.set_trace()  # noqa: E702

        def inner(rec):
            raise Exception("cannot compile template [%s]", template_str)

    return inner


@transformer(mode="convert", has_inner=True)
def jinja_render_convert(*args, options=None, **kwargs):

    options = options or {}

    if isinstance(options, dict):
        template_str = options["template"]
    else:
        template_str = options
    template = jinja_env.from_string(template_str)

    def inner(value, rec):
        # from oaa.settings import config
        # import bpdb; bpdb.set_trace()  # noqa: E702

        return template.render(row=rec)

    return inner


@transformer(mode="add", has_inner=True)
def join_fields_add(options=None, **kwargs):
    """TODO: Docstring for join_fields.

    :value: TODO
    :returns: TODO

    """
    options = options or {}
    join_char, fields_to_join = options["options"]

    def inner(rec):
        """Inner function to do rowmap with context

        :rec: TODO
        :returns: TODO

        """

        joined_value = join_char.join([rec[f] for f in fields_to_join])

        return joined_value

    return inner


@transformer
def validate_boolean(*args, **kwargs):
    def inner(value, *args, **kwargs):

        # import bpdb; bpdb.set_trace()  # noqa: E702
        # print('value', value)
        # ret_value = None

        # if type(value) in (int, str):
        #     value = value.lower()
        #     ret_value = value

        #     if value in VALID_BOOL_VALUES['true']:
        #         ret_value = True

        #     elif value in VALID_BOOL_VALUES['false']:

        #         ret_value = False
        #     else:

        #         ret_value = False
        #         logger.error('%s is not a valid option for a boolean field', value)

        # elif isinstance(value, bool):
        #     ret_value = value

        # assert isinstance(ret_value, bool)

        return value in VALID_BOOL_VALUES["true"]

    return inner


@transformer(has_inner=True)
def maybe_length(options=None, *args, **kwargs):
    if not options:
        options = {}
    length = int(options["options"])  # noqa E501

    def inner(value):
        """TODO: Docstring for maybe_length.

        :value: TODO
        :returns: TODO

        """
        # are we doing something with the length?

        # Checks that the value is no longer than the given length. If it is,
        # trim it, but TODO I don't know what this action should really be.
        # Right now, it just returns the value as is.

        return value

    return inner


@transformer(mode="add", has_inner=True)
def pull_from(*args, options=None, **kwargs):
    options = options or {}

    field_to_pull = options.get("options", options.get("pull_from", ""))

    def inner(rec):

        return rec.get(field_to_pull)

    return inner


@transformer(has_inner=True)
def lookup(options=None, **kwargs):
    options = options or {}
    lookup_table = options.get("lookup")

    def inner(value, rec, *args, **kwargs):
        # return the same value without converting it?
        if lookup_table is None:
            return value

        return lookup_table.get(value, value)

    return inner


@transformer(mode="add", has_inner=True)
def pull_from_lookup(*args, options=None, **kwargs):
    # func = func(_options['pull_from'], _options['lookup'])
    field_to_pull = options["pull_from"]
    lookup_table = options["lookup"]
    # import bpdb; bpdb.set_trace()  # noqa: E702

    def inner(rec):
        temp_options = {"options": field_to_pull}
        # import bpdb; bpdb.set_trace()  # noqa: E702
        value = str(pull_from(options=temp_options)(rec))
        value = lookup(lookup_table)(value.lower())

        return value

    return inner


@transformer(mode="select", has_inner=True)
def filter_by_field(options):

    filter_options = [f.strip() for f in options["options"].split(",")]  # noqa E501

    def inner(value):
        if options:
            return value in filter_options

        return True

    return inner


def util_null_to_blank(value):
    if value is None:
        return ""

    return value


@transformer_simple
def null_to_blank(value, *args, **kwargs):
    return util_null_to_blank(value)


@transformer_simple
def ignore(value, *args, **kwargs):
    return value


@transformer_simple
def none(value, *args, **kwargs):
    return value


@transformer_simple
def lowercase(value, *args, **kwargs):
    # import bpdb; bpdb.set_trace()  # noqa: E702

    return value.lower()


@transformer_simple
def dummy_permission(value, *args, **kwargs):
    from oaaclient.templates import OAAPermission

    print(value, args, kwargs)
    # import bpdb; bpdb.set_trace() # TODO: Remove this
    return OAAPermission.DataCreate


@transformer_simple
def debug(value, *args, **kwargs):
    print(value, args, kwargs)

    return value


@transformer_simple
def listify(value, *args, **kwargs):
    if value is None or value == "":
        return value
    options = kwargs.get("options") or {}
    delimiter = options.get("delimiter")
    if delimiter and isinstance(value, str):
        return [v.strip() for v in value.split(delimiter) if v.strip()]
    if isinstance(value, list):
        return value
    return [value]


# full_func_list = locals()
# FUNCS = {func:func for func in transformers}  # noqa E501
FUNCS = transformers

LOGIC_MODULE_ARGS = ["provider", "sources", "config"]


def validate_logic_module(custom_module):

    if hasattr(custom_module, "hook"):
        return True

    assert hasattr(custom_module, "run")
    argsinfo = inspect.getfullargspec(custom_module.run)

    args_good = all(map(lambda f: f in argsinfo.args, LOGIC_MODULE_ARGS))

    return args_good


def json_print(obj):
    print(json.dumps(obj, indent=2))
    # return json.dumps(obj, indent=2)


# Backward compatibility: Import BaseURLSession from modules
# (The canonical implementation is in oaa.modules.base_url_session)
from oaa.modules.base_url_session import BaseURLSession  # noqa: F401
