#!/usr/bin/env python3
"""Exercice fictif SQLite/Python3 : index de métadonnées, aucun accès Microsoft365.
Aucun fichier réel n'est lu/supprimé. Aucun jeton n'est utilisé. Les liens p1/p2/p3
sont des clés de fixture, pas des URL Graph. Modèle limité à un miroir en lecture.
"""
import sqlite3
import json
from copy import deepcopy

SCOPE = 'tenant-fictif/drive-documentaire/config-v1'
OLD = [('F0', 'Ancien', 'ROOT', 1), ('A', 'contrat-v1.pdf', 'F0', 0),
       ('B', 'tarifs.xlsx', 'ROOT', 0), ('C', 'obsolete.pdf', 'ROOT', 0)]
PAGES = {
    'p1': {'value': [
        {'id':'F1','name':'Référentiel','parent':'ROOT','folder':True},
        {'id':'F0','deleted':{}},
        {'id':'A','name':'contrat-provisoire.pdf','parent':'F0','folder':False},
    ], 'next':'p2'},
    'p2': {'value':[], 'next':'p3'},
    'p3': {'value':[
        {'id':'A','name':'contrat-final.pdf','parent':'F1','folder':False},
        {'id':'B','name':'tarifs-2026.xlsx','parent':'ROOT','folder':False},
        {'id':'D','name':'notice.pdf','parent':'F1','folder':False},
    ], 'delta':'d-final'},
}

class Refused(Exception):
    pass

def database():
    db=sqlite3.connect(':memory:')
    db.executescript('''CREATE TABLE active(id TEXT PRIMARY KEY,name TEXT,parent TEXT,is_folder INTEGER);
      CREATE TABLE stage(id TEXT PRIMARY KEY,name TEXT,parent TEXT,is_folder INTEGER);
      CREATE TABLE state(scope TEXT,cursor TEXT);
      CREATE TABLE business(document_id TEXT,note TEXT);
      INSERT INTO business VALUES('C','Historique contractuel à conserver');''')
    db.executemany('INSERT INTO active VALUES(?,?,?,?)',OLD)
    db.execute('INSERT INTO state VALUES(?,?)',(SCOPE,'d-old'))
    db.commit()
    return db

def snapshot(db):
    return {'items':db.execute('SELECT * FROM active ORDER BY id').fetchall(),
            'state':db.execute('SELECT * FROM state').fetchall(),
            'business':db.execute('SELECT * FROM business').fetchall()}

def rebuild(db,pages=PAGES,scope=SCOPE,fail_after=None,fail_publish=False,
            mode='resyncChangesApplyDifferences',read_only=True):
    if mode!='resyncChangesApplyDifferences' or not read_only:
        raise Refused('conciliation bidirectionnelle hors périmètre')
    if scope!=db.execute('SELECT scope FROM state').fetchone()[0]:
        raise Refused('périmètre changé')
    # Une nouvelle génération repart à vide en staging, jamais dans active.
    db.execute('DELETE FROM stage');db.commit()
    key='p1'; seen=[]; deleted=set(); cursor=None
    while key:
        if key in seen: raise Refused('boucle de continuation')
        seen.append(key)
        page=pages[key]
        if page.get('error'): raise Refused('erreur distante simulée : '+str(page['error']))
        if bool(page.get('next')) == bool(page.get('delta')):
            raise Refused('continuation absente ou ambiguë')
        for item in page['value']:
            if 'deleted' in item:
                deleted.add(item['id']);db.execute('DELETE FROM stage WHERE id=?',(item['id'],))
            else:
                deleted.discard(item['id'])
                db.execute('INSERT OR REPLACE INTO stage VALUES(?,?,?,?)',
                           (item['id'],item['name'],item['parent'],int(item['folder'])))
        db.commit() # Seule la préparation avance ; active et d-old restent ensemble.
        if key==fail_after: raise Refused('arrêt simulé après '+key)
        cursor=page.get('delta');key=page.get('next')
    # Validation seulement après le cycle complet. ROOT est hors jeu de données.
    items=db.execute('SELECT * FROM stage').fetchall()
    folders={r[0] for r in items if r[3]}
    if any(r[2]!='ROOT' and r[2] not in folders for r in items):
        raise Refused('parent absent ou supprimé encore référencé')
    if any(r[2] in deleted for r in items): raise Refused('dossier supprimé non vide')
    # Contrôle volontairement sans suppression de fichier ni d'historique métier.
    try:
        db.execute('BEGIN')
        db.execute('DELETE FROM active')
        db.execute('INSERT INTO active SELECT * FROM stage')
        if fail_publish: raise Refused('arrêt pendant promotion')
        db.execute('UPDATE state SET cursor=?',(cursor,))
        db.commit()
    except Exception:
        db.rollback();raise
    return seen

checks=[]
def check(name, condition):
    if not condition: raise AssertionError(name)
    checks.append(name)

def refused(call):
    try: call()
    except Refused: return True
    return False

db=database();initial=snapshot(db)
check('arrêt après première page refusé',refused(lambda:rebuild(db,fail_after='p1')))
check('index et curseur anciens conservés',snapshot(db)==initial)
check('page vide avec continuation parcourue',rebuild(db)==['p1','p2','p3'])
result=snapshot(db)
check('quatre éléments attendus F1 A B D',set(r[0] for r in result['items'])=={'F1','A','B','D'})
check('même quantité ne prouve pas même état',len(initial['items'])==len(result['items']) and initial['items']!=result['items'])
a=next(r for r in result['items'] if r[0]=='A')
check('dernière occurrence A conservée',a[1:3]==('contrat-final.pdf','F1'))
check('ancien dossier retiré après déplacement',not any(r[0]=='F0' or r[2]=='F0' for r in result['items']))
check('absent C retiré du miroir complet',not any(r[0]=='C' for r in result['items']))
check('historique métier C conservé',result['business']==initial['business'])
check('curseur final et index promus ensemble',result['state']==[(SCOPE,'d-final')])
rebuild(db);check('reconstruction rejouée idempotente',snapshot(db)==result)
db2=database();s2=snapshot(db2)
check('panne transactionnelle refusée',refused(lambda:rebuild(db2,fail_publish=True)))
check('rollback index et curseur après panne',snapshot(db2)==s2)
check('périmètre différent refusé',refused(lambda:rebuild(db2,scope='autre-drive')) and snapshot(db2)==s2)
check('mode upload différences hors périmètre',refused(lambda:rebuild(db2,mode='resyncChangesUploadDifferences')))
check('écritures locales non prises en charge',refused(lambda:rebuild(db2,read_only=False)))
bad=deepcopy(PAGES);bad['p2']={'error':403}
check('403 jamais traité en inventaire vide',refused(lambda:rebuild(db2,pages=bad)) and snapshot(db2)==s2)
bad=deepcopy(PAGES);bad['p3']['value'][0]['parent']='F0'
check('dossier supprimé encore parent bloque promotion',refused(lambda:rebuild(db2,pages=bad)) and snapshot(db2)==s2)
# Témoins de décisions erronées, sans les appliquer à active.
first_seen={}
for page in PAGES.values():
    for item in page['value']:
        if 'deleted' not in item: first_seen.setdefault(item['id'],item)
check('témoin première occurrence garde mauvais parent',first_seen['A']['parent']=='F0' and a[2]=='F1')
check('témoin arrêt sur vide manque la dernière page',PAGES['p2']['value']==[] and PAGES['p2']['next']=='p3')
check('témoin latest conserve ancien miroir incomplet',initial['items']!=result['items'])
print(json.dumps({'checks':len(checks),'passed':checks,'before':initial,'after':result,
                  'scope':'SQLite local fictif,aucun appel Graph,aucun fichier réel supprimé'},ensure_ascii=False,indent=2))
db.close();db2.close()
