#!/usr/bin/env python3
"""Models Studio Singularity 0.1.0. Suppression ciblée, locale et explicite.

Ne constitue pas un outil d'assainissement d'un disque entier. Le rapport atteste
les contrôles effectués sur les fichiers choisis, pas l'absence de toute copie.
Python >= 3.11. Aucune dépendance réseau. Licence MIT.
"""
from __future__ import annotations
import argparse,datetime as dt,hashlib,json,os,platform,queue,secrets,stat,subprocess,sys,threading
from pathlib import Path
VERSION='0.1.0'
CHUNK=1024*1024
LIMITS='Fichiers sélectionnés uniquement. Sauvegardes, synchronisation, anciennes versions, secteurs remappés et réserves SSD non couverts. Aucune garantie de récupération impossible.'
class Refused(Exception):pass
class Cancelled(Exception):pass
def stamp():return dt.datetime.now(dt.timezone.utc).isoformat()
def fingerprint(s):return {'device':s.st_dev,'inode':s.st_ino,'size':s.st_size,'mtime_ns':s.st_mtime_ns,'ctime_ns':s.st_ctime_ns}
def protected(path):
 if os.name=='nt':
  roots=[os.environ.get(k) for k in ['SystemRoot','ProgramFiles','ProgramFiles(x86)','ProgramData']]
 else:roots=['/etc','/usr','/bin','/sbin','/lib','/lib64','/boot','/dev','/proc','/sys','/var/lib']
 return any(path==Path(r).absolute() or Path(r).absolute() in path.parents for r in roots if r)
def inspect(path):
 p=Path(os.path.abspath(os.fspath(path)))
 if protected(p):raise Refused('Emplacement système protégé : '+str(p))
 for part in [p,*p.parents]:
  s=part.lstat()
  if stat.S_ISLNK(s.st_mode) or getattr(s,'st_file_attributes',0)&0x400:raise Refused('Lien, jonction ou fichier cloud refusé : '+str(p))
 s=p.lstat()
 if not stat.S_ISREG(s.st_mode):raise Refused('Choisis un fichier normal, pas un dossier ou un périphérique : '+str(p))
 if s.st_nlink!=1:raise Refused('Ce fichier possède plusieurs liens physiques : '+str(p))
 if p==Path(__file__).resolve():raise Refused('Le programme ne peut pas se supprimer lui-même.')
 return {'path':str(p),'name':p.name,**fingerprint(s)}
def plan(paths):
 if not paths or len(paths)>1000:raise Refused('Sélectionne entre 1 et 1 000 fichiers.')
 files=[];seen=set()
 for path in paths:
  item=inspect(path);identity=(item['device'],item['inode'])
  if identity in seen:continue
  seen.add(identity);files.append(item)
 return {'application':'Models Studio Singularity','version':VERSION,'id':secrets.token_hex(12),'created':stamp(),'files':files,'total_bytes':sum(f['size'] for f in files),'limits':LIMITS}
def same(item,s):return all(item.get(k)==v for k,v in fingerprint(s).items()) and s.st_nlink==1 and stat.S_ISREG(s.st_mode)
def write_json(path,data):
 path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
 flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0)
 fd=os.open(path,flags,0o600)
 with os.fdopen(fd,'w',encoding='utf-8') as out:json.dump(data,out,ensure_ascii=False,indent=2);out.flush();os.fsync(out.fileno())
def execute(prepared,confirmation,method='delete',medium='unknown',report_dir=None,cancel=None,progress=None):
 if not isinstance(prepared,dict):raise Refused('Plan invalide.')
 files=prepared.get('files',[])
 if not isinstance(files,list) or any(not isinstance(f,dict) or not isinstance(f.get('path'),str) or not isinstance(f.get('name'),str) or any(type(f.get(k)) is not int for k in ['device','inode','size','mtime_ns','ctime_ns']) for f in files):raise Refused('Plan incomplet ou invalide.')
 if not 1<=len(files)<=1000 or confirmation!='EFFACER '+str(len(files)):raise Refused('Confirmation exacte requise : EFFACER '+str(len(files)))
 if method not in {'delete','overwrite'}:raise Refused('Méthode inconnue.')
 if method=='overwrite' and medium!='hdd':raise Refused('Réécriture réservée à un support déclaré HDD. Sur SSD/flash, utiliser une méthode adaptée au support entier.')
 # Tous les objets sont contrôlés avant la première modification.
 for item in files:
  current=inspect(item['path'])
  if any(current.get(k)!=item.get(k) for k in fingerprint(Path(item['path']).lstat())):raise Refused('Un fichier a changé depuis la préparation : '+item['name'])
 report={'application':'Models Studio Singularity','version':VERSION,'plan':prepared.get('id'),'started':stamp(),'status':'in_progress','method':method,'medium_declared':medium,'limits':LIMITS,'files':[]}
 folder=Path(report_dir) if report_dir else Path.home()/'Singularity-Reports'
 folder.mkdir(parents=True,exist_ok=True)
 journal=folder/('singularity-'+secrets.token_hex(12)+'.jsonl')
 if any(Path(f['path'])==journal.absolute() for f in files):raise Refused('Le rapport doit être distinct des fichiers ciblés.')
 flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0)
 with os.fdopen(os.open(journal,flags,0o600),'w',encoding='utf-8') as log:
  def record(value):log.write(json.dumps(value,ensure_ascii=False)+'\n');log.flush();os.fsync(log.fileno())
  record({'type':'start',**{k:v for k,v in report.items() if k!='files'}})
  for item in files:
   if cancel and cancel.is_set():report['status']='interrupted';break
   result={'name':item['name'],'path_hash':hashlib.sha256(item['path'].encode()).hexdigest(),'bytes':item['size'],'status':'started','at':stamp()};record({'type':'file_start',**result})
   pending=None;moved=False;original=Path(item['path'])
   try:
    current=inspect(original)
    if any(current.get(k)!=item.get(k) for k in fingerprint(original.lstat())):raise Refused('Le fichier a changé depuis la préparation.')
    # Réservation exclusive du nom temporaire avant déplacement sur le même volume.
    pending=original.parent/('.singularity-'+secrets.token_hex(16)+'.pending')
    fd=os.open(pending,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600);os.close(fd)
    os.replace(original,pending)
    moved=True
    with os.fdopen(os.open(pending,os.O_RDWR|getattr(os,'O_NOFOLLOW',0)|getattr(os,'O_BINARY',0)),'r+b',buffering=0) as handle:
     after=os.fstat(handle.fileno())
     # Le déplacement modifie ctime sur certains systèmes ; les autres attributs restent ancrés.
     if not stat.S_ISREG(after.st_mode) or after.st_nlink!=1 or any(item[k]!=getattr(after,{'device':'st_dev','inode':'st_ino','size':'st_size','mtime_ns':'st_mtime_ns'}[k]) for k in ['device','inode','size','mtime_ns']):raise Refused('L’identité du fichier a changé ; opération refusée.')
     if method=='overwrite':
      total=item['size'];done=0;zero=b'\0'*CHUNK
      while done<total:
       if cancel and cancel.is_set():raise Cancelled('Interrompu : le contenu peut être partiellement réécrit.')
       part=zero[:min(CHUNK,total-done)];written=handle.write(part)
       if written!=len(part):raise OSError('Écriture incomplète.')
       done+=written
       if progress:progress(done,total,item['name'],'Écriture')
      handle.flush();os.fsync(handle.fileno());handle.seek(0);done=0
      while done<total:
       if cancel and cancel.is_set():raise Cancelled('Vérification interrompue après réécriture.')
       part=handle.read(min(CHUNK,total-done))
       if not part or part!=b'\0'*len(part):raise OSError('Le contrôle de lecture a échoué.')
       done+=len(part)
       if progress:progress(done,total,item['name'],'Vérification')
      result['readback']='all_addressed_bytes_zero'
    if cancel and cancel.is_set():raise Cancelled('Interrompu avant suppression.')
    # Une substitution de chemin est refusée avant unlink. Le fichier reste sinon dans le dossier source.
    actual=inspect(pending)
    if actual['inode']!=item['inode'] or actual['device']!=item['device']:raise Refused('Le fichier temporaire a changé.')
    pending.unlink();pending=None
    result['status']='overwritten_verified_and_unlinked' if method=='overwrite' else 'unlinked_logically'
   except Exception as error:
    result['status']='interrupted' if isinstance(error,Cancelled) else 'failed';result['error']=str(error);report['status']='interrupted' if isinstance(error,Cancelled) else 'failed'
    if pending and not moved:
     try:pending.unlink(missing_ok=True)
     except OSError:pass
    elif pending and pending.exists():
     if not original.exists():
      try:os.replace(pending,original);result['restored_name']=True
      except OSError:result['pending_name']=pending.name
     else:result['pending_name']=pending.name
    report['files'].append(result);record({'type':'file_end',**result});break
   report['files'].append(result);record({'type':'file_end',**result})
  if report['status']=='in_progress':report['status']='completed' if len(report['files'])==len(files) else 'interrupted'
  report['ended']=stamp();record({'type':'end','status':report['status'],'at':report['ended']})
 report['journal']=str(journal);final=journal.with_suffix('.json')
 try:write_json(final,report)
 except OSError as error:report['report_export_error']=str(error);final=journal
 return report,final

def inventory():
 """Lecture des supports, sans commande de modification de disque."""
 if os.name=='nt':
  command=['powershell.exe','-NoProfile','-NonInteractive','-Command',"Get-PhysicalDisk | Select-Object FriendlyName,MediaType,BusType,Size,HealthStatus | ConvertTo-Json -Depth 3"]
 elif sys.platform.startswith('linux'):command=['lsblk','--json','--bytes','--output','NAME,MODEL,SIZE,ROTA,TYPE,MOUNTPOINTS']
 else:return {'system':platform.system(),'note':'Inventaire matériel indisponible sur ce système.'}
 try:
  result=subprocess.run(command,capture_output=True,text=True,timeout=15,check=True)
  return {'system':platform.system(),'devices':json.loads(result.stdout or '[]'),'scope':'Inventaire seulement ; aucune commande d’assainissement exécutée.'}
 except (OSError,ValueError,subprocess.SubprocessError):return {'system':platform.system(),'note':'Inventaire indisponible avec les droits ou outils actuels.'}

def gui():
 try:import tkinter as tk;from tkinter import ttk,filedialog,messagebox,simpledialog
 except ImportError:raise SystemExit('Interface : installe Tkinter (python3-tk sur Linux) ou utilise --help pour le mode ligne de commande.')
 root=tk.Tk();root.title('Singularity · Models Studio');root.geometry('1000x720');root.minsize(760,600);style=ttk.Style();style.theme_use('clam')
 dark=tk.BooleanVar(value=True);paths=[];events=queue.Queue();cancellation=threading.Event();running=False;prepared=None
 def theme():
  bg,panel,ink,muted,accent=('#12151e','#1c2130','#f0eef7','#a6aec3','#b3a1fa') if dark.get() else ('#f4f4fa','#ffffff','#252837','#626b80','#7965d4')
  root.configure(bg=bg);style.configure('.',font=('Segoe UI',10),background=bg,foreground=ink);style.configure('TFrame',background=bg);style.configure('TNotebook',background=bg);style.configure('TNotebook.Tab',padding=(18,12));style.configure('TButton',padding=(14,10),background=panel,foreground=ink);style.configure('TLabel',background=bg,foreground=ink);style.configure('Hero.TLabel',font=('Segoe UI',28,'bold'));style.configure('Accent.TLabel',foreground=accent);style.configure('Muted.TLabel',foreground=muted);style.configure('Treeview',background=panel,fieldbackground=panel,foreground=ink,rowheight=32);style.configure('Treeview.Heading',background=panel,foreground=ink,padding=9);style.map('TButton',background=[('active',accent)]);style.configure('TCombobox',fieldbackground=panel,foreground=ink);style.map('TCombobox',fieldbackground=[('readonly',panel)],foreground=[('readonly',ink)])
 top=ttk.Frame(root,padding=24);top.pack(fill='x');ttk.Label(top,text='◉  singularity.',style='Hero.TLabel').pack(side='left');ttk.Checkbutton(top,text='ORBIT sombre',variable=dark,command=theme).pack(side='right')
 ttk.Label(root,text='MODELS STUDIO   /   FICHIERS SÉLECTIONNÉS',style='Accent.TLabel',padding=(26,0)).pack(anchor='w')
 notebook=ttk.Notebook(root);notebook.pack(fill='both',expand=True,padx=24,pady=20)
 files=ttk.Frame(notebook,padding=20);devices=ttk.Frame(notebook,padding=20);prepare=ttk.Frame(notebook,padding=20);reports=ttk.Frame(notebook,padding=20)
 for pane,name in [(files,'Fichiers'),(devices,'Supports'),(prepare,'Céder un PC'),(reports,'Rapport')]:notebook.add(pane,text=name)
 ttk.Label(files,text='Choisir. Vérifier. Confirmer.',font=('Segoe UI',18,'bold')).pack(anchor='w');ttk.Label(files,text='Aucune action n’est lancée à la sélection. Les dossiers, périphériques et liens sont refusés.',style='Muted.TLabel',wraplength=850).pack(anchor='w',pady=(8,18))
 table=ttk.Treeview(files,columns=('name','size'),show='headings',height=7);table.heading('name',text='Fichier');table.heading('size',text='Octets');table.column('name',width=650);table.column('size',width=140,anchor='e');table.pack(fill='both',expand=True)
 toolbar=ttk.Frame(files);toolbar.pack(fill='x',pady=12)
 def update_paths():
  nonlocal prepared
  table.delete(*table.get_children())
  try:
   prepared=plan(paths) if paths else None
   for index,item in enumerate(prepared['files'] if prepared else []):table.insert('',index,iid=str(index),values=(item['path'],item['size']))
   status.set(f"{len(prepared['files'])} fichier(s) · {prepared['total_bytes']:,} octets. Aucun effacement lancé." if prepared else 'Choisis les fichiers à retirer.')
  except (OSError,Refused) as e:prepared=None;status.set(str(e));messagebox.showerror('Sélection refusée',str(e),parent=root)
 def add_files():
  if running:return
  paths.extend(filedialog.askopenfilenames(parent=root,title='Fichiers à supprimer'));update_paths()
 def clear_files():
  if running:return
  paths.clear();update_paths()
 ttk.Button(toolbar,text='+ Ajouter des fichiers',command=add_files).pack(side='left');ttk.Button(toolbar,text='Vider la sélection',command=clear_files).pack(side='left',padx=10)
 options=ttk.Frame(files);options.pack(fill='x',pady=8);ttk.Label(options,text='Méthode').grid(row=0,column=0,sticky='w');method=ttk.Combobox(options,state='readonly',values=['Suppression logique','Réécriture HDD + lecture de contrôle'],width=37);method.current(0);method.grid(row=1,column=0,padx=(0,16),pady=6);ttk.Label(options,text='Support déclaré').grid(row=0,column=1,sticky='w');medium=ttk.Combobox(options,state='readonly',values=['Inconnu','HDD','SSD / flash'],width=18);medium.current(0);medium.grid(row=1,column=1,pady=6)
 status=tk.StringVar(value='Choisis les fichiers à retirer.');ttk.Label(files,textvariable=status,wraplength=840).pack(anchor='w',pady=12);bar=ttk.Progressbar(files,mode='determinate');bar.pack(fill='x')
 report_text=tk.Text(reports,wrap='word',font=('Consolas',10),bg='#1c2130',fg='#f0eef7',insertbackground='#f0eef7',relief='flat',padx=18,pady=18);report_text.pack(fill='both',expand=True);report_text.insert('1.0','Le rapport apparaîtra après une opération. Aucun fichier n’est transmis.\n\n'+LIMITS);report_text.configure(state='disabled')
 def start():
  nonlocal running,prepared
  if running or not prepared:return
  chosen='overwrite' if method.current()==1 else 'delete';media=['unknown','hdd','ssd'][medium.current()]
  if chosen=='overwrite' and media!='hdd':messagebox.showerror('Méthode inadaptée','La réécriture est réservée au HDD déclaré. Un SSD nécessite une méthode d’assainissement matériel pour une cession complète.',parent=root);return
  phrase='EFFACER '+str(len(prepared['files']));answer=simpledialog.askstring('Confirmation de suppression',f"{len(prepared['files'])} fichier(s) · {prepared['total_bytes']:,} octets\n{method.get()}\n\n{LIMITS}\n\nAction sans corbeille. Saisis exactement : {phrase}",parent=root)
  if answer!=phrase:return
  data=prepared;running=True;cancellation.clear();bar['value']=0;status.set('Opération en cours…')
  def worker():
   try:events.put(('done',execute(data,answer,chosen,media,cancel=cancellation,progress=lambda n,t,name,step:events.put(('progress',(n,t,name,step))))))
   except Exception as e:events.put(('error',str(e)))
  threading.Thread(target=worker,daemon=False).start()
 actions=ttk.Frame(files);actions.pack(fill='x',pady=12);ttk.Button(actions,text='Vérifier et supprimer…',command=start).pack(side='right');ttk.Button(actions,text='Interrompre',command=cancellation.set).pack(side='right',padx=10)
 ttk.Label(files,text='Un contrôle de lecture porte sur les octets adressés, jamais sur les anciennes copies ou réserves du support.',style='Muted.TLabel',wraplength=840).pack(anchor='w')
 device_text=tk.Text(devices,wrap='word',bg='#1c2130',fg='#f0eef7',font=('Consolas',11),relief='flat',padx=18,pady=18);device_text.pack(fill='both',expand=True)
 def scan_devices():
  device_text.delete('1.0','end');device_text.insert('1.0','Lecture des supports…');threading.Thread(target=lambda:events.put(('inventory',inventory())),daemon=True).start()
 ttk.Button(devices,text='Lire les supports · aucune modification',command=scan_devices).pack(anchor='w',pady=12)
 ttk.Label(prepare,text='Une cession se prépare sur le support entier.',font=('Segoe UI',18,'bold'),wraplength=800).pack(anchor='w',pady=12)
 for title,text in [('1 · Sauvegarder et vérifier','Ouvre quelques fichiers dans la sauvegarde, récupère les accès et moyens de récupération nécessaires.'),('2 · Identifier le support','HDD, SSD, NVMe : le modèle et les capacités déterminent la méthode. L’inventaire ne lance aucune commande destructive.'),('3 · Sortir du système actif','Le disque système se traite avec une procédure constructeur ou un environnement hors ligne adapté. Le mode fichiers de Singularity ne remplace pas cette procédure.'),('4 · Choisir et contrôler','Pour SSD/flash, privilégier une commande d’assainissement du support ou un effacement cryptographique dont les conditions sont vérifiées. Pour un support défaillant : filière de destruction adaptée.'),('5 · Vérifier les autres copies','Comptes en ligne, synchronisations, sauvegardes et autres appareils se traitent séparément.')]:
  ttk.Label(prepare,text=title,font=('Segoe UI',11,'bold')).pack(anchor='w',pady=(14,4));ttk.Label(prepare,text=text,wraplength=820,style='Muted.TLabel').pack(anchor='w')
 ttk.Label(prepare,text='Cette version n’exécute pas de commande d’effacement de disque. Référence : NIST SP 800-88r2 (2025).',wraplength=820,style='Accent.TLabel').pack(anchor='w',pady=20)
 def poll():
  nonlocal running,prepared
  try:
   while True:
    kind,value=events.get_nowait()
    if kind=='progress':n,t,name,step=value;bar['value']=n/max(t,1)*100;status.set(step+' · '+name+' · '+str(round(n/max(t,1)*100))+' %')
    elif kind=='done':
     report,path=value;running=False;status.set({'completed':'Opération terminée dans le périmètre indiqué.','interrupted':'Opération interrompue : consulte le rapport.','failed':'Échec : consulte le rapport.'}[report['status']]);bar['value']=100 if report['status']=='completed' else bar['value'];report_text.configure(state='normal');report_text.delete('1.0','end');report_text.insert('1.0',str(path)+'\n\n'+json.dumps(report,ensure_ascii=False,indent=2));report_text.configure(state='disabled');paths.clear();prepared=None;table.delete(*table.get_children());notebook.select(reports)
    elif kind=='error':running=False;status.set(value);messagebox.showerror('Opération non confirmée',value+'\nVérifie les éventuels journaux dans Singularity-Reports avant de relancer.',parent=root)
    elif kind=='inventory':device_text.delete('1.0','end');device_text.insert('1.0',json.dumps(value,ensure_ascii=False,indent=2))
  except queue.Empty:pass
  root.after(120,poll)
 def close():
  if running:cancellation.set();messagebox.showinfo('Interruption demandée','Attends la fin de la vérification du fichier courant et son rapport avant de fermer.',parent=root)
  else:root.destroy()
 theme();root.protocol('WM_DELETE_WINDOW',close);poll();root.mainloop()

def main():
 if len(sys.argv)==1:return gui()
 parser=argparse.ArgumentParser(description=__doc__);sub=parser.add_subparsers(dest='command',required=True)
 p=sub.add_parser('plan');p.add_argument('files',nargs='+');p.add_argument('--output',type=Path,required=True)
 p=sub.add_parser('execute');p.add_argument('plan',type=Path);p.add_argument('--confirm',required=True);p.add_argument('--method',choices=['delete','overwrite'],default='delete');p.add_argument('--medium',choices=['unknown','hdd','ssd'],default='unknown');p.add_argument('--reports',type=Path)
 sub.add_parser('inventory')
 args=parser.parse_args()
 try:
  if args.command=='plan':data=plan(args.files);write_json(args.output,data);print(json.dumps(data,ensure_ascii=False,indent=2))
  elif args.command=='inventory':print(json.dumps(inventory(),ensure_ascii=False,indent=2))
  else:
   if args.plan.stat().st_size>2*1024*1024:raise Refused('Plan trop volumineux.')
   data=json.loads(args.plan.read_text(encoding='utf-8'));report,path=execute(data,args.confirm,args.method,args.medium,args.reports);print(json.dumps(report,ensure_ascii=False,indent=2));print(path);return 0 if report['status']=='completed' else 1
 except (OSError,ValueError,Refused,KeyError,TypeError) as error:print('Opération interrompue ou refusée : '+str(error)+'. Vérifie les éventuels journaux avant de relancer.',file=sys.stderr);return 2
 return 0
if __name__=='__main__':sys.exit(main())
