# -*- coding: utf-8 -*-
"""
PLANNING TEAM - mini-serveur local.

Sert la page et expose une petite API qui lit et ecrit dans travail/planning.db.
Meme principe que les autres outils du dossier IA : on lance le .cmd, la page s'ouvre.

    python serveur.py [port]
"""

import datetime
import json
import os
import sys
import urllib.parse
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

BASE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(BASE, 'travail'))

import base as bd            # noqa: E402
import calendrier as cal     # noqa: E402
import comptes as cp         # noqa: E402
import conges as cg          # noqa: E402
import demandes as dm        # noqa: E402
import reel                  # noqa: E402
import sauvegarde as sv      # noqa: E402


NOM_COOKIE = 'planning_team'

# Ce qui reste atteignable sans etre connecte : la page de connexion, celle de premiere
# connexion, et le strict necessaire pour s'authentifier.
PUBLIC = ('/Connexion.html', '/Premiere-Connexion.html',
          '/api/connexion', '/api/invitation', '/api/invitation/activer', '/api/moi')

# Ce a quoi un compte « equipe » a droit. Tout le reste est reserve a la gestion.
ROUTES_EQUIPE = ('/api/etat', '/api/reelle', '/api/conges', '/api/demandes',
                 '/api/demande', '/api/demande/collegue', '/api/impact',
                 '/api/heures', '/api/deconnexion', '/api/moi', '/api/mot-de-passe')


def _json_bytes(donnees):
    return json.dumps(donnees, ensure_ascii=False).encode('utf-8')


class Handler(SimpleHTTPRequestHandler):

    # ------------------------------------------------------------- reponses

    _cookie_a_poser = None
    _filtre_equipe = None       # salarie_id quand la reponse doit etre cloisonnee

    def _repondre(self, donnees, code=200):
        # Dernier passage avant l'envoi : si l'appelant est un compte « equipe », la
        # reponse est allegee de tout ce qui ne le regarde pas.
        if self._filtre_equipe is not None and code == 200:
            route = urllib.parse.urlparse(self.path).path
            donnees = self._filtrer(route, donnees, self._filtre_equipe)
        corps = _json_bytes(donnees)
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(corps)))
        self.send_header('Cache-Control', 'no-store')
        if self._cookie_a_poser:
            self.send_header('Set-Cookie', self._cookie_a_poser)
            self._cookie_a_poser = None
        self.end_headers()
        self.wfile.write(corps)

    def _erreur(self, message, code=400):
        self._repondre({'erreur': message}, code)

    # ------------------------------------------------------- authentification

    def _jeton(self):
        brut = self.headers.get('Cookie') or ''
        for morceau in brut.split(';'):
            nom, _, valeur = morceau.strip().partition('=')
            if nom == NOM_COOKIE:
                return valeur
        return None

    def _poser_cookie(self, jeton):
        # HttpOnly : le jeton reste inaccessible au JavaScript de la page, donc une
        # faille d'affichage ne peut pas le voler. SameSite=Lax : il ne part pas avec
        # les requetes venues d'un autre site.
        self._cookie_a_poser = (
            '%s=%s; Path=/; HttpOnly; SameSite=Lax; Max-Age=%d'
            % (NOM_COOKIE, jeton, cp.DUREE_SESSION_JOURS * 86400))

    def _effacer_cookie(self):
        self._cookie_a_poser = '%s=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0' % NOM_COOKIE

    def _utilisateur(self, cx):
        return cp.session(cx, self._jeton())

    # ------------------------------------------------------------ cloisonnement

    @staticmethod
    def _filtrer(route, donnees, moi):
        """
        Ce qu'un compte « equipe » a le droit de recevoir.

        Le filtrage se fait ICI, dans la reponse du serveur — pas dans la page. Une
        page peut etre bidouillee, une reponse qui ne contient pas la donnee ne peut
        pas la reveler. Regles retenues au cadrage :

          * les horaires des collegues sont visibles (utile pour les echanges) ;
          * leurs heures, contrats et compteurs ne le sont pas ;
          * un collegue absent apparait « absent », jamais avec le motif : un arret
            maladie ou une AJPP sont des donnees de sante.
        """
        if not isinstance(donnees, dict):
            return donnees

        if route == '/api/etat':
            donnees['salaries'] = [
                s if s['id'] == moi else
                {k: v for k, v in s.items()
                 if k not in ('contrat_h_moyen', 'date_entree', 'paye_a_l_heure')}
                for s in donnees.get('salaries', [])]
            return donnees

        if route == '/api/reelle':
            for jour in donnees.get('jours', []):
                lignes = []
                for l in jour.get('lignes', []):
                    if l['salarie_id'] == moi:
                        lignes.append(l)
                        continue
                    lignes.append({
                        'salarie_id': l['salarie_id'],
                        # les creneaux restent, sans le detail administratif
                        'creneaux': [{'debut_min': c['debut_min'], 'fin_min': c['fin_min'],
                                      'hors_comptoir': c['hors_comptoir']}
                                     for c in l['creneaux']],
                        'absent': l['absent'],
                        'motif': (u'F\xe9ri\xe9' if l.get('motif') == u'F\xe9ri\xe9'
                                  else (u'absent' if l['absent'] else None)),
                        'change_le': l.get('change_le'),
                    })
                jour['lignes'] = lignes
            donnees.pop('remplacants', None)
            donnees.pop('recuperation', None)
            return donnees

        if route == '/api/conges':
            donnees['soldes'] = [s for s in donnees.get('soldes', [])
                                 if s['salarie_id'] == moi]
            donnees['absences'] = [a for a in donnees.get('absences', [])
                                   if a['salarie_id'] == moi]
            donnees['ajustements'] = []
            return donnees

        if route in ('/api/demandes', '/api/demande', '/api/demande/collegue'):
            donnees['demandes'] = [d for d in donnees.get('demandes', [])
                                   if d['salarie_id'] == moi or d.get('collegue_id') == moi]
            donnees.pop('en_attente', None)
            return donnees

        if route == '/api/impact':
            donnees.pop('remplacants', None)
            return donnees

        if route == '/api/heures':
            donnees['soldes'] = [s for s in donnees.get('soldes', [])
                                 if s['salarie_id'] == moi]
            donnees['ajustements'] = []
            return donnees

        return donnees

    def _acces_refuse(self, chemin):
        """
        Renvoie une reponse si l'acces doit etre refuse, None sinon.

        Tant que la connexion n'est pas exigee (outil local), tout passe. Une fois
        activee, il faut une session valable, et un compte « equipe » ne voit que les
        routes qui le concernent.
        """
        self._filtre_equipe = None
        cx = bd.connexion()
        try:
            if not cp.connexion_exigee(cx):
                return None
            if chemin in PUBLIC:
                return None
            utilisateur = self._utilisateur(cx)
            if utilisateur is None:
                return ('connexion', u'connexion requise')
            if utilisateur['role'] != 'gestion':
                if chemin not in ROUTES_EQUIPE:
                    return ('refus', u'cette partie de l’outil est r\xe9serv\xe9e '
                                     u'\xe0 J\xe9r\xf4me et Aurore')
                self._filtre_equipe = utilisateur['salarie_id']
            return None
        finally:
            cx.close()

    # ------------------------------------------------------------------ GET

    def do_GET(self):
        chemin = urllib.parse.urlparse(self.path)

        refus = self._acces_refuse(chemin.path)
        if refus and not chemin.path.startswith('/api/'):
            # Une page demandee sans session : on renvoie vers la connexion, et un
            # compte « equipe » va directement sur sa propre page.
            self.send_response(302)
            self.send_header('Location', '/Connexion.html')
            self.end_headers()
            return
        if refus:
            return self._erreur(refus[1], 401 if refus[0] == 'connexion' else 403)

        if not chemin.path.startswith('/api/'):
            return SimpleHTTPRequestHandler.do_GET(self)

        params = urllib.parse.parse_qs(chemin.query)
        cx = bd.connexion()
        try:
            if chemin.path == '/api/moi':
                utilisateur = self._utilisateur(cx)
                return self._repondre({
                    'connecte': utilisateur is not None,
                    'connexion_exigee': cp.connexion_exigee(cx),
                    'utilisateur': utilisateur,
                })

            if chemin.path == '/api/invitation':
                bloc = cp.compte_par_invitation(cx, params.get('jeton', [''])[0])
                if bloc is None:
                    return self._erreur(u'ce lien n’est plus valable — en redemander un')
                return self._repondre({'identifiant': bloc['identifiant'],
                                       'prenom': bloc['prenom']})

            if chemin.path == '/api/comptes':
                invitations = cp.invitations_en_cours(cx)
                comptes = cp.liste(cx)
                for c in comptes:
                    c['invitation'] = invitations.get(c['id'])
                return self._repondre({
                    'comptes': comptes,
                    'connexion_exigee': cp.connexion_exigee(cx),
                    'roles': list(cp.ROLES),
                })

            if chemin.path == '/api/etat':
                return self._repondre({
                    'salaries': bd.salaries(cx),
                    'jours': bd.JOURS,
                    'grille': bd.creneaux_grille(),
                    'ouverture': {str(j): bd.creneaux_ouverture(j) for j in range(1, 7)},
                    'derniere': {str(j): bd.derniere_demi_heure(j) for j in range(1, 7)},
                    'mini_comptoir': bd.MINI_COMPTOIR,
                    'groupes': bd.GROUPES,
                    'debut': {'annee': cal.DEBUT_ANNEE, 'semaine': cal.DEBUT_SEMAINE,
                              'lundi': cal.lundi_de(cal.DEBUT_ANNEE,
                                                    cal.DEBUT_SEMAINE).isoformat()},
                    'defaut': dict(zip(('annee', 'semaine'), cal.semaine_par_defaut())),
                })

            if chemin.path == '/api/semaine':
                try:
                    cycle = int(params.get('cycle', ['1'])[0])
                except ValueError:
                    return self._erreur(u'cycle invalide')
                if cycle not in (1, 2):
                    return self._erreur(u'cycle doit valoir 1 ou 2')
                return self._repondre({
                    'cycle': cycle,
                    'creneaux': bd.creneaux(cx, cycle),
                    'presence': {str(j): p for j, p in bd.presence(cx, cycle).items()},
                    'compteurs': bd.compteurs(cx),
                    'jours_ajpp': {str(k): v for k, v in bd.jours_ajpp(cx, cycle).items()},
                    'modifiees': bd.lignes_modifiees(cx, cycle),
                })

            if chemin.path == '/api/reelle':
                defaut = cal.semaine_par_defaut()
                try:
                    annee = int(params.get('annee', [str(defaut[0])])[0])
                    numero = int(params.get('numero', [str(defaut[1])])[0])
                except ValueError:
                    return self._erreur(u'annee ou numero invalide')
                if cal.avant_le_debut(annee, numero):
                    return self._erreur(
                        u'le planning commence \xe0 la semaine %d de %d '
                        u'(le nouveau cycle d\xe9marre le %s)'
                        % (cal.DEBUT_SEMAINE, cal.DEBUT_ANNEE,
                           cal.lundi_de(cal.DEBUT_ANNEE, cal.DEBUT_SEMAINE)
                           .strftime('%d/%m/%Y')))
                try:
                    return self._repondre(reel.semaine(cx, annee, numero))
                except ValueError:
                    return self._erreur(u'cette semaine ISO n\'existe pas')

            if chemin.path == '/api/mois':
                try:
                    annee = int(params.get('annee', ['2026'])[0])
                    numero = int(params.get('mois', ['9'])[0])
                except ValueError:
                    return self._erreur(u'annee ou mois invalide')
                if not 1 <= numero <= 12:
                    return self._erreur(u'mois hors limites')
                bloc = cal.mois(annee, numero)
                bloc['absences'] = cg.absences(
                    cx, bloc['semaines'][0]['jours'][0]['date'],
                    bloc['semaines'][-1]['jours'][-1]['date'])
                return self._repondre(bloc)

            if chemin.path == '/api/conges':
                return self._repondre({
                    'soldes': cg.soldes(cx),
                    'motifs': [{'code': m[0], 'libelle': m[1], 'decompte': m[2],
                                'salaire_maintenu': m[3]} for m in cg.MOTIFS],
                    'absences': cg.absences(cx, params.get('debut', [None])[0],
                                            params.get('fin', [None])[0]),
                    'ajustements': cg.historique_ajustements(cx),
                    'vacances': [{'nom': p['nom'], 'debut': p['debut'].isoformat(),
                                  'fin': p['fin'].isoformat()}
                                 for p in cal.vacances_scolaires()],
                })

            if chemin.path == '/api/sauvegardes':
                fichiers = sv.liste()
                return self._repondre({
                    'sauvegardes': [{k: f[k] for k in ('nom', 'quand', 'octets', 'genre')}
                                    for f in fichiers],
                    'dossier': sv.DOSSIER,
                    'garde_jours': sv.GARDE_JOURS,
                    'du_jour': sv.sauvegarde_du_jour_faite(),
                })

            if chemin.path == '/api/heures':
                ecarts = reel.ecarts_cumules(cx)
                return self._repondre({
                    'soldes': cg.soldes_heures(cx, ecarts),
                    'ajustements': cg.historique_heures(cx),
                    'types': list(cg.TYPES_HEURES),
                })

            if chemin.path == '/api/demandes':
                return self._repondre({
                    'demandes': dm.liste(cx, statut=params.get('statut', [None])[0],
                                         salarie_id=(int(params['salarie_id'][0])
                                                     if 'salarie_id' in params else None)),
                    'en_attente': dm.en_attente(cx),
                    'types': [{'code': k, 'libelle': v} for k, v in dm.TYPES.items()],
                })

            if chemin.path == '/api/impact':
                try:
                    sid = int(params['salarie_id'][0])
                    debut, fin = params['debut'][0], params['fin'][0]
                except (KeyError, IndexError, ValueError):
                    return self._erreur(u'parametres attendus : salarie_id, debut, fin')
                try:
                    return self._repondre(dm.impact(cx, sid, debut, fin))
                except ValueError as erreur:
                    return self._erreur(str(erreur))

            if chemin.path == '/api/heures-prevues':
                try:
                    sid = int(params['salarie_id'][0])
                    debut = datetime.date.fromisoformat(params['debut'][0])
                    fin = datetime.date.fromisoformat(params['fin'][0])
                except (KeyError, IndexError, ValueError):
                    return self._erreur(u'parametres attendus : salarie_id, debut, fin')
                return self._repondre(reel.heures_prevues(cx, sid, debut, fin))

            return self._erreur(u'route inconnue', 404)
        finally:
            cx.close()

    # ----------------------------------------------------------------- POST

    def do_POST(self):
        chemin = urllib.parse.urlparse(self.path)
        refus = self._acces_refuse(chemin.path)
        if refus:
            return self._erreur(refus[1], 401 if refus[0] == 'connexion' else 403)

        routes = ('/api/connexion', '/api/deconnexion', '/api/invitation/activer',
                  '/api/compte', '/api/mot-de-passe', '/api/reglage',
                  '/api/creneau', '/api/ajpp', '/api/reinitialiser', '/api/reimporter',
                  '/api/absence', '/api/absence/supprimer', '/api/solde',
                  '/api/heures', '/api/heures/basculer',
                  '/api/sauvegarde', '/api/sauvegarde/restaurer',
                  '/api/jour', '/api/creneau-reel', '/api/jour/cycle',
                  '/api/demande', '/api/demande/repondre',
                  '/api/demande/collegue', '/api/demande/supprimer')
        if chemin.path not in routes:
            return self._erreur(u'route inconnue', 404)

        try:
            taille = int(self.headers.get('Content-Length') or 0)
            corps = json.loads(self.rfile.read(taille).decode('utf-8')) if taille else {}
        except (ValueError, TypeError):
            return self._erreur(u'corps de requete illisible')

        if chemin.path == '/api/reimporter':
            cx = bd.connexion()
            try:
                bd.importer(cx, bavard=False)
                return self._repondre(self._instantane(cx, 1))
            finally:
                cx.close()

        # --- connexion, comptes, mots de passe ------------------------------------
        if chemin.path in ('/api/connexion', '/api/deconnexion',
                           '/api/invitation/activer', '/api/compte',
                           '/api/mot-de-passe', '/api/reglage'):
            cx = bd.connexion()
            try:
                return self._authentification(cx, chemin.path, corps)
            finally:
                cx.close()

        # --- sauvegardes ---------------------------------------------------------
        if chemin.path in ('/api/sauvegarde', '/api/sauvegarde/restaurer'):
            try:
                if chemin.path == '/api/sauvegarde':
                    resultat = sv.sauvegarder(bavard=False)
                    sv.purger(bavard=False)
                else:
                    resultat = sv.restaurer(corps['nom'], bavard=False)
            except (KeyError, TypeError, IOError, ValueError) as erreur:
                return self._erreur(str(erreur) or u'sauvegarde impossible')
            reponse = {'sauvegardes': [{k: f[k] for k in
                                        ('nom', 'quand', 'octets', 'genre')}
                                       for f in sv.liste()]}
            reponse.update({k: v for k, v in resultat.items() if k != 'chemin'})
            return self._repondre(reponse)

        # --- compteurs d'heures : recuperation et heures supplementaires --------
        if chemin.path in ('/api/heures', '/api/heures/basculer'):
            cx = bd.connexion()
            try:
                ecarts = reel.ecarts_cumules(cx)
                try:
                    salarie_id = int(corps['salarie_id'])
                    if chemin.path == '/api/heures/basculer':
                        cg.basculer_recup_en_hs(cx, salarie_id, corps['heures'],
                                                motif=corps.get('motif'),
                                                auteur=corps.get('auteur'))
                        resultat = {}
                    else:
                        resultat = cg.ajuster_heures(
                            cx, salarie_id, corps['type'], corps['solde'],
                            corps.get('motif', ''),
                            ecart_planning=ecarts.get(salarie_id, 0.0),
                            auteur=corps.get('auteur'))
                except (KeyError, TypeError, ValueError) as erreur:
                    return self._erreur(str(erreur) or u'parametres invalides')

                ecarts = reel.ecarts_cumules(cx)
                reponse = {'soldes': cg.soldes_heures(cx, ecarts),
                           'ajustements': cg.historique_heures(cx)}
                reponse.update(resultat)
                return self._repondre(reponse)
            finally:
                cx.close()

        # --- retouche d'une vraie date, sans toucher au cycle -------------------
        if chemin.path in ('/api/creneau-reel', '/api/jour/cycle'):
            cx = bd.connexion()
            try:
                try:
                    salarie_id = int(corps['salarie_id'])
                    date = corps['date']
                    jour_date = datetime.date.fromisoformat(date)
                except (KeyError, TypeError, ValueError):
                    return self._erreur(u'champs attendus : salarie_id, date')

                if chemin.path == '/api/jour/cycle':
                    change = reel.rendre_au_cycle(cx, salarie_id, date)
                else:
                    try:
                        minute = int(corps['minute'])
                    except (KeyError, TypeError, ValueError):
                        return self._erreur(u'champ « minute » attendu')
                    if minute not in bd.creneaux_grille():
                        return self._erreur(u'demi-heure hors de la grille 8h30-19h30')
                    change = reel.basculer_creneau_reel(cx, salarie_id, date, minute)

                annee, numero = cal.semaine_de(jour_date)
                reponse = reel.semaine(cx, annee, numero)
                reponse['change'] = change
                reponse['soldes'] = cg.soldes(cx)
                return self._repondre(reponse)
            finally:
                cx.close()

        # --- une journee basculee depuis la grille des semaines reelles ---------
        if chemin.path == '/api/jour':
            cx = bd.connexion()
            try:
                try:
                    resultat = reel.basculer_jour(
                        cx, int(corps['salarie_id']), corps['date'], corps['motif'])
                except (KeyError, TypeError, ValueError) as erreur:
                    return self._erreur(str(erreur) or u'parametres invalides')
                annee, numero = cal.semaine_de(
                    datetime.date.fromisoformat(corps['date']))
                reponse = reel.semaine(cx, annee, numero)
                reponse['bascule'] = resultat
                reponse['soldes'] = cg.soldes(cx)
                return self._repondre(reponse)
            finally:
                cx.close()

        # --- demandes de l'equipe -----------------------------------------------
        if chemin.path.startswith('/api/demande'):
            cx = bd.connexion()
            try:
                if chemin.path == '/api/demande':
                    # Un compte « equipe » ne depose que pour lui-meme, quoi qu'il
                    # envoie : l'identite vient de la session, pas du formulaire.
                    if self._filtre_equipe is not None:
                        corps['salarie_id'] = self._filtre_equipe
                    try:
                        did = dm.deposer(
                            cx, corps['type'], int(corps['salarie_id']),
                            corps['date_debut'], corps['date_fin'],
                            motif=corps.get('motif'),
                            commentaire=corps.get('commentaire'),
                            collegue_id=corps.get('collegue_id'))
                    except (KeyError, TypeError, ValueError) as erreur:
                        return self._erreur(str(erreur) or u'parametres invalides')
                    return self._repondre({'id': did, 'demandes': dm.liste(cx),
                                           'en_attente': dm.en_attente(cx)})

                if chemin.path == '/api/demande/collegue':
                    try:
                        # On ne repond qu'aux echanges qui nous sont adresses.
                        if self._filtre_equipe is not None:
                            visee = dm.une(cx, int(corps['id']))
                            if not visee or visee['collegue_id'] != self._filtre_equipe:
                                return self._erreur(
                                    u'cet \xe9change ne vous est pas adress\xe9', 403)
                        dm.repondre_collegue(cx, int(corps['id']),
                                             bool(corps.get('accepte')))
                    except (KeyError, TypeError, ValueError) as erreur:
                        return self._erreur(str(erreur) or u'parametres invalides')
                    return self._repondre({'demandes': dm.liste(cx),
                                           'en_attente': dm.en_attente(cx)})

                if chemin.path == '/api/demande/supprimer':
                    dm.supprimer(cx, int(corps['id']))
                    return self._repondre({'demandes': dm.liste(cx),
                                           'en_attente': dm.en_attente(cx)})

                try:
                    dm.repondre(cx, int(corps['id']), bool(corps.get('accepte')),
                                reponse=corps.get('reponse'), par=corps.get('par'))
                except (KeyError, TypeError, ValueError) as erreur:
                    return self._erreur(str(erreur) or u'parametres invalides')
                reponse = self._conges(cx)
                reponse['demandes'] = dm.liste(cx)
                reponse['en_attente'] = dm.en_attente(cx)
                return self._repondre(reponse)
            finally:
                cx.close()

        # --- conges et absences -------------------------------------------------
        if chemin.path in ('/api/absence', '/api/absence/supprimer', '/api/solde'):
            cx = bd.connexion()
            try:
                if chemin.path == '/api/absence/supprimer':
                    try:
                        cg.supprimer_absence(cx, int(corps['id']))
                    except (KeyError, TypeError, ValueError):
                        return self._erreur(u'champ « id » attendu')
                    return self._repondre(self._conges(cx))

                if chemin.path == '/api/solde':
                    try:
                        resultat = cg.ajuster_solde(
                            cx, int(corps['salarie_id']), float(corps['solde']),
                            corps.get('motif', ''), corps.get('auteur'))
                    except (KeyError, TypeError, ValueError) as erreur:
                        return self._erreur(str(erreur) or u'parametres invalides')
                    reponse = self._conges(cx)
                    reponse.update(resultat)
                    return self._repondre(reponse)

                try:
                    # Le décompte des CP suit la règle des bordures : un jour non
                    # travaillé en début ou en fin de période ne compte pas.
                    jours = None
                    if (corps.get('decompte') or 'cp') == 'cp':
                        jours = reel.compter_cp(
                            cx, int(corps['salarie_id']),
                            datetime.date.fromisoformat(corps['date_debut']),
                            datetime.date.fromisoformat(corps['date_fin']))[0]
                    absence_id = cg.poser_absence(
                        cx, int(corps['salarie_id']), corps['date_debut'],
                        corps['date_fin'], corps['motif'], jours=jours,
                        decompte=corps.get('decompte'),
                        heures=corps.get('heures'),
                        salaire_maintenu=corps.get('salaire_maintenu'),
                        statut=corps.get('statut', 'validee'),
                        libelle=corps.get('libelle'))
                except (KeyError, TypeError, ValueError) as erreur:
                    return self._erreur(str(erreur) or u'parametres invalides')
                reponse = self._conges(cx)
                reponse['id'] = absence_id
                return self._repondre(reponse)
            finally:
                cx.close()

        # --- cycle de reference -------------------------------------------------
        try:
            salarie_id = int(corps['salarie_id'])
            cycle = int(corps['cycle'])
            jour = int(corps['jour'])
        except (KeyError, TypeError, ValueError):
            return self._erreur(u'champs attendus : salarie_id, cycle, jour')
        if cycle not in (1, 2) or not 1 <= jour <= 6:
            return self._erreur(u'cycle ou jour hors limites')

        cx = bd.connexion()
        try:
            if chemin.path == '/api/reinitialiser':
                change = bd.reinitialiser_ligne(cx, salarie_id, cycle, jour)
                reponse = self._instantane(cx, cycle)
                reponse['change'] = change
                return self._repondre(reponse)

            if chemin.path == '/api/ajpp':
                ajpp = bd.basculer_ajpp(cx, salarie_id, cycle, jour)
                if ajpp is None:
                    return self._erreur(u'cette personne ne travaille pas ce jour-la')
                reponse = self._instantane(cx, cycle)
                reponse['ajpp'] = ajpp
                return self._repondre(reponse)

            try:
                minute = int(corps['minute'])
            except (KeyError, TypeError, ValueError):
                return self._erreur(u'champ « minute » attendu')
            if minute not in bd.creneaux_grille():
                return self._erreur(u'demi-heure hors de la grille 8h30-19h30')

            travaille = bd.basculer_creneau(cx, salarie_id, cycle, jour, minute)
            reponse = self._instantane(cx, cycle)
            reponse['travaille'] = travaille
            return self._repondre(reponse)
        finally:
            cx.close()

    def _authentification(self, cx, route, corps):
        """Connexion, deconnexion, gestion des comptes et des mots de passe."""
        if route == '/api/connexion':
            compte = cp.verifier(cx, corps.get('identifiant'), corps.get('mot_de_passe'))
            if compte is None:
                return self._erreur(u'identifiant ou mot de passe incorrect', 401)
            self._poser_cookie(cp.ouvrir_session(cx, compte['id']))
            return self._repondre({'prenom': compte['prenom'], 'role': compte['role'],
                                   'doit_changer': compte['doit_changer']})

        if route == '/api/deconnexion':
            cp.fermer_session(cx, self._jeton())
            self._effacer_cookie()
            return self._repondre({'deconnecte': True})

        if route == '/api/invitation/activer':
            try:
                jeton = cp.activer_par_invitation(cx, corps['jeton'],
                                                  corps.get('mot_de_passe'))
            except (KeyError, TypeError, ValueError) as erreur:
                return self._erreur(str(erreur) or u'lien invalide')
            self._poser_cookie(jeton)
            return self._repondre({'active': True})

        if route == '/api/mot-de-passe':
            # Changer son propre mot de passe : il faut donner l'ancien.
            utilisateur = self._utilisateur(cx)
            if utilisateur is None:
                return self._erreur(u'connexion requise', 401)
            if cp.verifier(cx, utilisateur['identifiant'],
                           corps.get('ancien')) is None:
                return self._erreur(u'ancien mot de passe incorrect', 403)
            try:
                cp.definir_mot_de_passe(cx, utilisateur['compte_id'],
                                        corps.get('nouveau'))
            except ValueError as erreur:
                return self._erreur(str(erreur))
            self._poser_cookie(cp.ouvrir_session(cx, utilisateur['compte_id']))
            return self._repondre({'change': True})

        # Les routes qui suivent modifient les acces : gestion uniquement, et
        # seulement une fois la connexion activee (avant, l'outil est local).
        utilisateur = self._utilisateur(cx)
        if cp.connexion_exigee(cx) and (utilisateur is None
                                        or utilisateur['role'] != 'gestion'):
            return self._erreur(u'r\xe9serv\xe9 \xe0 J\xe9r\xf4me et Aurore', 403)

        if route == '/api/reglage':
            if corps.get('cle') != 'connexion_exigee':
                return self._erreur(u'r\xe9glage inconnu')
            valeur = 'oui' if corps.get('valeur') in (True, 'oui', 1) else 'non'
            if valeur == 'oui':
                # Refus d'activer si personne ne peut plus entrer.
                prets = [c for c in cp.liste(cx)
                         if c['role'] == 'gestion' and c['a_mot_de_passe'] and c['actif']]
                if not prets:
                    return self._erreur(
                        u'aucun compte de gestion n’a de mot de passe : activer la '
                        u'connexion vous enfermerait dehors')
            cp.definir_reglage(cx, 'connexion_exigee', valeur)
            return self._repondre({'connexion_exigee': valeur == 'oui'})

        if route == '/api/compte':
            try:
                compte_id = int(corps['id'])
                action = corps['action']
            except (KeyError, TypeError, ValueError):
                return self._erreur(u'champs attendus : id, action')
            try:
                resultat = {}
                if action == 'role':
                    cp.definir_role(cx, compte_id, corps.get('role'))
                elif action == 'actif':
                    cp.activer(cx, compte_id, bool(corps.get('actif')))
                elif action == 'invitation':
                    resultat = {'invitation': cp.creer_invitation(cx, compte_id)}
                elif action == 'provisoire':
                    mot = cp.mot_de_passe_provisoire()
                    cp.definir_mot_de_passe(cx, compte_id, mot, provisoire=True)
                    resultat = {'provisoire': mot}
                else:
                    return self._erreur(u'action inconnue : %s' % action)
            except ValueError as erreur:
                return self._erreur(str(erreur))

            invitations = cp.invitations_en_cours(cx)
            comptes = cp.liste(cx)
            for c in comptes:
                c['invitation'] = invitations.get(c['id'])
            resultat.update({'comptes': comptes,
                             'connexion_exigee': cp.connexion_exigee(cx)})
            return self._repondre(resultat)

        return self._erreur(u'route inconnue', 404)

    @staticmethod
    def _conges(cx):
        """L'etat des conges apres une modification : soldes, absences, ajustements."""
        return {
            'soldes': cg.soldes(cx),
            'absences': cg.absences(cx),
            'ajustements': cg.historique_ajustements(cx),
        }

    @staticmethod
    def _instantane(cx, cycle):
        """L'etat recalcule apres une modification : creneaux, presence, compteurs."""
        return {
            'cycle': cycle,
            'creneaux': bd.creneaux(cx, cycle),
            'presence': {str(j): p for j, p in bd.presence(cx, cycle).items()},
            'compteurs': bd.compteurs(cx),
            'jours_ajpp': {str(k): v for k, v in bd.jours_ajpp(cx, cycle).items()},
            'modifiees': bd.lignes_modifiees(cx, cycle),
        }

    def log_message(self, *args):
        pass                      # pas de bruit dans la fenetre du serveur


if __name__ == '__main__':
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8767
    os.chdir(BASE)

    if not os.path.exists(bd.CHEMIN_DB):
        print(u'Base absente : import du classeur de reference...')
        cx = bd.connexion()
        bd.importer(cx)
        cx.close()

    # Garde-fou : la connexion ne doit jamais rester exigee si plus personne ne peut
    # entrer. Le cas s'est produit apres une restauration de sauvegarde, qui a ramene
    # un etat ou la connexion etait active et les mots de passe absents — l'outil
    # devenait inaccessible. On verifie donc a chaque demarrage.
    cx = bd.connexion()
    try:
        if cp.connexion_exigee(cx):
            prets = [c for c in cp.liste(cx)
                     if c['role'] == 'gestion' and c['a_mot_de_passe'] and c['actif']]
            if not prets:
                cp.definir_reglage(cx, 'connexion_exigee', 'non')
                print(u'ATTENTION : la connexion etait exigee alors qu\'aucun compte de '
                      u'gestion n\'a de mot de passe. Elle a ete desactivee pour ne pas '
                      u'vous enfermer dehors.')
    finally:
        cx.close()

    # Une sauvegarde par jour, prise au demarrage, verifiee, et les plus vieilles
    # purgees au-dela de 30 jours.
    try:
        faite = sv.sauvegarde_quotidienne(bavard=False)
        if faite:
            print(u'Sauvegarde du jour : %s' % faite['nom'])
    except Exception as erreur:                      # une sauvegarde ratee ne doit
        print(u'Sauvegarde impossible : %s' % erreur)  # jamais empecher de travailler

    print(u'PLANNING TEAM pret sur http://127.0.0.1:%d  (ne pas fermer cette fenetre)' % port)
    ThreadingHTTPServer(('127.0.0.1', port), Handler).serve_forever()
