import csv
from unittest import mock
from urllib.parse import parse_qs, urlparse

import icalendar
from django.contrib.auth import get_user_model
from django.http import QueryDict
from django.test import Client
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property

User = get_user_model()


class MockLDAPMixin:
    """
    Mixin pour simuler un appel à un serveur LDAP (e.g., celui de l'ENS) dans des
    tests unitaires. La réponse est une instance d'une classe Entry, qui simule
    grossièrement l'interface de ldap3.
    Cette classe patche la méthode magique `__enter__`, le code correspondant doit donc
    appeler `with Connection(*args, **kwargs) as foo` pour que le test fonctionne.
    """

    class MockLDAPModule:
        SCOPE_SUBTREE = None  # whatever

        def __init__(self, ldap_obj):
            self.ldap_obj = ldap_obj

        def initialize(self, *args):
            """Always return the same ldap object."""
            return self.ldap_obj

    def mockLDAP(self, results):
        entries = [
            (
                "whatever",
                {
                    "cn": [name.encode("utf-8")],
                    "uid": [uid.encode("utf-8")],
                    "mail": [mail.encode("utf-8")],
                },
            )
            for uid, name, mail in results
        ]
        # Mock ldap object whose `search_s` method always returns the same results.
        mock_ldap_obj = mock.Mock()
        mock_ldap_obj.search_s = mock.Mock(return_value=entries)

        # Mock ldap module whose `initialize_method` always return the same ldap object.
        mock_ldap_module = self.MockLDAPModule(mock_ldap_obj)

        patcher = mock.patch("shared.autocomplete.ldap", new=mock_ldap_module)
        patcher.start()
        self.addCleanup(patcher.stop)

        return mock_ldap_module


class CSVResponseMixin:
    """
    Mixin pour manipuler des réponses données via CSV. Deux choix sont possibles:
    - si `as_dict=False`, convertit le CSV en une liste de listes (une liste par ligne)
    - si `as_dict=True`, convertit le CSV en une liste de dicts, avec les champs donnés
    par la première ligne du CSV.
    """

    def _load_from_csv_response(self, r, as_dict=False, **reader_kwargs):
        content = r.content.decode("utf-8")

        # la dernière ligne du fichier CSV est toujours vide
        content = content.split("\n")[:-1]
        if as_dict:
            content = csv.DictReader(content, **reader_kwargs)
            # en python3.7, content est une liste d'OrderedDicts
            return list(map(dict, content))
        else:
            content = csv.reader(content, **reader_kwargs)
            return list(content)

    def assertCSVEqual(self, response, expected):
        if type(expected[0]) == list:
            as_dict = False
        elif type(expected[0]) == dict:
            as_dict = True
        else:
            raise AssertionError(
                "Unsupported type in `assertCSVEqual`: "
                "%(expected)s is not of type `list` nor `dict` !"
                % {"expected": str(expected[0])}
            )

        content = self._load_from_csv_response(response, as_dict=as_dict)
        self.assertCountEqual(content, expected)


class ICalMixin:
    """
    Mixin pour manipuler des iCalendars. Permet de tester l'égalité entre
    in iCal d'une part, et une liste d'évènements (représentés par des dicts)
    d'autre part.
    """

    def _test_event_equal(self, event, exp):
        """
        Les éléments du dict peuvent être de deux types:
        - un tuple `(getter, expected_value)`, auquel cas on teste l'égalité
        `getter(event[key]) == value)`;
        - une variable `value` de n'importe quel autre type, auquel cas on teste
        `event[key] == value`.
        """
        for key, value in exp.items():
            if isinstance(value, tuple):
                getter = value[0]
                v = value[1]
            else:
                getter = lambda v: v
                v = value
            # dans un iCal, les fields sont en majuscules
            if getter(event[key.upper()]) != v:
                return False
        return True

    def _find_event(self, ev, l):
        for i, elt in enumerate(l):
            if self._test_event_equal(ev, elt):
                return i
        return None

    def assertCalEqual(self, ical_content, expected):
        remaining = expected.copy()
        unexpected = []

        cal = icalendar.Calendar.from_ical(ical_content)

        for ev in cal.walk("vevent"):
            i_found = self._find_event(ev, remaining)
            if i_found is not None:
                remaining.pop(i_found)
            else:
                unexpected.append(ev)

        self.assertListEqual(remaining, [])
        self.assertListEqual(unexpected, [])


class TestCaseMixin:
    def assertForbidden(self, response):
        """
        Test that the response (retrieved with a Client) is a denial of access.

        The response should verify one of the following:
        - its HTTP response code is 403,
        - it redirects to the login page with a GET parameter named 'next'
        whose value is the url of the requested page.

        """
        request = response.wsgi_request

        try:
            try:
                # Is this an HTTP Forbidden response ?
                self.assertEqual(response.status_code, 403)
            except AssertionError:
                # A redirection to the login view is fine too.

                # Let's build the login url with the 'next' param on current
                # page.
                full_path = request.get_full_path()

                querystring = QueryDict(mutable=True)
                querystring["next"] = full_path

                login_url = "{}?{}".format(
                    reverse("cof-login"), querystring.urlencode(safe="/")
                )

                # We don't focus on what the login view does.
                # So don't fetch the redirect.
                self.assertRedirects(response, login_url, fetch_redirect_response=False)
        except AssertionError:
            raise AssertionError(
                "%(http_method)s request at %(path)s should be forbidden for "
                "%(username)s user.\n"
                "Response isn't 403, nor a redirect to login view. Instead, "
                "response code is %(code)d."
                % {
                    "http_method": request.method,
                    "path": request.get_full_path(),
                    "username": (
                        "'{}'".format(request.user)
                        if request.user.is_authenticated()
                        else "anonymous"
                    ),
                    "code": response.status_code,
                }
            )

    def assertUrlsEqual(self, actual, expected):
        """
        Test that the url 'actual' is as 'expected'.

        Arguments:
            actual (str): Url to verify.
            expected: Two forms are accepted.
                * (str): Expected url. Strings equality is checked.
                * (dict): Its keys must be attributes of 'urlparse(actual)'.
                    Equality is checked for each present key, except for
                    'query' which must be a dict of the expected query string
                    parameters.

        """
        if type(expected) == dict:
            parsed = urlparse(actual)
            for part, expected_part in expected.items():
                if part == "query":
                    self.assertDictEqual(
                        parse_qs(parsed.query), expected.get("query", {})
                    )
                else:
                    self.assertEqual(getattr(parsed, part), expected_part)
        else:
            self.assertEqual(actual, expected)


class ViewTestCaseMixin(TestCaseMixin):
    """
    Utilitaire pour automatiser certains tests sur les vues Django.

    Création d'utilisateurs
    ------------------------
    # Données de base
    On crée dans tous les cas deux utilisateurs : un utilisateur normal "user",
    et un superutilisateur "root", avec un mot de passe identique au username.

    # Accès et utilisateurs supplémentaires
    Les utilisateurs créés sont accessibles dans le dict `self.users`, qui associe
    un label à une instance de User.

    Pour rajouter des utilisateurs supplémentaires (et s'assurer qu'ils sont
    disponibles dans `self.users`), on peut redéfinir la fonction `get_users_extra()`,
    qui doit renvoyer là aussi un dict <label: User instance>.

    Misc QoL
    ------------------------
    Pour éviter une erreur de login (puisque les messages de Django ne sont pas
    disponibles), les messages de bienvenue de GestioCOF sont patchés.
    Un attribut `self.now` est fixé au démarrage, pour être donné comme valeur
    de retour à un patch local de `django.utils.timezone.now`. Cela permet de
    tester des dates/heures de manière robuste.

    Test d'URLS
    ------------------------

    # Usage basique
    Teste que l'URL générée par `reverse` correspond bien à l'URL théorique.
    Attributs liés :
    - `url_name` : nom de l'URL qui sera donné à `reverse`,
    - `url_expected` : URL attendue en retour.
    - (optionnels) `url_args` et `url_kwargs` : arguments de l'URL pour `reverse`.

    # Usage avancé
    On peut tester plusieurs URLs pour une même vue, en redéfinissant la fonction
    `urls_conf()`. Cette fonction doit retourner une liste de dicts, avec les clés
    suivantes : `name`, `args`, `kwargs`, `expected`.

    # Accès aux URLs générées
    Dans le cas d'usage basique, l'attribut `self.url` contient l'URL de la vue testée
    (telle que renvoyée par `reverse()`). Si plusieurs URLs sont définies dans
    `urls_conf()`, elles sont accessibles par la suite dans `self.reversed_urls`.

    Authentification
    ------------------------
    Si l'attribut `auth_user` est dans `self.users`, l'utilisateur correspondant
    est authentifié avant chaque test (cela n'empêche bien sûr pas de login un autre
    utilisateur à la main).

    Test de restrictions d'accès
    ------------------------
    L'utilitaire vérifie automatiquement que certains utilisateurs n'ont pas accès à la
    vue. Plus spécifiquement, sont testés toutes les méthodes dans `self.http_methods`
    et tous les utilisateurs dans `self.auth_forbidden`. Pour rappel, l'utilisateur
    `None` sert à tester la vue sans authentification.
    On peut donner des paramètres GET/POST/etc. aux tests en définissant un attribut
    <methode>_data.

    TODO (?): faire pareil pour vérifier les GET/POST classiques (code 200)
    """

    url_name = None
    url_expected = None

    http_methods = ["GET"]

    auth_user = None
    auth_forbidden = []

    def setUp(self):
        """
        Warning: Do not forget to call super().setUp() in subclasses.
        """

        patcher_messages = mock.patch("gestioncof.signals.messages")
        patcher_messages.start()
        self.addCleanup(patcher_messages.stop)

        self.now = timezone.now()

        self.users = {}

        for label, user in dict(self.users_base, **self.users_extra).items():
            self.register_user(label, user)

        if self.auth_user:
            # The wrapper is a sanity check.
            self.assertTrue(
                self.client.login(username=self.auth_user, password=self.auth_user)
            )

    def tearDown(self):
        del self.users_base
        del self.users_extra

    def get_users_base(self):
        """
        Dict of <label: user instance>.

        Note: Don't access yourself this property. Use 'users_base' attribute
        which cache the returned value from here.
        It allows to give functions calls, which creates users instances, as
        values here.

        """
        return {
            "user": User.objects.create_user("user", "", "user"),
            "root": User.objects.create_superuser("root", "", "root"),
        }

    @cached_property
    def users_base(self):
        return self.get_users_base()

    def get_users_extra(self):
        """
        Dict of <label: user instance>.

        Note: Don't access yourself this property. Use 'users_base' attribute
        which cache the returned value from here.
        It allows to give functions calls, which create users instances, as
        values here.

        """
        return {}

    @cached_property
    def users_extra(self):
        return self.get_users_extra()

    def register_user(self, label, user):
        self.users[label] = user

    def get_user(self, label):
        if self.auth_user is not None:
            return self.auth_user
        return self.auth_user_mapping.get(label)

    @property
    def urls_conf(self):
        return [
            {
                "name": self.url_name,
                "args": getattr(self, "url_args", []),
                "kwargs": getattr(self, "url_kwargs", {}),
                "expected": self.url_expected,
            }
        ]

    @property
    def reversed_urls(self):
        return [
            reverse(
                url_conf["name"],
                args=url_conf.get("args", []),
                kwargs=url_conf.get("kwargs", {}),
            )
            for url_conf in self.urls_conf
            if url_conf["name"] is not None
        ]

    @property
    def url(self):
        return self.reversed_urls[0]

    def test_urls(self):
        for url, conf in zip(self.reversed_urls, self.urls_conf):
            self.assertEqual(url, conf["expected"])

    def test_forbidden(self):
        for method in self.http_methods:
            for user in self.auth_forbidden:
                for url in self.reversed_urls:
                    self.check_forbidden(method, url, user)

    def check_forbidden(self, method, url, user=None):
        method = method.lower()
        client = Client()
        if user is not None:
            client.login(username=user, password=user)

        send_request = getattr(client, method)
        data = getattr(self, "{}_data".format(method), {})

        r = send_request(url, data)
        self.assertForbidden(r)
