# -*- coding: utf-8 -*-
"""
PLANNING TEAM - reprise du classeur `Planning 2026.xlsx`, semaines 36 a 52.

Ce classeur est le planning REEL de l'annee : il porte les retouches semaine par
semaine, la ou le classeur de base ne donne que le cycle theorique. On l'importe pour
que l'outil demarre sur la realite et non sur le modele.

Sa mise en page differe de celle du classeur de base :
  * une feuille par semaine, nommee « 41(1) » — numero ISO, puis la semaine du cycle ;
  * les premieres lignes portent des annotations en texte libre, une par personne
    concernee : « Aurore deplacement Flo (-2h Lu 05 => HS) (-2,5h Ma 06 => HS) » ;
  * la grille commence a la ligne ou la colonne A vaut « 8h30 » ;
  * les jours n'ont PAS tous le meme nombre de colonnes — leurs bornes se lisent sur
    la ligne des noms de jours (Lundi, Mardi...) ;
  * l'initiale de la personne est ecrite directement dans la case.

Ce qu'on en tire :
  * les creneaux reels de chaque personne, poses comme retouches datees
    (`creneau_jour`), sans jamais toucher au cycle de reference ;
  * l'ecart au cycle, qui alimente le compteur de recuperation.
"""

import datetime
import os
import re
import sys
import zipfile
import xml.etree.ElementTree as ET

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

import base as bd            # noqa: E402
import calendrier as cal     # noqa: E402
import reel                  # noqa: E402

NS = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'
CLASSEUR = os.path.join(PROJET, 'docs', 'Planning 2026.xlsx')

JOURS_NOMS = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']

# Les initiales de la grille. « S » seul est ambigu (Sariaka ou Stephanie) : le
# classeur 2026 ecrit bien « Sa » et « St », mais on garde le controle.
INITIALES_CONNUES = {'A', 'N', 'Sa', 'MC', 'V', 'E', 'G', 'St', 'T', 'C', 'P', 'M', 'L'}


# ------------------------------------------------------------------- lecture

def _num_colonne(lettres):
    n = 0
    for ch in lettres:
        n = n * 26 + (ord(ch) - 64)
    return n


def _lettres_colonne(n):
    s = ''
    while n > 0:
        n, r = divmod(n - 1, 26)
        s = chr(65 + r) + s
    return s


def _ouvrir(chemin):
    """Renvoie (cellules par feuille, noms des feuilles) du classeur."""
    with zipfile.ZipFile(chemin) as zf:
        with zf.open('xl/sharedStrings.xml') as f:
            chaines = [''.join(t.text or '' for t in si.iter(NS + 't'))
                       for si in ET.parse(f).getroot()]
        with zf.open('xl/workbook.xml') as f:
            noms = [s.get('name') for s in ET.parse(f).getroot().iter(NS + 'sheet')]
    return chaines, noms


def _cellules(chemin, index_feuille, chaines):
    cell = {}
    with zipfile.ZipFile(chemin) as zf:
        with zf.open('xl/worksheets/sheet%d.xml' % index_feuille) as f:
            for c in ET.parse(f).getroot().iter(NS + 'c'):
                v = c.find(NS + 'v')
                if v is None or v.text is None:
                    continue
                texte = chaines[int(v.text)] if c.get('t') == 's' else v.text
                texte = (texte or '').strip()
                if texte:
                    cell[c.get('r')] = texte
    return cell


def feuilles_semaines(chemin=None):
    """
    { numero ISO : (index de feuille, semaine du cycle annoncee) } pour les semaines
    de 2026 ecrites dans le nouveau format « 36(2) », « 37(1) »...
    """
    chaines, noms = _ouvrir(chemin or CLASSEUR)
    resultat = {}
    for i, nom in enumerate(noms, start=1):
        m = re.fullmatch(r'(\d{1,2})\((\d)\)', (nom or '').strip())
        if m:
            resultat[int(m.group(1))] = (i, int(m.group(2)))
    return resultat, chaines


def lire_semaine(chemin, index_feuille, chaines):
    """
    Extrait une feuille hebdomadaire.

    Renvoie (annotations, { (initiale, jour 1-6) : [minutes travaillees] }).
    """
    cell = _cellules(chemin, index_feuille, chaines)

    # La ligne des jours donne les bornes de chaque bloc.
    ligne_jours, colonnes_jours = None, []
    for ref, valeur in cell.items():
        m = re.fullmatch(r'([A-Z]+)(\d+)', ref)
        if m and valeur in JOURS_NOMS:
            ligne = int(m.group(2))
            if ligne_jours is None or ligne < ligne_jours:
                ligne_jours = ligne
    if ligne_jours is None:
        return [], {}
    for ref, valeur in cell.items():
        m = re.fullmatch(r'([A-Z]+)%d' % ligne_jours, ref)
        if m and valeur in JOURS_NOMS:
            colonnes_jours.append((_num_colonne(m.group(1)), JOURS_NOMS.index(valeur) + 1))
    colonnes_jours.sort()

    # La grille commence a la ligne ou la colonne A vaut « 8h30 ».
    ligne_debut = None
    for ligne in range(ligne_jours, ligne_jours + 6):
        if cell.get('A%d' % ligne) == '8h30':
            ligne_debut = ligne
            break
    if ligne_debut is None:
        return [], {}

    # Les annotations : tout ce qui est ecrit en colonne A au-dessus de la grille,
    # hors titre de la semaine et hors « Semaine 1 / 2 ».
    annotations = []
    for ligne in range(1, ligne_jours + 1):
        texte = cell.get('A%d' % ligne)
        if not texte:
            continue
        if texte.upper().startswith('SEMAINE'):
            continue
        annotations.append(texte)

    # Les bornes de chaque bloc de jour.
    blocs = []
    for i, (colonne, jour) in enumerate(colonnes_jours):
        fin = (colonnes_jours[i + 1][0] - 1) if i + 1 < len(colonnes_jours) else colonne + 12
        blocs.append((jour, colonne, fin))

    presence = {}
    for jour, premiere, derniere in blocs:
        for rang in range(22):
            ligne = ligne_debut + rang
            minute = 510 + rang * 30
            for i in range(premiere, derniere + 1):
                initiale = cell.get('%s%d' % (_lettres_colonne(i), ligne))
                if not initiale:
                    continue
                initiale = initiale.strip()
                if initiale not in INITIALES_CONNUES:
                    continue
                presence.setdefault((initiale, jour), []).append(minute)
    return annotations, presence


def plages(minutes):
    """Regroupe des demi-heures en plages continues (debut, fin)."""
    minutes = sorted(set(minutes))
    if not minutes:
        return []
    resultat, debut, precedente = [], minutes[0], minutes[0]
    for m in minutes[1:]:
        if m != precedente + 30:
            resultat.append((debut, precedente + 30))
            debut = m
        precedente = m
    resultat.append((debut, precedente + 30))
    return resultat


# ------------------------------------------------------------ classement

# Ce que disent les annotations du classeur, et ce qu'il faut en faire. L'ordre compte :
# la premiere expression trouvee l'emporte.
# Les expressions sont ecrites SANS ACCENT : le texte du classeur est normalise avant
# la comparaison.
LECTURE = [
    (u'maladie',   'maladie'),
    (u'arret',     'maladie'),
    (u'formation', 'formation'),
    (u'du ortho',  'formation'),
    (u'ecole',     'formation'),
    (u'revision',  'revision'),
    (u'paternit',  'maternite'),
    (u'maternit',  'maternite'),
    (u'ajpp',      'ajpp'),
    (u'=> cp',     'cp'),
    (u'conge',     'cp'),
]

# Ces mentions signalent un ECHANGE D'HEURES, pas une absence : la personne ne
# travaille pas ce jour-la mais rattrape ailleurs. Elles alimentent la recuperation.
ECHANGE = (u'=> hs', u'recup', u'ech ')


JOURS_COURTS = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']


def clause_du_jour(annotation, date):
    """
    La partie de l'annotation qui parle de CETTE date.

    Une annotation couvre toute la semaine et peut melanger plusieurs choses :
    « Marie-Cecile (-2j => CP) (-8,5h Sa 05/09 => HS ECH Sariaka Ma 15/09) ».
    Lue d'un bloc, le « CP » l'emportait et le samedi devenait un conge au lieu d'un
    echange. On isole donc la clause qui cite le jour concerne.
    """
    if not annotation:
        return None
    clauses = re.findall(r'\(([^)]*)\)', annotation)
    reperes = ['%02d/%02d' % (date.day, date.month),
               '%s %02d' % (JOURS_COURTS[date.weekday()], date.day),
               '%s %d' % (JOURS_COURTS[date.weekday()], date.day)]
    for c in clauses:
        bas = _sans_accent(c)
        if any(_sans_accent(r) in bas for r in reperes):
            return c
    return None


def classer(annotation, initiale, jour_ajpp, date=None):
    """
    Que faire d'une journee entierement vide ?

    Renvoie un motif d'absence, 'recup' si c'est un echange d'heures, ou None si le
    classeur ne dit rien d'exploitable — auquel cas on prefere le signaler.
    """
    # La clause qui cite la date prime sur le reste de l'annotation.
    precise = clause_du_jour(annotation, date) if date else None
    texte = _sans_accent(precise if precise else annotation)

    # Elodie sans annotation, sur un de ses jours d'AJPP : c'est son AJPP.
    if initiale == 'E' and not texte and jour_ajpp:
        return 'ajpp'

    for expression, motif in LECTURE:
        if expression in texte:
            return motif
    for expression in ECHANGE:
        if expression in texte:
            return 'recup'
    return None


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'}
    return ''.join(table.get(c, c) for c in (texte or '').lower())


def _annotation_de(annotations, prenom):
    """
    L'annotation qui concerne cette personne, si le classeur en porte une.

    La comparaison ignore les accents : la base ecrit « Stephanie », le classeur
    « Stéphanie ». Sans cette precaution, son annotation passait inapercue et sa
    journee de formation etait comptee comme une dette d'heures.
    """
    debut = _sans_accent(prenom)[:5]
    for a in annotations:
        if _sans_accent(a).startswith(debut):
            return a
    return None


# --------------------------------------------------------------- analyse

def apercu(cx, numeros=None, chemin=None):
    """
    Ce que l'import ferait, sans rien ecrire.

    Trois traitements differents, decides au cadrage :
      * les ETUDIANTES (Manon, Lilou) n'ont pas de cycle : leurs heures sont reprises
        telles quelles, sans notion d'ecart ;
      * ELODIE : les journees ou le cycle prevoit des heures et ou le classeur n'en
        montre aucune sont ses journees d'AJPP — elles deviennent des absences AJPP,
        et n'alimentent pas la recuperation ;
      * TOUS LES AUTRES : les creneaux du classeur deviennent des retouches datees, et
        l'ecart au cycle alimente le compteur de recuperation.

    Les jours feries sont ignores : l'officine est fermee, il n'y a rien a reprendre.
    """
    chemin = chemin or CLASSEUR
    feuilles, chaines = feuilles_semaines(chemin)
    numeros = sorted(numeros or [n for n in feuilles if 36 <= n <= 52])

    gens = {s['initiale']: s for s in bd.salaries(cx)}
    index_cycle = {1: reel._index_creneaux(cx, 1), 2: reel._index_creneaux(cx, 2)}
    feries = cal.tous_les_feries([2025, 2026, 2027])

    resultat = []
    for numero in numeros:
        if numero not in feuilles:
            continue
        index, _ = feuilles[numero]
        annotations, presence = lire_semaine(chemin, index, chaines)
        lundi = cal.lundi_de(2026, numero)
        cycle = cal.semaine_cycle(2026, numero)

        lignes = []
        for initiale, s in gens.items():
            annotation = _annotation_de(annotations, s['prenom'])
            journees, ecart_total, absences, incertains = [], 0.0, [], []

            for jour in range(1, 7):
                date = lundi + datetime.timedelta(jour - 1)
                if date in feries:
                    continue
                minutes = presence.get((initiale, jour), [])
                prevu_creneaux = index_cycle[cycle].get((s['id'], jour), [])
                prevu = sum(c['fin_min'] - c['debut_min'] for c in prevu_creneaux) / 60.0
                # `set` est indispensable : quand quelqu'un remplace un collegue, le
                # classeur l'inscrit dans DEUX colonnes du meme jour. Sans
                # dedoublonnage, Marie-Cecile ressortait a 14 h dans une journee qui
                # n'en compte que 9,5 au maximum.
                fait = len(set(minutes)) * 0.5

                if abs(fait - prevu) < 0.01:
                    continue                      # conforme au cycle : rien a ecrire

                # Journee entierement vide : c'est une absence ou un echange d'heures,
                # jamais une simple retouche d'horaire.
                if fait == 0 and prevu > 0:
                    deja = cx.execute(
                        """SELECT motif FROM absence WHERE salarie_id = ?
                            AND date_debut <= ? AND date_fin >= ?""",
                        (s['id'], date.isoformat(), date.isoformat())).fetchone()
                    if deja:
                        continue                  # deja connue : on ne double pas

                    jour_ajpp = any(c['ajpp'] for c in prevu_creneaux)
                    motif = classer(annotation, initiale, jour_ajpp, date)
                    if motif is None:
                        incertains.append({'date': date.isoformat(),
                                           'heures': round(prevu, 2),
                                           'annotation': annotation})
                        continue
                    if motif == 'recup':
                        # Echange d'heures : la journee est vide, l'ecart compte.
                        journees.append({'date': date.isoformat(), 'jour': jour,
                                         'plages': [], 'fait': 0.0,
                                         'prevu': round(prevu, 2),
                                         'ecart': round(-prevu, 2)})
                        if not s['paye_a_l_heure']:
                            ecart_total -= prevu
                    else:
                        absences.append({'date': date.isoformat(), 'motif': motif,
                                         'heures': round(prevu, 2),
                                         'annotation': clause_du_jour(annotation, date)
                                                        or annotation})
                    continue

                journees.append({'date': date.isoformat(), 'jour': jour,
                                 'plages': plages(minutes),
                                 'fait': round(fait, 2), 'prevu': round(prevu, 2),
                                 'ecart': round(fait - prevu, 2)})
                if not s['paye_a_l_heure']:
                    ecart_total += fait - prevu

            if journees or absences or incertains:
                lignes.append({
                    'salarie_id': s['id'], 'initiale': initiale, 'prenom': s['prenom'],
                    'couleur': s['couleur'], 'etudiante': bool(s['paye_a_l_heure']),
                    'annotation': annotation,
                    'journees': journees, 'absences': absences,
                    'incertains': incertains, 'ecart': round(ecart_total, 2),
                })

        resultat.append({'numero': numero, 'annee': 2026, 'cycle': cycle,
                         'lundi': lundi.isoformat(), 'annotations': annotations,
                         'lignes': lignes})
    return resultat


def appliquer(cx, vue, bavard=True):
    """Ecrit dans la base ce que l'apercu a montre. Rien de plus."""
    import conges as cg

    reel.creer_schema(cx)
    maintenant = datetime.datetime.now().isoformat(timespec='seconds')
    journees, ajpp_poses = 0, 0

    for semaine in vue:
        for ligne in semaine['lignes']:
            sid = ligne['salarie_id']

            for j in ligne['journees']:
                # On fige la journee, puis on la remplace par ce que dit le classeur.
                cx.execute('DELETE FROM creneau_jour WHERE salarie_id = ? AND date = ?',
                           (sid, j['date']))
                cx.execute(
                    """INSERT INTO jour_modifie (salarie_id, date, modifie_le)
                       VALUES (?, ?, ?)
                       ON CONFLICT(salarie_id, date) DO UPDATE
                          SET modifie_le = excluded.modifie_le""",
                    (sid, j['date'], maintenant))
                for debut, fin in j['plages']:
                    for d, f, hors in reel._decouper_ouverture(j['jour'], debut, fin):
                        cx.execute(
                            """INSERT INTO creneau_jour
                                 (salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp)
                               VALUES (?, ?, ?, ?, ?, 0)""",
                            (sid, j['date'], d, f, hors))
                # Ce que dit le classeur devient l'etat de reference de la journee :
                # c'est la que le bouton ↺ ramenera, pas au cycle theorique.
                reel.figer_origine(cx, sid, j['date'], forcer=True)
                journees += 1

            for a in ligne.get('absences', []):
                deja = cx.execute(
                    """SELECT 1 FROM absence WHERE salarie_id = ?
                        AND date_debut <= ? AND date_fin >= ?""",
                    (sid, a['date'], a['date'])).fetchone()
                if deja:
                    continue
                decompte = 'cp' if a['motif'] == 'cp' else 'heures'
                cg.poser_absence(
                    cx, sid, a['date'], a['date'], a['motif'],
                    decompte=decompte,
                    heures=None if decompte == 'cp' else a['heures'],
                    jours=1.0 if decompte == 'cp' else None,
                    libelle=(a.get('annotation') or u'repris du planning 2026')[:120])
                ajpp_poses += 1
    cx.commit()

    if bavard:
        print(u'Import appliqu\xe9 : %d journ\xe9e(s) retouch\xe9e(s), %d journ\xe9e(s) d\'AJPP.'
              % (journees, ajpp_poses))
    return {'journees': journees, 'ajpp': ajpp_poses}


if __name__ == '__main__':
    semaine = int(sys.argv[1]) if len(sys.argv) > 1 else 41

    feuilles, chaines = feuilles_semaines()
    if semaine not in feuilles:
        print(u'Semaine %d absente du classeur (disponibles : %s)'
              % (semaine, ', '.join(str(n) for n in sorted(feuilles))))
        sys.exit(1)

    index, cycle_annonce = feuilles[semaine]
    annotations, presence = lire_semaine(CLASSEUR, index, chaines)
    lundi = cal.lundi_de(2026, semaine)

    print(u'SEMAINE %d — du %s, cycle annonc\xe9 S%d, calcul\xe9 S%d'
          % (semaine, lundi.strftime('%d/%m/%Y'), cycle_annonce,
             cal.semaine_cycle(2026, semaine)))
    for a in annotations:
        print(u'   note : %s' % a)

    cx = bd.connexion()
    gens = {s['initiale']: s for s in bd.salaries(cx)}
    index_cycle = {1: reel._index_creneaux(cx, 1), 2: reel._index_creneaux(cx, 2)}
    cycle = cal.semaine_cycle(2026, semaine)

    print(u'\n%-13s %8s %8s %8s   %s'
          % (u'Salari\xe9', u'classeur', u'cycle', u'\xe9cart', u'd\xe9tail du classeur'))
    total_ecart = 0.0
    for initiale, s in gens.items():
        reel_h = prevu_h = 0.0
        detail = []
        for jour in range(1, 7):
            minutes = presence.get((initiale, jour), [])
            reel_h += len(minutes) * 0.5
            prevu = sum(c['fin_min'] - c['debut_min']
                        for c in index_cycle[cycle].get((s['id'], jour), [])) / 60.0
            prevu_h += prevu
            if minutes and len(minutes) * 0.5 != prevu:
                detail.append('%s %s' % (
                    cal.JOURS[jour - 1][:3],
                    ' '.join('%dh%02d-%dh%02d' % (d // 60, d % 60, f // 60, f % 60)
                             for d, f in plages(minutes))))
        ecart = reel_h - prevu_h
        total_ecart += ecart
        if abs(ecart) >= 0.01 or detail:
            print(u'%-13s %8.1f %8.1f %+8.1f   %s'
                  % (s['prenom'], reel_h, prevu_h, ecart, ' | '.join(detail)))
    print(u'\n\xc9cart total de la semaine : %+.1f h' % total_ecart)
    cx.close()
