#! /usr/bin/python3

License = """\
Copyright (c) 2023 Georges Khaznadar <georgesk@debian.org>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or (at
your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import sys, csv, os, re, yaml
from pathlib import Path
from subprocess import call
from datetime import datetime
from copy import copy

from PyQt6.QtWidgets import QApplication, QMainWindow, QFileDialog, QWidget, \
    QTableWidgetItem, QComboBox, QCheckBox, QMessageBox

from PyQt6.QtCore import pyqtSignal, QTimer, Qt
from PyQt6.QtGui import QTextDocument

from PyQt6.Qsci import QsciScintilla, QsciLexerPython

from tableCSV import TableCSV, fn2modele, modele2fn
from common import safeText, safeIdent

from Ui_csv2modele import Ui_MainWindow

CONFIG_FILE = os.path.join(Path.home(), ".local", "share", "csv2modele", "config.yaml")


DEBUT_CLASSE = '''\
class {nom}(models.Model):
    """
    Ici, la documentation de la classe {nom}
    """
'''


class MaFenetre(QMainWindow, Ui_MainWindow):

    toDjango = pyqtSignal()                  # tableaux => code de models.py
    updateConfig = pyqtSignal(str, str, str, str) # mise à jour de self.config

    registeredCombos = {}

    types = {
        "text": "CharField(max_length=25, {vn}{unicity})",
        "int" : "IntegerField({vn}{unicity})",
        "float": "DecimalField(max_digits=6, decimal_places=2, {vn}{unicity})",
        "date": "DateField({vn}{unicity})",
        "foreign": "models.ForeignKey('{foreign}'{related_name}, on_delete=models.CASCADE)"
    }
    
    def __init__(self, parent=None):
        QMainWindow.__init__(self, parent)
        Ui_MainWindow.__init__(self)
        self.setupUi(self)
        self.config=self.readConfig()

        self.filenames = []                   # fichiers CSV ouverts
        self.modeles = []                     # noms de modèles, accentués
        self.file_tableCSV = {}               # dict. fichier -> tableCSV
        self.tableWidgets = []                # liste des tablewidgets
        self.dir_csv = self.config["dir_csv"] # répertoire des fichiers CSV
        self.export_to_model = \
            self.config["export_to_model"]    # répertoire pour models.py
        self.tabWidget.setCurrentIndex(0)
        self.modeles_edit.setText(self.export_to_model)
        self.toDjango.connect(self.csv2django)
        self.updateConfig.connect(self.update_data_CSV)
        self.dir_csv_button.clicked.connect(self.selectDir)
        self.modeles_button.clicked.connect(self.selectExportDir)
        self.add2modeles_button.clicked.connect(self.exportModeles)
        if len(sys.argv) > 1:
            self.selDirDonne(os.path.abspath(sys.argv[1]))
        elif self.csv_files_in_dir():
            self.selDirDonne(os.path.abspath(self.dir_csv))
        return

    def exportModeles(self):
        """
        Exporte les sources Python vers le fichier models.py du répertoire
        self.export_to_model
        """
        path = os.path.join(self.export_to_model, "models.py")
        ok = not os.path.exists(path)
        if not ok:
            reply = QMessageBox.question(
                self, "Fichier models.py", """\
Un fichier models.py existe déjà
dans le répertoire d'exportation.
OK pour réécrire par-dessus ?""", )
            ok = bool(reply)
        if ok:
            with open(path, "w") as outfile:
                outfile.write(self.pythonEditor.text())
        return
    
    def selectExportDir(self):
        """
        Met à jour self.export_to_model
        """
        dir_ = QFileDialog.getExistingDirectory(
            self, "Répertoire des fichiers CSV", self.export_to_model)
        if dir_:
            self.export_to_model = dir_
            self.config["export_to_model"] = dir_
            self.modeles_edit.setText(dir_)
        return
    
    def selectDir(self):
        """
        Met à jour self.dir_csv puis ouvre les fichiers CSV présents dans
        le répertoire dans autant de tabs de self.tabFichiers
        """
        dir_ = QFileDialog.getExistingDirectory(
            self, "Répertoire des fichiers CSV", self.dir_csv)
        if dir_:
            self.dir_csv = dir_
            self.selDirDonne(dir_)
        return

    def selDirDonne(self, dir_):
        """
        Sélectionne un répertoire donné pour les fichiers CSV, puis
        peple les onglets relatifs à chacun des fichiers ; enfin, exporte
        une fois en code Python pour Django.
        
        @param dir_ le répertoire donné
        """
        self.config["dir_csv"] = dir_
        self.dir_csv_edit.setText(dir_)
        # on commence par vider self.tabFichiers
        for i in range(self.tabFichiers.count(), 0, -1):
            w = self.tabFichiers.widget(i-1)
            w.close()
            del (w)
            self.tabFichiers.removeTab(i-1)
        # puis on ajoute un nouveau tab par fichier CSV de self.dir_csv
        self.filenames = [
            os.path.join(self.dir_csv, f.name) for f in self.csv_files_in_dir()]
        self.modeles = [fn2modele(fn) for fn in self.filenames]
        for f in self.filenames:
            # on crée le tableau CSV
            w = TableCSV(self.tabFichiers, self)
            label = re.sub(".csv$", "", os.path.basename(f))
            # on insère ce tableau dans un nouvel onglet
            self.tabFichiers.addTab(w, label)
            self.file_tableCSV[f] = w
            self.tableWidgets.append(w.tableWidget)
            # et là on y déploie le fichier
            self.ouvreFichierDonne(os.path.join(self.dir_csv, f), w)
        # met en place la colonne "Vers..."
        for t in self.tableWidgets:
            for r in range(t.rowCount()):
                if t.cellWidget(
                        r, TableCSV.TYPE_COL).currentText() == "foreign":
                    t.parent().foreignCells.emit(r, True, self.modeles)
        # lance csv2django une première fois
        self.toDjango.emit()
        return

    def csv_files_in_dir(self):
        """
        @return la liste pathlib.PosixPath (CSV) trouvés dans self.dir_csv
        """
        return (f for f in sorted(Path(self.dir_csv).iterdir()) if \
                f.is_file() and f.name.endswith(".csv"))
    
    def readConfig(self):
        """
        Récupère la configuration
        @return : la configuration
        """
        if not os.path.exists(CONFIG_FILE):
            # création d'une configuration vierge
            os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True)
            config = {
                "known_CSV": [],        # chemins absolus de fichiers déjà vus
                "data_CSV": {},         # dict. fichiers -> détails des fichiers
                "dir_csv":  ".",        # répertoire des fichiers CSV
                "export_to_model": '.', # chemin vers le répertoire de models.py
                "modele_dep": {},       # dépendances entre modèles
            }
            with open(CONFIG_FILE, "w") as yamlfile:
                yaml.dump(config, yamlfile)
        return yaml.safe_load(open(CONFIG_FILE))

    def saveConfig(self):
        """
        Enregistre la configuration
        """
        with open(CONFIG_FILE, "w") as yamlfile:
            yaml.dump(self.config, yamlfile)
        return

    
    def closeEvent(self, ev):
        """
        Calcule la dépendance entre modèles, ... puis
        registre la configuration avant de fermer
        """
        self.saveConfig()
        return

    def update_modele_dep(self):
        """
        Calcul des dépendances ente modèles et inscription
        dans la configuration (self.config["modele_dep"])
        """
        for m in self.modeles:
            filename = modele2fn(self.dir_csv, m)
            data = self.config["data_CSV"][filename]
            foreigns = [data[fn].get("foreign") for fn in data if \
                        'type' in data[fn] and data[fn]['type'] == 'foreign']
            self.config["modele_dep"][m] = foreigns
        return
    
    def ouvreFichierDonne(self, filename, w):
        """
        ouvre un fichier de données et le déploie dans un tableau interactif
        @param filename le nom du fichier à ouvrir
        @param w une instance de TableCSV (qui a une propriété .tableWidget)
        """
        filename = os.path.abspath(filename)
        # Il faudra changer ailleurs toutes les dates
        # au format jj/mm/aaaa en format aaaa-mm-jj, le seul qui soit
        # lu directement par Django.
        #call(f"./fixTheDates.sh '{filename}'", shell = True)
        if filename not in self.config["known_CSV"]:
            self.config["known_CSV"].append(filename)
            self.config["data_CSV"][filename] = {} # dict. des données fichier
        # voici les données de configuration pour le fichier courant
        w.populate(filename)
        return

    def update_data_CSV(self, filename, fieldname, key, val):
        """
        Mise à jour de self.config["data_CSV"][filename][fieldname][key],
        avec la valeur val ; fonction de rappel pour le signal
        updateConfig
        """
        if filename not in self.config["data_CSV"]:
            print (f"""Création de self.config["data_CSV"][{filename}]""")
            self.config["data_CSV"][filename] = {}
        if fieldname not in self.config["data_CSV"][filename]:
            print (f"""Création de self.config["data_CSV"][{filename}][{fieldname}]""")
            self.config["data_CSV"][filename][fieldname] = {}
        self.config["data_CSV"][filename][fieldname][key] = val
        return

    def verboseName(self, fieldname):
        """
        @return une mention à la bonne syntaxe pour Django
        """
        return '''verbose_name = """{}""" '''.format(fieldname)

    def notables(self, table):
        """
        Écrit du code pour la fonction __str__ qui décrit un modèle
        selon les cases de notabilité cochées
        @param table le tableau à considérer
        @return une chaîne à formater de type f""
        """
        notableFields = [
            safeIdent(fn) for j, fn in enumerate(table.fieldnames) \
            if table.cellWidget(j, TableCSV.NOTABLE_COL).checkState() \
            == Qt.CheckState.Checked
        ]
        return "{self." + "} {self.".join(notableFields) + "}"
    
    def csv2django(self):
        """
        Produit du code Python, utile pour Django, dans le deuxième volet,
        et par effet de bord, modifie aussi la configuration.
        """
        # recalcule les dépendances
        self.update_modele_dep()
        # mise en ordre pour respecter les dépendances
        max = len(self.filenames) # pas plus de max tours de boucle:
        orderedfiles = []
        n = 0
        not_ordered = copy(self.filenames)
        while not_ordered:
            oset = set((fn2modele(f) for f in orderedfiles))
            for f in not_ordered:
                print("GRRR on teste", fn2modele(f))
                dependencies = set(self.config["modele_dep"][fn2modele(f)])
                if oset.issuperset(dependencies) and f not in orderedfiles:
                    orderedfiles.append(f)
                    i = not_ordered.index(f)
                    del not_ordered[i]
                    print("GRRR on ajoute", fn2modele(f), "n =", n)
            n += 1
            if n > max:
                QMessageBox.critical(self, "ERREUR", "Il y a une dépendance circulaire,\nrevoyez les colonnes Vers...")
                return
            
        # orderedfiles contient une liste de fichiers tels que les
        # dépendances soient satisfaites si on le traite sans cet ordre
        
        result = MODELS_HEAD # écrit l'en-tête du fichier models.py

        # on marque ici les modèles dans l'ordre des dépendances
        result += f"modeles_ordre = {[fn2modele(f) for f in orderedfiles]}\n\n"

        for f in orderedfiles:
            w = self.file_tableCSV[f]
            # Creation de la classe
            nom = safeIdent(re.sub(r"\.csv$", "", os.path.basename(f)))
            result += DEBUT_CLASSE.format(nom = nom)

            # Fonction d'accès au widget dans une case de tableau
            at = w.tableWidget.cellWidget
            # définit les champs comme des propriétés de cette classe
            for r in range(w.tableWidget.rowCount()):
                # écrit la ligne de code pour le champ "key" de la ligne courante
                key = w.tableWidget.verticalHeaderItem(r).text()
                model = self.types[self.registeredCombos[key].currentText()]
                unicityCell = at(r, TableCSV.UNICITY_COL)
                unicity = unicityCell.djangoParam if unicityCell else None
                foreignCell = at(r, TableCSV.VERS_COL)
                foreign = safeIdent(foreignCell.currentText()) if \
                    foreignCell else None
                aliasCell = at(r, TableCSV.ALIAS_COL)
                alias = ", related_name='{}'".format(aliasCell.text()) if \
                    aliasCell and aliasCell.text() else ""
                result += "    {key} = {modelBuilder}\n".format(
                    key = safeIdent(key),
                    modelBuilder = model.format(
                        vn = self.verboseName(key),
                        unicity = unicity,
                        foreign = foreign,
                        related_name = alias,
                    ))
                # met à jour la configuration
                isNotable = at(r, TableCSV.NOTABLE_COL).checkState() == \
                    Qt.CheckState.Checked
                self.updateConfig.emit(
                    f, key, "notable", "true" if isNotable else "false")
                self.updateConfig.emit(
                    f, key, "unicity", str(at(r, TableCSV.UNICITY_COL)))
                self.updateConfig.emit(
                    f, key, "type", self.registeredCombos[key].currentText())
            # crée la fonction __str__ du modèle à partir des champs notables
            result += "\n"
            result += "    def __str__(self):\n"
            result += "        return f'''" + self.notables(w.tableWidget) + "'''\n\n"

            # enregistre le modèle et son fichier CSV associé
            t= w.tableWidget
            fieldtypes = {safeIdent(t.verticalHeaderItem(i).text()): \
                          valType(t, i) \
                          for i in range(t.rowCount())}
            result += f"""\
les_modeles['{nom}'] = {{
    'csvfile': '{f}',
    'fieldtypes': {fieldtypes},
}}

"""
        self.pythonEditor.setText(result)
        self.statusBar().showMessage("Écriture de code Django ...", 2000)
        return

# début du fichier models.py
MODELS_HEAD = """\
# This file was generated automatically at {datetime}
# by the program csv2modele.py
""".format(datetime = datetime.now().strftime("%Y-%m-%d (%H:%M)"))

MODELS_HEAD +="""\
'''
{License}
'''
""".format(License = License)

MODELS_HEAD += """\
from django.db import models
from django.db.models import *
from django.utils import timezone
import sys, time, re, math
from datetime import timedelta, date, datetime
from copy import copy
import random

DATE111 = date.fromisoformat("0001-01-01")

les_modeles = {} # pour enregistrer les modèles

def preprocess(val, typedic):
    '''
    Prépare une valeur pour l'utiliser dans la création d'une instance
    de modèle ; ça permet de traiter le cas des chaînes vides qui ne donnent
    pas facilement des entiers, des dates, etc.
    @param val une valeur
    @param typedic un dictionnaire : nom de type -> valeur supplémentaire
    '''
    typestr, supp = list(typedic.items())[0] # seul élément de typedic
    if typestr == "int" and val =="": return "-999999999"
    if typestr == "float" and val =="": return "-999999999.0"
    if typestr == "date" and val =="": return "0001-01-01"
    if typestr == "foreign":
        try:
            result = eval("{supp}.objects.get(pk={val})".format(supp=supp, val=int(val)))
        except:
            result = "ERREUR: Impossible de trouver une instance de {supp} avec une clé {val}".format(supp=supp, val=int(val))
        return result
    # par défaut, on ne change rien à val
    return val

verbose = False # à modifier pour avoir plus de trace dans le terminal

def log(message):
    '''
    Enregistre et date un message d'erreur
    '''
    with open("fromCSV.log", "a") as errorfile:
        errorfile.write("{now)}: {message}\n".format(
            now=datetime.now(), message=message))
    if verbose:
        print(message)
    return

def fromCSV(model, filedict):
    '''
    Enrichit la base de données à partir d'un fichier CSV
    @param model un modèle (à voir ci-dessous)
    @param filedict dictionnaire:
      'csvfile'    -> le fichier source du modèle
      'fieldtypes' -> dictionnaire champ -> type du champ
    '''
    filename = filedict['csvfile']
    fieldtypes = filedict['fieldtypes']
    print(f"Importation des données de {filename}")
    max= len(open(filename).readlines()) - 1
    import csv
    dr = csv.DictReader(open(filename), delimiter=",")
    idents = {fn: safeIdent(fn) for fn in dr.fieldnames}
    i = 1
    t0 = time.time()
    for dict in dr:
        dict1 = {idents[fn]: preprocess(val, fieldtypes[idents[fn]]) for \
            fn, val in dict.items()}
        if verbose: print(dict1)
        erreur = {ident: val for ident, val in dict1.items() if isinstance(val, str) and val.startswith("ERREUR: Impossible de trouver une instance de")}
        if erreur:
            log(f"{filename} ligne {i+1} : {erreur}")
        else:
            record = model.__class__(**dict1)
            record.save()
        if i%10 == 0:
            t1 = time.time()
            attente = round((max - i) *  (t1-t0) / i)
            sys.stdout.write( f"{i}/{max}      EOT: {attente} s  DUR: {round(t1-t0)} s\\r")
            sys.stdout.flush()
        i += 1
    print()
    print(f"Importation des données de {filename} terminée")
    return

def safeText(text):
    '''
    routine de désaccentuation
    '''
    result=text
    desaccent = {
        r"[àâä]" : "a",
        r"[ÀÂÄ]" : "A",
        r"[éèêë]" : "e",
        r"[ÉÈÊË]" : "E",
        r"[îï]" : "i",
        r"[ÎÏ]" : "I",
        r"[ôö]" : "o",
        r"[ÔÖ]" : "O",
        r"[ùûü]" : "u",
        r"[ÙÛÜ]" : "U",
        r"[ç]" : "c",
        r"[Ç]" : "C",
    }
    for r, l in desaccent.items():
        result = re.sub(r,l, result)
    return result
    
def safeIdent(text):
    '''
    nettoyage des identifiants
    '''
    return re.sub(r'''[. ,;!"'?]''', "_", safeText(text).replace("°", ""))

# Create your models here.

"""

def valType(t, i):
    """
    Renvoie un dictionnaire type -> information supplémentaire
    C'est utile pour ce qui est des clés étrangères, car il faut la classe
    de la clé
    """
    atype = t.cellWidget(i, TableCSV.TYPE_COL).currentText()
    if atype == "foreign":
        supp = safeIdent(t.cellWidget(i, TableCSV.VERS_COL).currentText())
    else:
        supp = None
    return {atype: supp}
    
if __name__ == "__main__":
    app = QApplication(sys.argv)
    w = MaFenetre()
    w.show()
    sys.exit(app.exec())
