"""This module contains utils of the ``common`` app."""

import os
import string
import sys
import unicodedata
import urllib.parse

from contextlib import contextmanager
from datetime import timedelta
from subprocess import PIPE, Popen

from django.apps import apps
from django.conf import settings
from django.core.exceptions import (
    MultipleObjectsReturned,
    ObjectDoesNotExist,
)
from django.core.management.base import CommandError
from django.db.models import FileField
from django.utils import six, timezone
from django.utils.encoding import DEFAULT_LOCALE_ENCODING, force_text
from django.utils.functional import lazy

from django_fields_iterator import FieldsIterator

from .constants import TIMEDELTA_STRING_SEPARATOR, PERIODS, PLURAL_NUM

EXCEL_BOM = '\xEF\xBB\xBF'  # BOM for excel


def timedelta_to_string(td_object):
    """Convert a timedelta to string object.

    Parameters
    ----------
    td_object: timedelta
        The timedelta object we want cast to string.

    Notes
    -----
    This function is i18n-ready.

    Returns
    -------
    str
        The provided timedelta as string.

    """
    if not td_object:
        return ''

    seconds = int(td_object.total_seconds())

    strings = []
    for __, period_name, period_seconds in PERIODS:
        if seconds >= period_seconds:
            period_value, seconds = divmod(seconds, period_seconds)

            strings.append(period_name % {PLURAL_NUM: period_value})

    return TIMEDELTA_STRING_SEPARATOR.join(strings)


def string_to_timedelta(td_string):
    """Convert a string to timedelta object.

    Parameters
    ----------
    td_string: str
        The string we want to parse as a timedelta.

    Notes
    -----
    This function is i18n-ready.

    Returns
    -------
    timedelta
        The provided string as timedelta.

    """

    def get_period_info(period):
        """Get period information extracted from the given string

        Parameters
        ----------
        period: str
            The string from which to extract the period.

        Raises
        ------
        ValueError
            If the period name don't match any period parameters.
            Or if value is a string.
            Or if period cannot splited.

        Returns
        -------
        tuple(str, int)
            A tuple with the unit of period extracted, and the number of such a unit.

        """
        # TODO: Make it work by extracting in a reliable way, because translation may have changed
        # the order or the separator in ``PERIODS``
        value, name = period.split(' ')
        value = int(value)

        for period_param, period_name, __ in PERIODS:
            if period_name % {PLURAL_NUM: value} == period:
                return period_param, value

        raise ValueError('"%s" period name is not correct.' % name)

    time_params = {}
    for part in td_string.split(TIMEDELTA_STRING_SEPARATOR):
        try:
            time_name, time_value = get_period_info(part)
        except ValueError:
            return None
        else:
            time_params[time_name] = time_value

    return timedelta(**time_params)


def start_of_day(date_time):
    """Return a a datetime of the start of day

    This should be used instead of using `.date()` when
    a timezone aware object is needed.

    Parameters
    ----------
    date_time: datetime
        The datetime to convert

    Returns
    -------
    datetime
        The same date, but at the beginning of the day (0:00:00)

    """

    return date_time.replace(hour=0, minute=0, second=0, microsecond=0)


def remove_ponctuation(text):
    exclude = set(string.punctuation)
    return ''.join(char for char in str(text) if char not in exclude)


def url_to_path(url):
    url_parts = urllib.parse.urlsplit(url)
    path = ''.join([url_parts[i] for i in range(2, 5)])
    return path


def cmd_exists(program):

    def is_exe(fpath):
        return os.path.isfile(fpath) and os.access(fpath, os.X_OK)

    fpath, __ = os.path.split(program)
    if fpath:
        if is_exe(program):
            return program
    else:
        for path in os.environ['PATH'].split(os.pathsep):
            path = path.strip("'")
            exe_file = os.path.join(path, program)
            if is_exe(exe_file):
                return exe_file

    return None


def upload_to_generator(instance, filename=None):

    ext = 'png'
    if filename:
        ext = filename.split('.')[-1].lower()

    return '%s' % (os.path.join(
        settings.UPLOAD_TO[type(instance)._meta.object_name],
        '%s.%s' % (instance.id, ext)
    ))


def strip_accents(text):
    text = text.replace(' ', '')
    return ''.join(
        c for c in unicodedata.normalize('NFD', text)
        if unicodedata.category(c) != 'Mn'
    )


def handle_uploaded_file(file, filename):
    """Write an uploaded file to the given filename

    Parameters
    ----------
    file: django ``UploadedFile`` instance
        The file we want to write
    filename: str
        The path we want to write the file to

    """

    directory_path = os.path.dirname(filename)

    # If the destination folder don't exists, create it
    os.makedirs(directory_path, exist_ok=True)

    with open(filename, 'wb+') as destination:
        for chunk in file.chunks():
            destination.write(chunk)


def get_current_timestamp():
    return int(timezone.now().timestamp() * 100000)


def sort_list_of_dicts(list_of_dicts, key):
    """Sort a list of dicts using the values of the specified ``key``.

    Parameters
    ----------
    list_of_dicts : list of dicts
        The list of dicts to sort
    key : str
        The key of which to use the values to sort the list

    Returns
    -------
    list of dicts
        The sorted list

    Raises
    ------
    KeyError
        If the specified ``key`` doesn't exist in the dicts

    """
    return sorted(list_of_dicts, key=lambda k: k[key])


def filter_list_of_dicts(list_of_dicts, key, value):
    """Return only the dicts in which the specified ``key`` contains
    the specified ``value``.

    Parameters
    ----------
    list_of_dicts : list of dicts
        The list of dicts to filter
    key : str
        The key in which to look for the value
    value
        The value to look for

    Returns
    -------
    list of dicts
        The filtered list

    """
    return [dct for dct in list_of_dicts if dct.get(key) == value]


def get_dict_in_list(list_of_dicts, key, value, dict_if_error=None):
    """Return the dict in the given list in which the specified ``key``
    contains the specified value.

    Parameters
    ----------
    list_of_dicts : list of dicts
        The list of dicts to search
    key : str
        The key in which to look for the value
    value
        The value to look for
    dict_if_error: object
        By default, is None, so we raise an exception.
        Else, we return this value.

    Returns
    -------
    dict
        The dict in question

    Raises
    ------
    ObjectDoesNotExist
        If no such dict can be found in the list, or
        if the specified ``key`` doesn't exist in the dicts
    MultipleObjectsReturned
        If more than one dict in the list contains ``value`` in ``key``

    """
    lst = filter_list_of_dicts(list_of_dicts, key, value)

    if len(lst) == 1:
        return lst[0]

    if dict_if_error is None:
        raise MultipleObjectsReturned if lst else ObjectDoesNotExist
    else:
        return dict_if_error


@contextmanager
def bypass_orderable(obj=None):
    """A context manager to use to allow bypassing ``Orderable`` saving on an instance or a class.

    Must only be used when the order is manually set (or automatically, like in duplication)

    Parameters
    ----------
    obj : type/object
        Class or instance of a class on which we want to bypass the ``Orderable`` saving.

    Example
    -------

    # Will deactivate orderable saving for the instance only
    >>> with bypass_orderable(instance):

    # Will deactivate orderable saving for all instances of the model
    >>> with bypass_orderable(MyOrderableModel):

    # Will deactivate orderable saving for all instances of all orderable models
    >>> with bypass_orderable(Orderable):

    """

    # Tell pylint that we are allowed to access ``_bypass``
    # pylint:disable=protected-access

    if obj is None:
        from .models import Orderable
        obj = Orderable

    # Save the previous value
    old_bypass = obj._bypass

    # Deactivate orderable saving
    obj._bypass = True

    try:
        # Run the code encapsulated in the ``with`` statement
        yield
    finally:
        # Restore the previous value
        obj._bypass = old_bypass


def popen_wrapper(args, os_err_exc_type=CommandError, universal_newlines=True, env=None):
    """Friendly wrapper around Popen.

    This is a copy of ``django.core.management.utils.popen_wrapper`` but with one more argument:
    ``env``, an override of the default environment variables

    Parameters
    ----------
    args : list
        The ``args`` argument to ``Popen``: one entry for the command, and one for each argument
    os_err_exc_type : exception
        The exception to reraise if an ``OSError`` exception occurs
    universal_newlines : bool, default to ``True``
    env : dict, optional, default to ``None``
        If set, ``Popen`` will receive a copy of the actual environment, updated with the content
        of this dict.

    Returns
    -------
    tuple(str, str, int)
        A tuple with:
            - the stdout output
            - the stderr output
            - the OS status code

    """

    if env:
        env = dict(os.environ.copy(), **env)
    try:
        process = Popen(args, shell=False, stdout=PIPE, stderr=PIPE,
                        close_fds=os.name != 'nt', universal_newlines=universal_newlines, env=env)
    except OSError as exc:
        strerror = force_text(exc.strerror, DEFAULT_LOCALE_ENCODING,
                              strings_only=True)
        six.reraise(os_err_exc_type, os_err_exc_type('Error executing %s: %s' %
                                                     (args[0], strerror)), sys.exc_info()[2])
    else:
        output, errors = process.communicate()
        return (
            output,
            force_text(errors, DEFAULT_LOCALE_ENCODING, strings_only=True),
            process.returncode
        )


class classproperty(object):  # pylint: disable=invalid-name
    """A decorator to have a read-only class property

    Parameters
    ----------
    method : str
        The decorated method

    Examples
    --------

    >>> class MyClass:
    ...     @classproperty
    ...     def foo(cls):
    ...         return 'bar'
    ...
    >>> MyClass.foo
    'bar'

    """

    def __init__(self, method):
        """Constructor of the ``classproperty`` decorator that saves the method locally"""

        self.method = method

    def __get__(self, obj, owner):
        """Getter of the property that calls the method passing the class

        Parameters
        ----------
        obj: class
            When called from an instance, it's the instance itself.
        owner: type
            The class on which this class property is defined

        Returns
        -------
        What is returned by the decorated method

        """

        return self.method(owner)


def mod_lazy(text, *args, **kwargs):
    """Apply the module operator in a lazy way

    This is to be used for ``ugettext_lazy`` calls
    with parameters, because using the module operator
    (``%``), the lazy text is casted at init time, so
    translated at init time, not once the translation is
    activated.

    This function is wrapped with a call to ``lazy`` for the
    real lazy stuff.

    Parameters
    -----------
    text: str
        The text to be formatted with %
    args: list
        The args to pass for unnamed % arguments
    kwargs: dict
        The kwargs to pass for named % arguments

    Notes
    ------
    You must pass either `args` OR `kwargs`

    Examples
    --------

    Assuming:
    >>> from django.utils.translation import ugettext_lazy as _

    Instead of using:
    >>> help_text = _('foo: %s') % 'bar'

    use:
    >>> help_text = mod_lazy(_('foo: %s'), 'bar')

    And instead of using:
    >>> help_text = _('foo: %(bar)s') % {'bar': 'BAR'}

    use:
    >>> help_text = mod_lazy(_('foo: %(bar)s'), bar='BAR'}

    Note that the dict is passed as kwargs, so you can do this:
    >>> help_text = mod_lazy(_('foo: %(bar)s'), **{'bar': 'BAR'})

    """

    assert not(args and kwargs), "mod_lazy expects args OR kwargs"

    return text % (args or kwargs)

mod_lazy = lazy(mod_lazy, str)  # pylint: disable=invalid-name


def get_file_fields(models=None):
    """Get all the file fields (and subclasses) in the project

    Parameters
    ----------
    models: iterable
        A list of models for which we want the file fields.
        If ``None``, the work will be done for all the used
        models (as returned by ``get_models``)

    Returns
    -------
    dict
        A dict with an entry for each model having file fields,
        with the list of all file fields for this model

    """
    file_fields = {}

    if not models:
        models = apps.get_models()

    for model in models:
        iterator = FieldsIterator(model)

        model_file_fields = [
            field
            for field, __, __  # no need for name and field_type
            in iterator.iter_simple_fields()
            if isinstance(field, FileField)
        ]

        if model_file_fields:
            file_fields[model] = model_file_fields

    return file_fields


def get_all_subclasses(python_class):
    """Helper function to get all the subclasses of a class.

    Parameters
    ----------
    python_class: Class
        Any Python class that implements __subclasses__()

    Returns
    -------
    set(class)

    """

    python_class.__subclasses__()

    subclasses = set()
    check_these = [python_class]

    while check_these:
        parent = check_these.pop()
        for child in parent.__subclasses__():
            if child not in subclasses:
                subclasses.add(child)
                check_these.append(child)

    return sorted(subclasses, key=lambda x: x.__name__)


def get_concrete_models(base_model):
    """Helper function to get all concrete models that are subclasses of base_model

    Parameters
    ----------
    base_model: Model
        A Django models.Model instance.

    Returns
    -------
    set(class)

    """

    found = get_all_subclasses(base_model)

    def filter_func(model):
        """Filter out the abstract models

        Parameters
        ----------
        model: Model
            The model we want to check

        Returns
        -------
        True if the model is a concrete one,
        False otherwise

        """

        meta = getattr(model, '_meta', '')
        if getattr(meta, 'abstract', True):
            # Skip meta classes
            return False
        if '_Deferred_' in model.__name__:
            # See deferred_class_factory() in django.db.models.query_utils
            # Catches when you do .only('attr') on a queryset
            return False
        return True

    subclasses = [
        model for model in found
        if filter_func(model)
    ]
    return sorted(subclasses, key=lambda x: x.__name__)
