# -*- coding: utf-8 -*-
"""
PLANNING TEAM - sauvegarde de la base.

La base tient dans un seul fichier, mais le copier pendant que le serveur ecrit
donnerait une copie corrompue. On passe donc par l'API `backup` de SQLite, qui produit
une copie coherente meme si l'outil est en train de servir des pages.

Une copie par jour, gardee 30 jours, dans le dossier `sauvegardes`. Il est dans le
projet, donc Dropbox l'emporte aussi dans le cloud.

Toute sauvegarde est **verifiee** juste apres avoir ete ecrite : controle d'integrite
SQLite et comptage des lignes. Une sauvegarde qu'on n'a pas relue n'est pas une
sauvegarde.
"""

import datetime
import os
import re
import sqlite3
import sys

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

DOSSIER = os.path.join(PROJET, 'sauvegardes')
GARDE_JOURS = 30
# Le nom porte les secondes, et le prefixe distingue les copies automatiques de celles
# prises juste avant une restauration. Sans les secondes, deux sauvegardes de la meme
# minute portaient le meme nom : la copie de securite ecrasait alors la sauvegarde qu'on
# s'appretait a restaurer, et on restaurait la base abimee. Un test de restauration
# reelle a mis ce piege au jour.
MOTIF = re.compile(r'^(planning|avant-restauration)-(\d{4}-\d{2}-\d{2})-(\d{6})\.db$')

# Les tables dont on verifie le contenu apres copie.
TABLES_SUIVIES = ('salarie', 'creneau_type', 'absence', 'cp_solde', 'cp_ajustement',
                  'heures_ajustement', 'demande', 'compte')


def _dossier():
    if not os.path.isdir(DOSSIER):
        os.makedirs(DOSSIER)
    return DOSSIER


def _compter(chemin):
    """{ table : nombre de lignes } d'une base, en ignorant les tables absentes."""
    cx = sqlite3.connect(chemin)
    try:
        presentes = {l[0] for l in cx.execute(
            "SELECT name FROM sqlite_master WHERE type = 'table'")}
        return {t: cx.execute('SELECT COUNT(*) FROM %s' % t).fetchone()[0]
                for t in TABLES_SUIVIES if t in presentes}
    finally:
        cx.close()


def verifier(chemin):
    """Relit une sauvegarde : integrite SQLite et comptage. Leve si quelque chose cloche."""
    cx = sqlite3.connect(chemin)
    try:
        verdict = cx.execute('PRAGMA integrity_check').fetchone()[0]
    finally:
        cx.close()
    if verdict != 'ok':
        raise IOError(u'sauvegarde corrompue : %s' % verdict)
    return _compter(chemin)


def sauvegarder(source=None, bavard=True, prefixe='planning'):
    """
    Copie la base a chaud, puis relit la copie. Renvoie le chemin et le comptage.

    Le fichier n'ecrase jamais un fichier existant : si le nom est deja pris, on ajoute
    un suffixe. Ecraser une sauvegarde serait le pire des defauts pour un outil de
    sauvegarde.
    """
    import base as bd
    source = source or bd.CHEMIN_DB
    if not os.path.exists(source):
        raise IOError(u'base introuvable : %s' % source)

    maintenant = datetime.datetime.now()
    nom = '%s-%s.db' % (prefixe, maintenant.strftime('%Y-%m-%d-%H%M%S'))
    cible = os.path.join(_dossier(), nom)
    n = 2
    while os.path.exists(cible):
        cible = os.path.join(_dossier(), '%s-%s-%d.db'
                             % (prefixe, maintenant.strftime('%Y-%m-%d-%H%M%S'), n))
        n += 1

    origine = sqlite3.connect(source)
    copie = sqlite3.connect(cible)
    try:
        origine.backup(copie)          # copie coherente, meme serveur en marche
    finally:
        copie.close()
        origine.close()

    comptes = verifier(cible)
    attendus = _compter(source)
    manquants = {t: (attendus[t], comptes.get(t))
                 for t in attendus if comptes.get(t) != attendus[t]}
    if manquants:
        raise IOError(u'la sauvegarde ne correspond pas \xe0 la base : %s' % manquants)

    if bavard:
        print(u'Sauvegarde \xe9crite et v\xe9rifi\xe9e : %s (%s)'
              % (os.path.basename(cible),
                 ', '.join('%s %d' % (t, n) for t, n in sorted(comptes.items()))))
    return {'chemin': cible, 'nom': os.path.basename(cible),
            'quand': maintenant.isoformat(timespec='seconds'),
            'octets': os.path.getsize(cible), 'lignes': comptes}


def liste():
    """Les sauvegardes presentes, de la plus recente a la plus ancienne."""
    if not os.path.isdir(DOSSIER):
        return []
    resultat = []
    for nom in os.listdir(DOSSIER):
        m = MOTIF.match(nom)
        if not m:
            continue
        chemin = os.path.join(DOSSIER, nom)
        heure = m.group(3)
        resultat.append({
            'nom': nom, 'chemin': chemin, 'genre': m.group(1),
            'quand': '%sT%s:%s:%s' % (m.group(2), heure[:2], heure[2:4], heure[4:]),
            'octets': os.path.getsize(chemin),
        })
    resultat.sort(key=lambda s: s['nom'], reverse=True)
    return resultat


def purger(garde_jours=GARDE_JOURS, bavard=True):
    """Supprime les sauvegardes plus vieilles que la duree de conservation."""
    limite = (datetime.date.today() - datetime.timedelta(days=garde_jours)).isoformat()
    supprimes = []
    for s in liste():
        if s['quand'][:10] < limite:
            os.remove(s['chemin'])
            supprimes.append(s['nom'])
    if bavard and supprimes:
        print(u'Sauvegardes purg\xe9es (plus de %d jours) : %d'
              % (garde_jours, len(supprimes)))
    return supprimes


def sauvegarde_du_jour_faite():
    aujourdhui = datetime.date.today().isoformat()
    return any(s['quand'][:10] == aujourdhui and s['genre'] == 'planning'
               for s in liste())


def sauvegarde_quotidienne(bavard=True):
    """Une seule sauvegarde par jour : appelee au demarrage du serveur."""
    if sauvegarde_du_jour_faite():
        return None
    resultat = sauvegarder(bavard=bavard)
    purger(bavard=bavard)
    return resultat


def restaurer(nom, source=None, bavard=True):
    """
    Remet une sauvegarde en place. La base actuelle est d'abord copiee de cote — on ne
    remplace jamais des donnees sans en garder une trace.
    """
    import base as bd
    source = source or bd.CHEMIN_DB
    chemin = os.path.join(DOSSIER, os.path.basename(nom))
    if not os.path.exists(chemin):
        raise IOError(u'sauvegarde introuvable : %s' % nom)
    verifier(chemin)

    # La copie de securite porte un prefixe distinct : elle ne peut donc pas prendre
    # le nom de la sauvegarde qu'on est en train de restaurer.
    avant = None
    if os.path.exists(source):
        avant = sauvegarder(source, bavard=False, prefixe='avant-restauration')['nom']

    origine = sqlite3.connect(chemin)
    cible = sqlite3.connect(source)
    try:
        origine.backup(cible)
    finally:
        cible.close()
        origine.close()

    comptes = _compter(source)
    if bavard:
        print(u'Restauration faite depuis %s. La base pr\xe9c\xe9dente a \xe9t\xe9 '
              u'sauvegard\xe9e sous %s.' % (os.path.basename(chemin), avant))
    return {'restauree': os.path.basename(chemin), 'base_precedente': avant,
            'lignes': comptes}


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

    action = sys.argv[1] if len(sys.argv) > 1 else 'sauvegarder'

    if action == 'liste':
        for s in liste():
            print(u'%-28s %8.1f Ko  %s'
                  % (s['nom'], s['octets'] / 1024.0, s['quand'].replace('T', ' ')))
    elif action == 'restaurer':
        if len(sys.argv) < 3:
            print(u'usage : sauvegarde.py restaurer <nom-du-fichier>')
        else:
            restaurer(sys.argv[2])
    elif action == 'purger':
        purger()
    else:
        sauvegarder()
