# -*- coding: utf-8 -*-
"""
PLANNING TEAM - le planning reel, date.

Assemble les trois briques : le cycle de reference (base.py), le calendrier et ses
jours feries (calendrier.py), les absences (conges.py).

    planning effectif d'une semaine  =  cycle  -  absences  -  jours feries

Une absence porte toujours sur des JOURNEES entieres. Ce qui change, c'est le
decompte : soit des jours de conges payes, soit des heures a deduire — et dans ce
second cas, les heures se calculent toutes seules a partir de ce que la personne
aurait travaille. C'est exactement la question que pose le cabinet chaque mois :
« combien d'heures a deduire pour l'arret du 26/06 au 05/07 ».
"""

import datetime
import os
import sys

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

import base as bd            # noqa: E402
import calendrier as cal     # noqa: E402
import conges as cg          # noqa: E402


SCHEMA = """
-- Une journee retouchee a la main sur une VRAIE date. Quand une ligne existe ici, elle
-- remplace entierement ce que dit le cycle pour cette personne ce jour-la.
CREATE TABLE IF NOT EXISTS creneau_jour (
    id            INTEGER PRIMARY KEY,
    salarie_id    INTEGER NOT NULL REFERENCES salarie(id) ON DELETE CASCADE,
    date          TEXT NOT NULL,
    debut_min     INTEGER NOT NULL,
    fin_min       INTEGER NOT NULL,
    hors_comptoir INTEGER NOT NULL DEFAULT 0,
    ajpp          INTEGER NOT NULL DEFAULT 0
);

-- Le marqueur du jour retouche. Il est indispensable : sans lui, une journee videe a
-- la main serait indiscernable d'une journee jamais touchee, et le cycle reprendrait
-- le dessus.
CREATE TABLE IF NOT EXISTS jour_modifie (
    salarie_id INTEGER NOT NULL REFERENCES salarie(id) ON DELETE CASCADE,
    date       TEXT NOT NULL,
    modifie_le TEXT NOT NULL,
    PRIMARY KEY (salarie_id, date)
);

-- L'etat de reference d'une journee : ce qu'elle valait AVANT que Jerome ne la
-- retouche. Pose par l'import du planning 2026, et par la premiere retouche d'une
-- journee qui n'en avait pas encore. C'est la que le bouton ↺ va chercher.
CREATE TABLE IF NOT EXISTS creneau_jour_origine (
    id            INTEGER PRIMARY KEY,
    salarie_id    INTEGER NOT NULL REFERENCES salarie(id) ON DELETE CASCADE,
    date          TEXT NOT NULL,
    debut_min     INTEGER NOT NULL,
    fin_min       INTEGER NOT NULL,
    hors_comptoir INTEGER NOT NULL DEFAULT 0,
    ajpp          INTEGER NOT NULL DEFAULT 0
);

-- Le marqueur : une journee peut avoir un etat de reference VIDE (personne ne
-- travaillait), qu'il faut distinguer de « pas d'etat de reference du tout ».
CREATE TABLE IF NOT EXISTS jour_origine (
    salarie_id INTEGER NOT NULL REFERENCES salarie(id) ON DELETE CASCADE,
    date       TEXT NOT NULL,
    pose_le    TEXT NOT NULL,
    PRIMARY KEY (salarie_id, date)
);

CREATE INDEX IF NOT EXISTS idx_creneau_jour ON creneau_jour (date, salarie_id);
CREATE INDEX IF NOT EXISTS idx_jour_origine ON creneau_jour_origine (date, salarie_id);
"""


def creer_schema(cx):
    """
    Cree les tables si elles manquent — et surtout, ne fait RIEN sinon.

    `executescript` valide implicitement la transaction en cours. Appele au milieu
    d'une simulation (voir demandes.impact), il rendait definitive l'absence qu'on
    s'appretait a annuler. D'ou le controle prealable, qui ne co\xfbte qu'une lecture.
    """
    if cg.tables_completes(cx, ('creneau_jour', 'jour_modifie',
                                'creneau_jour_origine', 'jour_origine')):
        return
    cx.executescript(SCHEMA)
    cx.commit()


def _index_creneaux(cx, cycle):
    """{ (salarie_id, jour) : [creneaux] } pour une semaine du cycle."""
    index = {}
    for c in bd.creneaux(cx, cycle):
        index.setdefault((c['salarie_id'], c['jour']), []).append(c)
    return index


def _index_exceptions(cx, lundi, samedi):
    """
    Les journees retouchees a la main sur la periode.
    Renvoie ({ (salarie_id, date) : [creneaux] }, { (salarie_id, date) })
    """
    creer_schema(cx)
    creneaux_jour = {}
    for l in cx.execute(
            """SELECT salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp
                 FROM creneau_jour WHERE date BETWEEN ? AND ?
                ORDER BY debut_min""", (lundi.isoformat(), samedi.isoformat())):
        creneaux_jour.setdefault((l['salarie_id'], l['date']), []).append(dict(l))

    modifies = {(l['salarie_id'], l['date']): l['modifie_le'] for l in cx.execute(
        'SELECT salarie_id, date, modifie_le FROM jour_modifie WHERE date BETWEEN ? AND ?',
        (lundi.isoformat(), samedi.isoformat()))}
    return creneaux_jour, modifies


def creneaux_effectifs(cx, salarie_id, jour, index_cycle=None,
                       exceptions=None, modifies=None):
    """
    Ce que la personne travaille reellement ce jour-la : la journee retouchee si elle
    existe, le cycle sinon.
    """
    cle = (salarie_id, jour.isoformat())
    if modifies is None:
        creer_schema(cx)
        modifie = cx.execute(
            'SELECT 1 FROM jour_modifie WHERE salarie_id = ? AND date = ?',
            (salarie_id, jour.isoformat())).fetchone() is not None
    else:
        modifie = cle in modifies

    if modifie:
        if exceptions is not None:
            return exceptions.get(cle, [])
        return [dict(l) for l in cx.execute(
            """SELECT id, salarie_id, debut_min, fin_min, hors_comptoir, ajpp
                 FROM creneau_jour WHERE salarie_id = ? AND date = ?
                ORDER BY debut_min""", (salarie_id, jour.isoformat()))]

    annee_iso, numero = cal.semaine_de(jour)
    cycle = cal.semaine_cycle(annee_iso, numero)
    index = index_cycle[cycle] if index_cycle else _index_creneaux(cx, cycle)
    return index.get((salarie_id, jour.weekday() + 1), [])


def _materialiser(cx, salarie_id, jour):
    """Fige la journee du cycle dans creneau_jour, pour pouvoir la retoucher."""
    creer_schema(cx)
    if cx.execute('SELECT 1 FROM jour_modifie WHERE salarie_id = ? AND date = ?',
                  (salarie_id, jour.isoformat())).fetchone():
        return
    for c in creneaux_effectifs(cx, salarie_id, jour):
        cx.execute(
            """INSERT INTO creneau_jour
                 (salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (salarie_id, jour.isoformat(), c['debut_min'], c['fin_min'],
             c['hors_comptoir'], c['ajpp']))
    cx.execute(
        'INSERT INTO jour_modifie (salarie_id, date, modifie_le) VALUES (?, ?, ?)',
        (salarie_id, jour.isoformat(),
         datetime.datetime.now().isoformat(timespec='seconds')))


def basculer_creneau_reel(cx, salarie_id, date_iso, minute):
    """
    Ajoute ou retire une demi-heure sur une VRAIE date, sans toucher au cycle.
    La premiere retouche fige la journee ; les suivantes la modifient.
    """
    jour = datetime.date.fromisoformat(date_iso)
    # Avant la premiere retouche, on garde une photo de la journee : c'est a elle que
    # le bouton ↺ ramenera.
    figer_origine(cx, salarie_id, date_iso)
    _materialiser(cx, salarie_id, jour)
    # On horodate a chaque retouche, pas seulement a la premiere : c'est ce que
    # l'equipe lit pour savoir ce qui a bouge depuis sa derniere visite.
    cx.execute('UPDATE jour_modifie SET modifie_le = ? WHERE salarie_id = ? AND date = ?',
               (datetime.datetime.now().isoformat(timespec='seconds'), salarie_id, date_iso))

    ligne = cx.execute(
        """SELECT id, debut_min, fin_min, hors_comptoir, ajpp FROM creneau_jour
            WHERE salarie_id = ? AND date = ? AND debut_min <= ? AND fin_min > ?""",
        (salarie_id, date_iso, minute, minute)).fetchone()

    if ligne:
        cx.execute('DELETE FROM creneau_jour WHERE id = ?', (ligne['id'],))
        if ligne['debut_min'] < minute:
            _inserer_jour(cx, salarie_id, date_iso, ligne['debut_min'], minute,
                          jour, ligne['ajpp'])
        if minute + 30 < ligne['fin_min']:
            _inserer_jour(cx, salarie_id, date_iso, minute + 30, ligne['fin_min'],
                          jour, ligne['ajpp'])
        cx.commit()
        return False

    reste = cx.execute(
        """SELECT MIN(ajpp) AS tous FROM creneau_jour
            WHERE salarie_id = ? AND date = ?""", (salarie_id, date_iso)).fetchone()['tous']
    _inserer_jour(cx, salarie_id, date_iso, minute, minute + 30, jour,
                  1 if reste else 0)
    _fusionner_jour(cx, salarie_id, date_iso)
    cx.commit()
    return True


def _inserer_jour(cx, salarie_id, date_iso, debut, fin, jour, ajpp):
    numero_jour = jour.weekday() + 1
    for d, f, hors in _decouper_ouverture(numero_jour, debut, fin):
        cx.execute(
            """INSERT INTO creneau_jour
                 (salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (salarie_id, date_iso, d, f, hors, ajpp))


def _decouper_ouverture(numero_jour, debut, fin):
    """Separe la part ouverte de la part hors ouverture (temps administratif)."""
    morceaux, reste = [], [(debut, fin)]
    for od, of in bd.OUVERTURE[numero_jour]:
        suite = []
        for rd, rf in reste:
            id_, if_ = max(rd, od), min(rf, of)
            if id_ < if_:
                morceaux.append((id_, if_, 0))
                if rd < id_:
                    suite.append((rd, id_))
                if if_ < rf:
                    suite.append((if_, rf))
            else:
                suite.append((rd, rf))
        reste = suite
    morceaux.extend((rd, rf, 1) for rd, rf in reste)
    return sorted(morceaux)


def _fusionner_jour(cx, salarie_id, date_iso):
    lignes = cx.execute(
        """SELECT id, debut_min, fin_min, hors_comptoir, ajpp FROM creneau_jour
            WHERE salarie_id = ? AND date = ? ORDER BY debut_min""",
        (salarie_id, date_iso)).fetchall()
    precedente = None
    for l in lignes:
        if (precedente and precedente['fin_min'] == l['debut_min']
                and precedente['hors_comptoir'] == l['hors_comptoir']
                and precedente['ajpp'] == l['ajpp']):
            cx.execute('UPDATE creneau_jour SET fin_min = ? WHERE id = ?',
                       (l['fin_min'], precedente['id']))
            cx.execute('DELETE FROM creneau_jour WHERE id = ?', (l['id'],))
            precedente = {'id': precedente['id'], 'debut_min': precedente['debut_min'],
                          'fin_min': l['fin_min'], 'hors_comptoir': l['hors_comptoir'],
                          'ajpp': l['ajpp']}
        else:
            precedente = l


def figer_origine(cx, salarie_id, date_iso, forcer=False):
    """
    Enregistre l'etat de reference d'une journee : ce qu'elle vaut a cet instant.
    Appele par l'import du planning 2026 (forcer=True) et avant la premiere retouche
    manuelle d'une journee qui n'en a pas encore.
    """
    creer_schema(cx)
    deja = cx.execute('SELECT 1 FROM jour_origine WHERE salarie_id = ? AND date = ?',
                      (salarie_id, date_iso)).fetchone()
    if deja and not forcer:
        return False

    cx.execute('DELETE FROM creneau_jour_origine WHERE salarie_id = ? AND date = ?',
               (salarie_id, date_iso))
    for c in creneaux_effectifs(cx, salarie_id, datetime.date.fromisoformat(date_iso)):
        cx.execute(
            """INSERT INTO creneau_jour_origine
                 (salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (salarie_id, date_iso, c['debut_min'], c['fin_min'],
             c['hors_comptoir'], c['ajpp']))
    cx.execute(
        """INSERT INTO jour_origine (salarie_id, date, pose_le) VALUES (?, ?, ?)
           ON CONFLICT(salarie_id, date) DO UPDATE SET pose_le = excluded.pose_le""",
        (salarie_id, date_iso, datetime.datetime.now().isoformat(timespec='seconds')))
    return True


def rendre_au_cycle(cx, salarie_id, date_iso):
    """
    Le bouton ↺ : la journee revient a son etat AVANT retouche.

    S'il existe un etat de reference — pose par l'import du planning 2026, ou juste
    avant la premiere retouche — on le remet. Sinon la journee redevient simplement
    celle du cycle. Autrement dit : on annule ce que Jerome a change, pas ce que
    disait le planning d'origine.
    """
    creer_schema(cx)
    origine = cx.execute(
        'SELECT 1 FROM jour_origine WHERE salarie_id = ? AND date = ?',
        (salarie_id, date_iso)).fetchone()

    avant = cx.execute(
        """SELECT debut_min, fin_min FROM creneau_jour
            WHERE salarie_id = ? AND date = ? ORDER BY debut_min""",
        (salarie_id, date_iso)).fetchall()

    cx.execute('DELETE FROM creneau_jour WHERE salarie_id = ? AND date = ?',
               (salarie_id, date_iso))

    if origine:
        lignes = cx.execute(
            """SELECT debut_min, fin_min, hors_comptoir, ajpp FROM creneau_jour_origine
                WHERE salarie_id = ? AND date = ? ORDER BY debut_min""",
            (salarie_id, date_iso)).fetchall()
        for l in lignes:
            cx.execute(
                """INSERT INTO creneau_jour
                     (salarie_id, date, debut_min, fin_min, hors_comptoir, ajpp)
                   VALUES (?, ?, ?, ?, ?, ?)""",
                (salarie_id, date_iso, l['debut_min'], l['fin_min'],
                 l['hors_comptoir'], l['ajpp']))
        apres = [(l['debut_min'], l['fin_min']) for l in lignes]
    else:
        cx.execute('DELETE FROM jour_modifie WHERE salarie_id = ? AND date = ?',
                   (salarie_id, date_iso))
        apres = None

    cx.commit()
    return [tuple(l) for l in avant] != (apres if apres is not None else [])


def _absences_par_jour(cx, lundi, samedi):
    """{ (salarie_id, date iso) : absence } sur une periode."""
    index = {}
    for a in cg.absences(cx, lundi.isoformat(), samedi.isoformat()):
        if a['statut'] == 'refusee':
            continue
        # Une absence importee dont le libelle n'a pas pu etre date couvre toute la
        # semaine ISO : elle sert au solde de conges, jamais a peindre le planning.
        # Sans cette exception, la personne paraitrait absente du lundi au samedi.
        if a['origine'] == 'import':
            continue
        debut = datetime.date.fromisoformat(a['date_debut'])
        fin = datetime.date.fromisoformat(a['date_fin'])
        jour = max(debut, lundi)
        while jour <= min(fin, samedi):
            index[(a['salarie_id'], jour.isoformat())] = a
            jour += datetime.timedelta(1)
    return index


def heures_prevues(cx, salarie_id, debut, fin):
    """
    Les heures que la personne aurait travaillees entre deux dates, d'apres le cycle,
    jours feries deduits. C'est le chiffre que le cabinet reclame pour les arrets.
    """
    feries = cal.tous_les_feries(range(debut.year - 1, fin.year + 2))
    index = {1: _index_creneaux(cx, 1), 2: _index_creneaux(cx, 2)}
    exceptions, modifies = _index_exceptions(cx, debut, fin)
    total, detail = 0.0, []

    jour = debut
    while jour <= fin:
        if jour.weekday() < 6 and jour not in feries:
            minutes = sum(c['fin_min'] - c['debut_min']
                          for c in creneaux_effectifs(cx, salarie_id, jour, index,
                                                      exceptions, modifies))
            if minutes:
                total += minutes / 60.0
                detail.append({'date': jour.isoformat(), 'heures': round(minutes / 60.0, 2)})
        jour += datetime.timedelta(1)
    return {'heures': round(total, 2), 'detail': detail}


def travaille_le(cx, salarie_id, jour):
    """La personne a-t-elle des creneaux ce jour-la, d'apres le cycle ou une retouche ?"""
    if jour.weekday() > 5:
        return False
    return bool(creneaux_effectifs(cx, salarie_id, jour))


def compter_cp(cx, salarie_id, debut, fin):
    """
    Le nombre de jours de conges payes d'une periode : les jours ouvrables, lundi ->
    samedi, jours feries exclus. Un jour ou la personne ne travaille pas compte quand
    meme — c'est le decompte legal en jours ouvrables.

    Une variante avait ete envisagee : ne pas compter les jours non travailles situes
    en BORDURE de periode. Elle a ete confrontee aux 43 periodes du classeur des
    conges. Verdict sans appel :

        decompte strict en jours ouvrables ....... 38 concordances sur 43  (88 %)
        avec la regle des bordures ............... 20 concordances sur 43  (47 %)

    La variante corrigeait quatre cas et en cassait vingt-trois. Elle est ecartee.
    Les cinq ecarts qui subsistent viennent vraisemblablement de libelles dont la date
    de debut reelle differe de celle qu'on en a lue.

    Renvoie (nombre de jours, liste des dates retenues).
    """
    jours = cal.jours_ouvrables(debut, fin)
    return len(jours), jours


def basculer_jour(cx, salarie_id, date_iso, motif):
    """
    Marque ou demarque UNE journee, directement depuis la grille des semaines reelles.

    C'est le pendant date du bouton AJPP du cycle : un clic sur « CP » ou « AJPP »
    pose l'absence de la journee, elle se hachure aussitot sur le planning et
    apparait dans l'onglet Absences. Un second clic la retire.

    Les CP se decomptent en jours ouvrables, le reste en heures - et les heures sont
    calculees a partir de ce que la personne aurait travaille ce jour-la.
    """
    jour = datetime.date.fromisoformat(date_iso)

    existante = cx.execute(
        """SELECT id, motif, date_debut, date_fin FROM absence
            WHERE salarie_id = ? AND date_debut <= ? AND date_fin >= ?""",
        (salarie_id, date_iso, date_iso)).fetchone()

    if existante:
        if existante['date_debut'] != date_iso or existante['date_fin'] != date_iso:
            raise ValueError(
                u'cette journ\xe9e fait partie d’une absence du %s au %s : '
                u'\xe0 modifier depuis l’onglet Absences'
                % (existante['date_debut'], existante['date_fin']))
        cg.supprimer_absence(cx, existante['id'])
        if existante['motif'] == motif:
            return {'pose': False, 'motif': None}

    reference = {m[0]: m for m in cg.MOTIFS}
    if motif not in reference:
        raise ValueError(u'motif inconnu : %s' % motif)
    decompte = 'cp' if motif == 'cp' else 'heures'

    heures, jours = None, None
    if decompte == 'heures':
        heures = heures_prevues(cx, salarie_id, jour, jour)['heures']
    else:
        jours = compter_cp(cx, salarie_id, jour, jour)[0]

    absence_id = cg.poser_absence(cx, salarie_id, date_iso, date_iso, motif,
                                  decompte=decompte, heures=heures, jours=jours)
    return {'pose': True, 'motif': motif, 'id': absence_id, 'heures': heures}


def compteur_recuperation(cx, debut, fin):
    """
    L'ecart entre ce qui a ete reellement planifie et ce que prevoit le cycle, sur une
    periode. Une heure retiree a la main creuse le compteur, une heure ajoutee le
    remplit. Positif = heures a recuperer, negatif = heures dues.

    Les absences ne comptent pas ici : un conge ou un arret se decompte ailleurs, en
    jours de CP ou en heures a deduire pour le cabinet. Seules les retouches du
    planning alimentent la recuperation.
    """
    creer_schema(cx)
    index = {1: _index_creneaux(cx, 1), 2: _index_creneaux(cx, 2)}
    exceptions, modifies = _index_exceptions(cx, debut, fin)
    feries = cal.tous_les_feries(range(debut.year - 1, fin.year + 2))

    # Les etudiantes n'ont pas de cycle : tout ce qu'elles font serait compte comme un
    # ecart. Leurs heures se suivent ailleurs, pas dans la recuperation.
    a_lheure = {s['id'] for s in bd.salaries(cx) if s['paye_a_l_heure']}

    totaux = {}
    for salarie_id, date_iso in sorted(modifies):
        if salarie_id in a_lheure:
            continue
        jour = datetime.date.fromisoformat(date_iso)
        if not (debut <= jour <= fin) or jour.weekday() > 5 or jour in feries:
            continue
        annee_iso, numero = cal.semaine_de(jour)
        cycle = cal.semaine_cycle(annee_iso, numero)
        prevu = sum(c['fin_min'] - c['debut_min']
                    for c in index[cycle].get((salarie_id, jour.weekday() + 1), []))
        fait = sum(c['fin_min'] - c['debut_min']
                   for c in exceptions.get((salarie_id, date_iso), []))
        ecart = (fait - prevu) / 60.0
        if abs(ecart) < 0.001:
            continue
        bloc = totaux.setdefault(salarie_id, {'heures': 0.0, 'detail': []})
        bloc['heures'] += ecart
        bloc['detail'].append({'date': date_iso, 'prevu': round(prevu / 60.0, 2),
                               'fait': round(fait / 60.0, 2), 'ecart': round(ecart, 2)})

    gens = {s['id']: s for s in bd.salaries(cx)}
    resultat = []
    for salarie_id, bloc in totaux.items():
        s = gens.get(salarie_id)
        if not s:
            continue
        resultat.append({
            'salarie_id': salarie_id, 'prenom': s['prenom'],
            'initiale': s['initiale'], 'couleur': s['couleur'],
            'heures': round(bloc['heures'], 2), 'detail': bloc['detail'],
        })
    resultat.sort(key=lambda r: r['heures'])
    return resultat


def ecarts_cumules(cx, fin=None):
    """
    { salarie_id : heures } — l'ecart au cycle accumule depuis le demarrage du planning
    (semaine 36 de 2026) jusqu'a aujourd'hui. C'est ce qui alimente la recuperation.
    """
    debut = cal.lundi_de(cal.DEBUT_ANNEE, cal.DEBUT_SEMAINE)
    fin = fin or (datetime.date.today() + datetime.timedelta(days=365 * 2))
    return {r['salarie_id']: r['heures'] for r in compteur_recuperation(cx, debut, fin)}


def semaine(cx, annee, numero):
    """
    Le planning effectif d'une semaine ISO : qui travaille quand, qui est absent,
    et ou les regles ne sont plus tenues.
    """
    cycle = cal.semaine_cycle(annee, numero)
    lundi = cal.lundi_de(annee, numero)
    samedi = lundi + datetime.timedelta(5)

    gens = bd.salaries(cx)
    fonctions = {s['id']: s['fonction'] for s in gens}
    initiales = {s['id']: s['initiale'] for s in gens}

    index = {1: _index_creneaux(cx, 1), 2: _index_creneaux(cx, 2)}
    exceptions, modifies = _index_exceptions(cx, lundi, samedi)
    abs_index = _absences_par_jour(cx, lundi, samedi)
    feries = cal.tous_les_feries(range(lundi.year - 1, samedi.year + 2))

    jours, occupe = [], {}
    for i in range(6):
        date = lundi + datetime.timedelta(i)
        ferie = feries.get(date)
        lignes = []
        for s in gens:
            creneaux_jour = creneaux_effectifs(cx, s['id'], date, index,
                                               exceptions, modifies)
            absence = abs_index.get((s['id'], date.isoformat()))
            heures = sum(c['fin_min'] - c['debut_min'] for c in creneaux_jour) / 60.0
            # Ce que dirait le cycle, meme si la journee a ete retouchee : la grille
            # garde ainsi la trace des creneaux enleves a la main, en hachure.
            creneaux_cycle = index[cycle].get((s['id'], i + 1), [])
            lignes.append({
                'salarie_id': s['id'],
                'creneaux': creneaux_jour,
                'creneaux_cycle': creneaux_cycle,
                'heures_cycle': round(
                    sum(c['fin_min'] - c['debut_min'] for c in creneaux_cycle) / 60.0, 2),
                'heures': round(heures, 2),
                'absent': bool(ferie) or absence is not None,
                'absence': absence,
                'motif': u'F\xe9ri\xe9' if ferie else (absence['motif'] if absence else None),
                'modifie': (s['id'], date.isoformat()) in modifies,
                # Quand cette journee a bougé pour la derniere fois : une retouche du
                # planning ou la pose d'une absence. C'est ce qui permet a l'equipe de
                # voir ce qui a change depuis sa derniere visite.
                'change_le': max([x for x in (
                    modifies.get((s['id'], date.isoformat())),
                    absence['cree_le'] if absence else None) if x] or [None]),
            })
            if not ferie and absence is None:
                for c in creneaux_jour:
                    if c['hors_comptoir']:
                        continue
                    for minute in range(c['debut_min'], c['fin_min'], 30):
                        occupe.setdefault((i + 1, minute), []).append(s['id'])

        jours.append({'date': date.isoformat(), 'jour': i + 1,
                      'nom': cal.JOURS[i], 'ferie': ferie, 'lignes': lignes})

    presence = {}
    for i in range(6):
        jour = i + 1
        ouverts = set(bd.creneaux_ouverture(jour))
        derniere = bd.derniere_demi_heure(jour)
        ferme_toute_la_journee = jours[i]['ferie'] is not None
        pas = []
        for minute in bd.creneaux_grille():
            ouvert = (minute in ouverts) and not ferme_toute_la_journee
            ids = occupe.get((jour, minute), [])
            nb_pharmaciens = sum(1 for x in ids if fonctions.get(x) == bd.PHARMACIENS)
            au_comptoir = [x for x in ids if fonctions.get(x) not in bd.HORS_COMPTOIR]
            pas.append({
                'minute': minute,
                'ouvert': ouvert,
                'initiales': [initiales[x] for x in ids],
                'nb': len(ids),
                'comptoir': len(au_comptoir),
                'pharmaciens': nb_pharmaciens,
                'manque_pharmacien': ouvert and nb_pharmaciens == 0,
                'sous_effectif': (ouvert and len(au_comptoir) < bd.MINI_COMPTOIR
                                  and minute != derniere),
            })
        presence[jour] = pas

    return {
        'annee': annee, 'numero': numero, 'cycle': cycle,
        'lundi': lundi.isoformat(), 'samedi': samedi.isoformat(),
        'jours': jours, 'presence': presence,
        'remplacants': remplacants(cx, jours, presence, cycle),
        'recuperation': compteur_recuperation(cx, lundi, samedi),
    }


def remplacants(cx, jours, presence, cycle):
    """
    Pour chaque jour en alerte, qui pourrait couvrir.

    Deux criteres, ceux retenus au cadrage :
      1. le bon statut — s'il manque un pharmacien, on ne propose que des pharmaciens ;
      2. qui est sous son contrat sur le cycle — on rattrape au lieu de creuser.

    On ne propose que des gens libres ce jour-la et non absents. La rayonniste en est
    exclue d'office : les alertes portent sur la tenue du COMPTOIR, qu'elle n'assure
    pas — la proposer ne reglerait rien.
    """
    ecarts = {c['salarie_id']: (c['ecart'] if c['ecart'] is not None else 0.0)
              for c in bd.compteurs(cx)}
    gens = {s['id']: s for s in bd.salaries(cx)}

    propositions = []
    for jour_bloc in jours:
        jour = jour_bloc['jour']
        alertes = [p for p in presence[jour] if p['sous_effectif'] or p['manque_pharmacien']]
        if not alertes:
            continue
        besoin_pharmacien = any(p['manque_pharmacien'] for p in alertes)

        occupes = {l['salarie_id'] for l in jour_bloc['lignes']
                   if l['creneaux'] and not l['absent']}
        indisponibles = {l['salarie_id'] for l in jour_bloc['lignes'] if l['absent']}

        candidats = []
        for sid, s in gens.items():
            if sid in occupes or sid in indisponibles:
                continue
            if s['fonction'] in bd.HORS_COMPTOIR:
                continue                     # ne tient pas le comptoir
            if besoin_pharmacien and s['fonction'] != bd.PHARMACIENS:
                continue
            candidats.append({
                'salarie_id': sid, 'prenom': s['prenom'], 'initiale': s['initiale'],
                'fonction': s['fonction'], 'couleur': s['couleur'],
                'ecart': round(ecarts.get(sid, 0.0), 2),
                'paye_a_l_heure': s['paye_a_l_heure'],
            })
        # Le plus en retard sur son contrat d'abord ; les etudiantes ferment la marche
        # tant que quelqu'un a des heures a rattraper.
        candidats.sort(key=lambda c: (c['paye_a_l_heure'], c['ecart']))

        propositions.append({
            'jour': jour,
            'date': jour_bloc['date'],
            'creneaux_en_alerte': len(alertes),
            'besoin_pharmacien': besoin_pharmacien,
            'candidats': candidats[:5],
        })
    return propositions


if __name__ == '__main__':
    cx = bd.connexion()

    for numero in (37, 38):
        s = semaine(cx, 2026, numero)
        alertes = sum(1 for j in s['presence'].values()
                      for p in j if p['sous_effectif'] or p['manque_pharmacien'])
        print(u'Semaine %d (S%d) — du %s au %s : %d alerte(s)'
              % (s['numero'], s['cycle'], s['lundi'], s['samedi'], alertes))

    # Une absence de test : combien d'heures a deduire ?
    sid = cx.execute("SELECT id FROM salarie WHERE initiale = 'E'").fetchone()['id']
    d1, d2 = datetime.date(2026, 9, 7), datetime.date(2026, 9, 12)
    print(u'\n\xc9lodie, du %s au %s : %s h pr\xe9vues'
          % (d1, d2, heures_prevues(cx, sid, d1, d2)['heures']))
    cx.close()
