# -*- coding: utf-8 -*-
"""
PLANNING TEAM - comptes, mots de passe et sessions.

Prepare la mise en ligne (lot 4). Tant que l'outil tourne en local, la connexion peut
rester desactivee ; elle devra imperativement etre activee AVANT de publier quoi que ce
soit, puisque la page portera des donnees RH nominatives.

Deux roles :
  * gestion — Jerome et Aurore : tout voir, tout modifier, valider les demandes ;
  * equipe  — les salaries : leur propre planning, la presence des collegues sans leurs
              heures ni leurs conges, et leurs propres demandes.

Les mots de passe ne sont jamais stockes : on garde une empreinte PBKDF2-HMAC-SHA256
avec un sel tire au hasard pour chacun, 240 000 iterations. Tout est dans la
bibliotheque standard, aucune dependance a installer.
"""

import datetime
import hashlib
import hmac
import os
import secrets
import sys

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

ITERATIONS = 240000
DUREE_SESSION_JOURS = 30

ROLES = ('gestion', 'equipe')

SCHEMA = """
CREATE TABLE IF NOT EXISTS compte (
    id                INTEGER PRIMARY KEY,
    salarie_id        INTEGER NOT NULL UNIQUE REFERENCES salarie(id) ON DELETE CASCADE,
    identifiant       TEXT NOT NULL UNIQUE,
    empreinte         TEXT,           -- NULL tant qu'aucun mot de passe n'est defini
    sel               TEXT,
    role              TEXT NOT NULL DEFAULT 'equipe',
    actif             INTEGER NOT NULL DEFAULT 1,
    doit_changer      INTEGER NOT NULL DEFAULT 1,
    cree_le           TEXT NOT NULL,
    derniere_connexion TEXT
);

-- Invitation de premiere connexion : un lien a usage unique, valable 14 jours, par
-- lequel chacun choisit lui-meme son mot de passe. Jerome ne connait donc jamais les
-- mots de passe de l'equipe.
CREATE TABLE IF NOT EXISTS invitation (
    jeton     TEXT PRIMARY KEY,
    compte_id INTEGER NOT NULL REFERENCES compte(id) ON DELETE CASCADE,
    creee_le  TEXT NOT NULL,
    expire_le TEXT NOT NULL,
    utilisee_le TEXT
);

CREATE TABLE IF NOT EXISTS session (
    jeton      TEXT PRIMARY KEY,
    compte_id  INTEGER NOT NULL REFERENCES compte(id) ON DELETE CASCADE,
    ouverte_le TEXT NOT NULL,
    expire_le  TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS reglage (
    cle    TEXT PRIMARY KEY,
    valeur TEXT NOT NULL
);
"""


TABLES = ('compte', 'invitation', 'session', 'reglage')


def creer_schema(cx):
    presentes = {l[0] for l in cx.execute(
        "SELECT name FROM sqlite_master WHERE type = 'table'")}
    if all(t in presentes for t in TABLES):
        return
    cx.executescript(SCHEMA)
    cx.commit()


# ------------------------------------------------------------------ reglages

def reglage(cx, cle, defaut=None):
    creer_schema(cx)
    l = cx.execute('SELECT valeur FROM reglage WHERE cle = ?', (cle,)).fetchone()
    return l['valeur'] if l else defaut


def definir_reglage(cx, cle, valeur):
    creer_schema(cx)
    cx.execute("""INSERT INTO reglage (cle, valeur) VALUES (?, ?)
                  ON CONFLICT(cle) DO UPDATE SET valeur = excluded.valeur""",
               (cle, str(valeur)))
    cx.commit()


def connexion_exigee(cx):
    """
    Faut-il se connecter pour utiliser l'outil ?

    Reste a « non » tant qu'on travaille en local. **A passer a « oui » avant toute
    mise en ligne** : sans cela, n'importe qui atteignant l'adresse verrait les heures,
    les conges et les absences de toute l'equipe.
    """
    return reglage(cx, 'connexion_exigee', 'non') == 'oui'


# ------------------------------------------------------------- mots de passe

def _empreinte(mot_de_passe, sel):
    return hashlib.pbkdf2_hmac('sha256', mot_de_passe.encode('utf-8'),
                               bytes.fromhex(sel), ITERATIONS).hex()


def definir_mot_de_passe(cx, compte_id, mot_de_passe, provisoire=False):
    if len(mot_de_passe or '') < 8:
        raise ValueError(u'le mot de passe doit faire au moins 8 caract\xe8res')
    sel = secrets.token_hex(16)
    cx.execute(
        'UPDATE compte SET empreinte = ?, sel = ?, doit_changer = ? WHERE id = ?',
        (_empreinte(mot_de_passe, sel), sel, 1 if provisoire else 0, compte_id))
    cx.execute('DELETE FROM session WHERE compte_id = ?', (compte_id,))  # on deconnecte
    cx.commit()


def verifier(cx, identifiant, mot_de_passe):
    """Renvoie le compte si le mot de passe est bon, None sinon."""
    creer_schema(cx)
    l = cx.execute(
        """SELECT c.*, s.prenom, s.initiale FROM compte c
             JOIN salarie s ON s.id = c.salarie_id
            WHERE c.identifiant = ? AND c.actif = 1""",
        ((identifiant or '').strip().lower(),)).fetchone()
    if l is None or not l['empreinte']:
        # On calcule quand meme une empreinte : sans cela, le temps de reponse
        # trahirait l'existence ou non de l'identifiant.
        _empreinte(mot_de_passe or '', secrets.token_hex(16))
        return None
    if not hmac.compare_digest(_empreinte(mot_de_passe or '', l['sel']), l['empreinte']):
        return None
    return dict(l)


# --------------------------------------------------------------------- comptes

def creer_comptes_manquants(cx, bavard=True):
    """
    Un compte par salarie actif. L'identifiant est le prenom en minuscules, sans
    accent ; le role « gestion » va a Jerome et Aurore, « equipe » aux autres.
    Aucun mot de passe n'est pose : chacun sera defini depuis l'onglet Acces.
    """
    creer_schema(cx)
    maintenant = datetime.datetime.now().isoformat(timespec='seconds')
    existants = {l['salarie_id'] for l in cx.execute('SELECT salarie_id FROM compte')}
    pris = {l['identifiant'] for l in cx.execute('SELECT identifiant FROM compte')}

    crees = []
    for s in cx.execute(
            """SELECT id, prenom, initiale, hors_planning FROM salarie
                WHERE actif = 1 ORDER BY hors_planning DESC, ordre"""):
        if s['id'] in existants:
            continue
        base_id = _sans_accent(s['prenom']).lower().replace(' ', '').replace('-', '')
        identifiant, n = base_id, 2
        while identifiant in pris:
            identifiant, n = '%s%d' % (base_id, n), n + 1
        pris.add(identifiant)
        # Le titulaire et Aurore pilotent l'outil ; tous les autres sont en « equipe ».
        role = 'gestion' if (s['hors_planning'] or s['prenom'] == 'Aurore') else 'equipe'
        cx.execute(
            """INSERT INTO compte (salarie_id, identifiant, role, cree_le)
               VALUES (?, ?, ?, ?)""", (s['id'], identifiant, role, maintenant))
        crees.append((identifiant, role))
    cx.commit()

    if bavard and crees:
        print(u'Comptes cr\xe9\xe9s : %s'
              % ', '.join('%s (%s)' % c for c in crees))
    return crees


def _sans_accent(texte):
    table = {u'\xe9': 'e', u'\xe8': 'e', u'\xea': 'e', u'\xeb': 'e', u'\xe0': 'a',
             u'\xe2': 'a', u'\xee': 'i', u'\xef': 'i', u'\xf4': 'o', u'\xf9': 'u',
             u'\xfb': 'u', u'\xe7': 'c', u'\xc9': 'E', u'\xc8': 'E'}
    return ''.join(table.get(c, c) for c in texte)


def liste(cx):
    creer_schema(cx)
    lignes = cx.execute(
        """SELECT c.id, c.identifiant, c.role, c.actif, c.doit_changer,
                  c.derniere_connexion, c.empreinte IS NOT NULL AS a_mot_de_passe,
                  s.id AS salarie_id, s.prenom, s.groupe, s.couleur
             FROM compte c JOIN salarie s ON s.id = c.salarie_id
            ORDER BY c.role DESC, s.ordre""").fetchall()
    return [{k: l[k] for k in l.keys()} for l in lignes]


def definir_role(cx, compte_id, role):
    if role not in ROLES:
        raise ValueError(u'r\xf4le inconnu : %s' % role)
    cx.execute('UPDATE compte SET role = ? WHERE id = ?', (role, compte_id))
    cx.commit()


def activer(cx, compte_id, actif):
    cx.execute('UPDATE compte SET actif = ? WHERE id = ?',
               (1 if actif else 0, compte_id))
    if not actif:
        cx.execute('DELETE FROM session WHERE compte_id = ?', (compte_id,))
    cx.commit()


DUREE_INVITATION_JOURS = 14


def creer_invitation(cx, compte_id):
    """
    Un lien de premiere connexion, a usage unique. Toute invitation precedente encore
    valable est annulee : un seul lien vivant a la fois.
    """
    creer_schema(cx)
    cx.execute('DELETE FROM invitation WHERE compte_id = ? AND utilisee_le IS NULL',
               (compte_id,))
    jeton = secrets.token_urlsafe(24)
    maintenant = datetime.datetime.now()
    cx.execute(
        """INSERT INTO invitation (jeton, compte_id, creee_le, expire_le)
           VALUES (?, ?, ?, ?)""",
        (jeton, compte_id, maintenant.isoformat(timespec='seconds'),
         (maintenant + datetime.timedelta(days=DUREE_INVITATION_JOURS))
         .isoformat(timespec='seconds')))
    cx.commit()
    return jeton


def compte_par_invitation(cx, jeton):
    """Le compte derriere un lien d'invitation, s'il est encore valable."""
    if not jeton:
        return None
    creer_schema(cx)
    l = cx.execute(
        """SELECT i.jeton, i.expire_le, i.utilisee_le, c.id AS compte_id,
                  c.identifiant, c.role, s.prenom
             FROM invitation i
             JOIN compte c ON c.id = i.compte_id
             JOIN salarie s ON s.id = c.salarie_id
            WHERE i.jeton = ? AND c.actif = 1""", (jeton,)).fetchone()
    if l is None or l['utilisee_le']:
        return None
    if l['expire_le'] < datetime.datetime.now().isoformat(timespec='seconds'):
        return None
    return dict(l)


def activer_par_invitation(cx, jeton, mot_de_passe):
    """
    Le salarie choisit son mot de passe et se retrouve connecte. C'est le seul chemin
    qui pose un mot de passe : personne d'autre ne le connait, jamais.
    """
    bloc = compte_par_invitation(cx, jeton)
    if bloc is None:
        raise ValueError(u'ce lien n’est plus valable — en redemander un')
    definir_mot_de_passe(cx, bloc['compte_id'], mot_de_passe, provisoire=False)
    cx.execute('UPDATE invitation SET utilisee_le = ? WHERE jeton = ?',
               (datetime.datetime.now().isoformat(timespec='seconds'), jeton))
    cx.commit()
    return ouvrir_session(cx, bloc['compte_id'])


def invitations_en_cours(cx):
    """{ compte_id : jeton } des liens encore valables, pour les reafficher."""
    creer_schema(cx)
    maintenant = datetime.datetime.now().isoformat(timespec='seconds')
    return {l['compte_id']: l['jeton'] for l in cx.execute(
        """SELECT compte_id, jeton FROM invitation
            WHERE utilisee_le IS NULL AND expire_le > ?""", (maintenant,))}


def mot_de_passe_provisoire():
    """Un mot de passe lisible, a communiquer de vive voix, a changer a la connexion."""
    mots = ['tilleul', 'menthe', 'lavande', 'romarin', 'sureau', 'aubepine',
            'valeriane', 'melisse', 'camomille', 'gentiane', 'arnica', 'bardane']
    return '%s-%s-%d' % (secrets.choice(mots), secrets.choice(mots),
                         secrets.randbelow(90) + 10)


# -------------------------------------------------------------------- sessions

def ouvrir_session(cx, compte_id):
    jeton = secrets.token_urlsafe(32)
    maintenant = datetime.datetime.now()
    cx.execute(
        """INSERT INTO session (jeton, compte_id, ouverte_le, expire_le)
           VALUES (?, ?, ?, ?)""",
        (jeton, compte_id, maintenant.isoformat(timespec='seconds'),
         (maintenant + datetime.timedelta(days=DUREE_SESSION_JOURS))
         .isoformat(timespec='seconds')))
    cx.execute('UPDATE compte SET derniere_connexion = ? WHERE id = ?',
               (maintenant.isoformat(timespec='seconds'), compte_id))
    cx.commit()
    return jeton


def session(cx, jeton):
    """Le compte derriere un jeton, ou None si le jeton est inconnu ou perime."""
    if not jeton:
        return None
    creer_schema(cx)
    l = cx.execute(
        """SELECT c.id AS compte_id, c.identifiant, c.role, c.doit_changer,
                  c.salarie_id, s.prenom, s.initiale, e.expire_le
             FROM session e
             JOIN compte c ON c.id = e.compte_id
             JOIN salarie s ON s.id = c.salarie_id
            WHERE e.jeton = ? AND c.actif = 1""", (jeton,)).fetchone()
    if l is None:
        return None
    if l['expire_le'] < datetime.datetime.now().isoformat(timespec='seconds'):
        cx.execute('DELETE FROM session WHERE jeton = ?', (jeton,))
        cx.commit()
        return None
    return dict(l)


def fermer_session(cx, jeton):
    cx.execute('DELETE FROM session WHERE jeton = ?', (jeton,))
    cx.commit()


def purger_sessions(cx):
    cx.execute('DELETE FROM session WHERE expire_le < ?',
               (datetime.datetime.now().isoformat(timespec='seconds'),))
    cx.commit()


if __name__ == '__main__':
    import base as bd

    cx = bd.connexion()
    creer_schema(cx)
    creer_comptes_manquants(cx)
    print(u'\nConnexion exig\xe9e : %s' % (u'oui' if connexion_exigee(cx) else u'non'))
    print(u'\n%-14s %-12s %-9s %-8s %s'
          % (u'Identifiant', u'Pr\xe9nom', u'R\xf4le', u'Actif', u'Mot de passe'))
    for c in liste(cx):
        print(u'%-14s %-12s %-9s %-8s %s'
              % (c['identifiant'], c['prenom'], c['role'],
                 u'oui' if c['actif'] else u'non',
                 u'd\xe9fini' if c['a_mot_de_passe'] else u'—'))
    cx.close()
