#!/usr/bin/env python
# -*- coding: UTF-8 -*-

from twisted.internet import reactor
from zephir.backend.config import log
from zephir.entpool import IdPool
from zephir.backend.uucp_utils import uucp_pool, UUCPError
from zephir.backend.lib_backend import cx_pool
import random, os, time, traceback, base64
from hashlib import md5

class InternalError(Exception):
    pass

class ZephirIdPool(IdPool):

    def __init__(self, code_ent):
        super(ZephirIdPool,self).__init__(code_ent)
        # liste des plages déjà réservées
        self.reserved = []
        self.pending = []
        self.free = [(0,self.max_id)]
        self.free_space = self.max_id + 1

    def get_range(self, length = 100):
        """renvoie les identifiants minimum/maximum des nouvelles plages
        et met en place la réservation en attendant une confirmation
        """
        if length <= 0:
            raise InternalError("Intervalle invalide : %s" % length)
        #if self.pending:
        #    # une réservation est déjà en cours
        #    raise InternalError, "une réservation est déjà en cours"
        if length > self.free_space:
            raise InternalError("Pas assez d'identifiants disponibles")
        # recherche de plages disponibles
        ranges = []
        for r_min, r_max in self.free:
            r_len = r_max - r_min + 1
            if length <= r_len:
                ranges.append((r_min, r_min+length-1))
                break
            else:
                # plage insuffisante, il faut utiliser aussi la suivante
                ranges.append((r_min, r_max))
                length = length - r_len
        self.pending.append(ranges)
        # recalcul des plages libres
        return [(self.id_to_string(minid), self.id_to_string(maxid)) for minid, maxid in ranges]

    def _add_reserved(self, minid, maxid, id_serveur = -1):
        """ajoute une plage dans la liste des plages réservées
        """
        # recherche de l'emplacement où insérer la plage
        insert_index = 0
        # mise à jour des plages réservées
        if self.reserved:
            for (r_min, r_max, id_s) in self.reserved:
                if r_min > maxid:
                    # on est sur la plage précédent celle à insérer
                    break
                insert_index += 1
        # ajout de la plage réservée avant insert_index
        self.reserved.insert(insert_index, (minid, maxid, id_serveur))
        self.update_free_ranges()

    def update_free_ranges(self):
        """met à jour la liste des plages disponibles
        en fonction des plages réservées
        """
        reserved = [(r_min, r_max) for r_min, r_max, id_s in self.reserved]
        # on considère les plages en attente de validation comme non disponibles
        for p_rng in self.pending:
            reserved.extend(p_rng)
        # recalcul des plages libres
        free = []
        start = 0
        free_space = 0
        for r_min, r_max in reserved:
            if r_min != start:
                free.append((start, r_min - 1))
                free_space += r_max - r_min + 1
            start = r_max + 1
        if start <= self.max_id:
            free.append((start, self.max_id))
            free_space += self.max_id - start +1
        self.free = free
        self.free_space = free_space

    def reserve_range(self, minid, maxid):
        """réserve une plage spécifique si elle est disponible
        (utile pour bloquer des intervalles non gérés par zephir)
        """
        # vérification de la disponibilité de la plage
        valid = False
        for r_min, r_max in self.free:
            if r_min <= minid and r_max > minid:
                if r_max >= maxid:
                    # plage libre
                    valid = True
                    # on enregistre la nouvelle plage
                    self._add_reserved(minid, maxid)
                break
        return valid

    def cancel(self,ranges):
        """annule une réservation de plages
        """
        if ranges in self.pending:
            self.pending.remove(ranges)
            self.update_free_ranges()

    def validate(self, id_serveur, ranges):
        """valide la prise en compte de la plage réservée
        """
        if ranges in self.pending:
            self.pending.remove(ranges)
            for r_min, r_max in ranges:
                self._add_reserved(r_min, r_max, id_serveur)
            return True
        return False

    def __repr__(self):
        """représentation par défaut de l'objet
        """
        descr = "ENT %s" % self.code_ent
        if not self.pending:
            descr += " - identifiants disponibles: %s" % (self.free_space)
        if self.pending:
            rngs=[]
            for ranges in self.pending:
                for r_min, r_max in ranges:
                    rngs.append("%s->%s" % (self.id_to_string(r_min), self.id_to_string(r_max)))
            descr += " - plage en attente de validation : %s" % " - ".join(rngs)
        return descr

    def to_dict(self):
        """renvoie les données actuelles sous forme de dictionnaire pour sauvegarde en base de données
        """
        return {'code_ent':self.code_ent, 'free_space':str(self.free_space)}

class IdPoolManager:
    """classe de gestion d'un ensemble de pool d'identifiants pour les ENT
    se reporter au Schéma Directeur des Espaces Numériques de Travail
    http://www.educnet.education.fr/services/ent/sdet
    """

    def __init__(self, serveur_pool):
        # chargement des plages déjà réservées depuis la base
        self.serveur_pool = serveur_pool
        self.id_pools = self.load_pools()
        log.msg("chargement des pools d'identifiants ENT")
        for pool in list(self.id_pools.values()):
            log.msg(pool)
        self.timeouts = {}

    def load_pools(self):
        """initialise les pools d'identifiant depuis les données stockées en base
        """
        # récupération des plages réservées dans la base
        cursor = cx_pool.create()
        cursor.execute("select code_ent, serveur, min, max from ent_id_ranges order by code_ent, min")
        ranges = cursor.fetchall()
        cx_pool.close(cursor)
        # initialisation des pools
        pools = {}
        if ranges:
            for code_ent, id_serveur, minid, maxid in ranges:
                if code_ent not in pools:
                    pools[code_ent] = ZephirIdPool(code_ent)
                pools[code_ent]._add_reserved(minid, maxid, id_serveur)
        return pools

    def get_pool(self, code_ent = None):
        """renvoie les informations d'un pool (plages réservées et nombre d'identifiants disponibles)
        code_ent: code ENT du pool à renvoyer (tous si rien)
        """
        data = []
        if code_ent:
            if code_ent in self.id_pools:
                pools = [self.id_pools[code_ent]]
            else:
                pools = []
        else:
            pools = list(self.id_pools.values())
        for pool in pools:
            ranges = []
            for minid, maxid, id_s in pool.reserved:
                ranges.append([pool.id_to_string(minid), pool.id_to_string(maxid), id_s])
            free_ranges = []
            for minid, maxid in pool.free:
                free_ranges.append([pool.id_to_string(minid), pool.id_to_string(maxid)])
            data.append([pool.code_ent, pool.free_space, ranges, free_ranges])
        return 1, data

    def get_code_ent(self, code_ent = None):
        """renvoie la liste des codes ent connus
        """
        return list(self.id_pools.keys())

    def get_id_range(self, id_serveur, cle_pub, nb_id = 100):
        """réserve une plage dans le pool d'adresses d'un ent
        - le code ent est récupéré dans la configuration du serveur
        - si le pool n'a jamais été utilisé, on l'initialise
        - le pool se met en attente de validation et bloque les autres demandes en attendant
          (si la validation n'est pas faite après un certain temps, on annule la réservation)
        - les données sont envoyées par uucp dans un fichier dont on garde le md5
          (une action de validation est aussi programmée)
        """
        # récupération du code ent
        try:
            serv = self.serveur_pool[int(id_serveur)]
        except:
            return 0, "Serveur inconnu"
        # lecture de la clé publique du serveur
        f_cle = os.path.join(serv.get_confdir(), 'cle_publique')
        data_cle = open(f_cle).read().strip()
        try:
            assert data_cle == """command="sudo /usr/sbin/uucico2 -D -l" %s""" % base64.decodebytes(cle_pub.encode()).decode()
        except:
            return 0, "Cle incorrecte pour le serveur %s" % str(id_serveur)
        try:
            code_ent = serv.parsedico()['code_ent']
            assert len(code_ent) == 2
        except:
            return 0, "Code ENT invalide pour ce serveur"
        # si une réservation est déjà en cours pour ce serveur, on en interdit une autre
        if code_ent not in self.timeouts:
            self.timeouts[code_ent] = {}
        if self.timeouts[code_ent].get(id_serveur,None):
            return 0, "Une réservation est encore en cours pour ce serveur"
        # récupération de la plage
        try:
            if not code_ent in self.id_pools:
                # initialisation d'un nouveau pool
                self.id_pools[code_ent] = ZephirIdPool(code_ent)
            pool = self.id_pools[code_ent]
            ranges = pool.get_range(nb_id)
        except Exception as e:
            return 0, (str(e))
        # préparation de l'envoi du fichier et de la commande de prise en compte
        random_id = self.send_interval(serv, ranges)
        # mise en place d'un timeout pour annuler la réservation
        # dans 30 secondes si elle n'est pas validée. On stocke le un md5 aléatoire pour
        # servir de 'mot de passe' lors de la validation par le client
        self.timeouts[code_ent][id_serveur] = (random_id, reactor.callLater(30, self.cancel, pool, id_serveur, ranges))
        log.msg(str(pool))
        return 1, "OK"

    def cancel(self, pool, id_serveur, ranges):
        """annule une réservation après un délai d'attente
        """
        code_ent = pool.code_ent
        if id_serveur in self.timeouts.get(code_ent,{}):
            log.msg("ENT %s - timeout de la reservation (serveur %s)" % (str(code_ent), str(id_serveur)))
            pool.cancel(ranges)
            del self.timeouts[code_ent][id_serveur]

    def validate_id_range(self, code_ent, id_serveur, md5_id, ranges):
        """Valide une réservation envoyée à un serveur
        - vérifie que l'id du serveur et le md5 renvoyé correspond à la réservation en cours
        - met à jour le pool d'identifiant de ce code ent
        """
        err = "Réservation invalide"
        if id_serveur in self.timeouts.get(code_ent,{}):
            pool = self.id_pools[code_ent]
            if pool.pending:
                stored_id, call_id = self.timeouts[code_ent][id_serveur]
                try:
                    # calcul des plages envoyées
                    num_ranges = [(pool.string_to_id(minid),pool.string_to_id(maxid)) for minid, maxid in ranges]
                    assert num_ranges in pool.pending
                    assert md5_id == stored_id
                    if pool.validate(id_serveur, num_ranges):
                        # suppression de la réservation et du callback de timeout
                        call_id.cancel()
                        del self.timeouts[code_ent][id_serveur]
                        return self.store_range(code_ent, id_serveur, pool, num_ranges)
                except:
                    traceback.print_exc()
                    # par sécurité, on ne donne pas le détail de l'erreur
                    pass
        log.msg("ENT %s - Plage(s) invalide(s) demandée par le serveur %s : " % (str(code_ent), str(id_serveur)), str(ranges))
        return 0, err

    def store_range(self, code_ent, id_serveur, pool, ranges):
        """enregistre une plage réservée dans la base de données"""
        date = str(time.ctime())
        cursor = cx_pool.create()
        for cur_min, cur_max in ranges:
            query = """insert into ent_id_ranges (code_ent, serveur, min, max, date_valid) values(%s, %s, %s, %s, %s)"""
            params =  (code_ent, id_serveur, cur_min, cur_max, date)
            cursor.execute(query, params)
            if id_serveur == -1:
                # plage non rattachée à un serveur (définie manuellement)
                log.msg("ENT %s - plage bloquée : " % str(code_ent), pool.id_to_string(cur_min), "->", pool.id_to_string(cur_max))
            else:
                log.msg("ENT %s - plage validée par le serveur %s : " % (str(code_ent), str(id_serveur)) \
                        , pool.id_to_string(cur_min) , "->", pool.id_to_string(cur_max))
        cx_pool.commit(cursor)
        return 1, "OK"

    def reserve_range(self, minid, maxid):
        """Réservation manuelle d'une plage d'identifiant
        """
        code_ent = minid[0]+minid[3]
        if (code_ent == maxid[0] + maxid[3]):
            if not code_ent in self.id_pools:
                #initialisation d'un nouveau pool
                self.id_pools[code_ent] = ZephirIdPool(code_ent)
            pool = self.id_pools[code_ent]
        else:
            # XXX FIXME : ajouter l'ent si inconnu ?
            return 0, "code ENT invalide"
        minid = pool.string_to_id(minid)
        maxid = pool.string_to_id(maxid)
        if minid > maxid:
            return 0, "Plage d'identifiants invalide"
        else:
            # réservation de la plage
            if pool.reserve_range(minid, maxid):
                return self.store_range(code_ent, -1, pool, [(minid, maxid)])
            else:
                return 0, "Plage non disponible"

    def send_interval(self, serv, ranges):
        """envoi d'un fichier indiquant l'intervalle réservé
        """
        # génération d'un hash aléatoire
        rand = random.SystemRandom().random()
        random_id = md5(str(rand))
        for rng in ranges:
            random_id.update(str(rng))
        random_id = random_id.hexdigest()
        id_uucp = str(serv.get_rne()) + '-' + str(serv.id_s)
        try:
            serveur_dir = serv.get_confdir()
            # écriture fichier de transfert
            archive = "ent_ids"
            content = random_id
            for r_min, r_max in ranges:
                content += "\n%s\t%s" % (r_min, r_max)
            f_arc = open(os.path.join(serveur_dir, archive), 'w')
            f_arc.write(content)
            f_arc.close()
            # préparation de l'envoi par uucp
            cmd_tar = ['cd ', serveur_dir, ';', '/bin/tar', '--same-owner', '-chpf', archive+'.tar', 'ent_ids']
            # création du fichier tar à envoyer
            cmd_tar.append('>/dev/null 2>&1')
            res = os.system(" ".join(cmd_tar))
            cmd_checksum = """cd %s ;md5sum -b %s.tar > %s.md5""" % (serveur_dir, archive, archive)
            res = os.system(cmd_checksum)
            # préparation des commandes
            res = uucp_pool.add_cmd(id_uucp, "zephir_client update_ent_ids")
            res = uucp_pool.add_file(id_uucp, os.path.join(serveur_dir, archive+".tar"))
            os.unlink(os.path.join(serveur_dir, archive))
        except Exception as e:
            return 0, "Erreur de préparation du fichier (%s)" % str(e)
        return random_id
