#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
win_upgrade_check.py - Vérification des prérequis pour une mise à jour majeure de Windows.

Scénarios gérés (détectés automatiquement) :
  * Windows 10  -> Windows 11   : contrôle complet du matériel (exigences officielles Windows 11)
  * Windows 11 24H2 -> 25H2     : mise à jour par "enablement package" (petite), contrôles d'état système
  * Windows 11 (autre) -> 25H2  : mise à jour de fonctionnalités complète

Exigences officielles Windows 11 (Microsoft) :
  CPU 64 bits >= 1 GHz, >= 2 coeurs, processeur de la liste compatible (+ SSE4.2/POPCNT pour 24H2+)
  RAM >= 4 Go | Stockage >= 64 Go | UEFI + Secure Boot (capable) | TPM 2.0
  GPU DirectX 12 / pilote WDDM 2.0 | Écran > 9" HD 720p | Connexion Internet (+ compte Microsoft pour Home)
  Mise à niveau depuis Windows 10 : version 2004 (build 19041) ou ultérieure

Utilisation (idéalement dans un terminal/session lancée en ADMINISTRATEUR) :
  python win_upgrade_check.py
  python win_upgrade_check.py --medulla --collect --output-dir C:\\Temp
  python win_upgrade_check.py --full --deep --skip-dxdiag

Code de sortie : 0 = OK, 1 = avertissements, 2 = au moins un blocage (utile pour un déploiement automatisé).
Python 3.8+, bibliothèque standard uniquement. Windows uniquement.
"""

import argparse
import ctypes
import datetime
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time

try:
    import winreg
except ImportError:  # pas sous Windows
    winreg = None

# --------------------------------------------------------------------------- #
# Seuils (ajustables)
# --------------------------------------------------------------------------- #
GIB = 1024 ** 3
MIN_RAM_GIB = 4
MIN_TOTAL_DISK_GIB = 64          # capacité minimale officielle Windows 11
MIN_CORES = 2
MIN_CPU_MHZ = 1000
MIN_WIN10_BUILD = 19041          # Windows 10 2004
MIN_RES = (1280, 720)

# Espace libre : marges pratiques (Microsoft indique ~20 Go pour un upgrade OS complet)
DISK_FREE_MIN = {"upgrade": 20, "enablement": 10}   # Go -> en dessous : FAIL
DISK_FREE_REC = {"upgrade": 30, "enablement": 15}   # Go -> en dessous : WARN
LAST_UPDATE_MAX_DAYS = 90

WIN11_BUILDS = {22000: "21H2", 22621: "22H2", 22631: "23H2", 26100: "24H2", 26200: "25H2"}

# --------------------------------------------------------------------------- #
# Sortie / résultats
# --------------------------------------------------------------------------- #
USE_COLOR = True
COLORS = {"PASS": "\033[92m", "INFO": "\033[96m", "WARN": "\033[93m", "FAIL": "\033[91m"}
RESET = "\033[0m"
RESULTS = []


def add(category, name, status, detail, hint=""):
    RESULTS.append({"category": category, "check": name, "status": status,
                    "detail": detail, "hint": hint})
    tag = f"[{status:<4}]"
    if USE_COLOR:
        tag = f"{COLORS[status]}{tag}{RESET}"
    print(f"{tag} {name:<30} {detail}")
    if hint and status in ("WARN", "FAIL"):
        print(f"       -> {hint}")


def section(title):
    print(f"\n=== {title} ===")


# --------------------------------------------------------------------------- #
# Utilitaires système
# --------------------------------------------------------------------------- #
def is_admin():
    try:
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def run(cmd, timeout=120):
    """Exécute une commande (liste ou chaîne). Retourne (code, sortie)."""
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, encoding="oem",
                           errors="replace", timeout=timeout,
                           shell=isinstance(cmd, str))
        return p.returncode, (p.stdout or "").strip() + (("\n" + p.stderr.strip()) if p.stderr.strip() else "")
    except (OSError, subprocess.TimeoutExpired) as e:
        return -1, str(e)


def ps(script, timeout=120):
    """Exécute du PowerShell (sortie en UTF-8). Retourne (code, stdout)."""
    full = ("[Console]::OutputEncoding=[Text.Encoding]::UTF8; "
            "$ProgressPreference='SilentlyContinue'; " + script)
    try:
        p = subprocess.run(["powershell", "-NoProfile", "-NonInteractive",
                            "-ExecutionPolicy", "Bypass", "-Command", full],
                           capture_output=True, timeout=timeout)
        return p.returncode, p.stdout.decode("utf-8", "replace").strip()
    except (OSError, subprocess.TimeoutExpired) as e:
        return -1, str(e)


def ps_json(script, timeout=120):
    rc, out = ps(script + " | ConvertTo-Json -Depth 4 -Compress", timeout)
    if rc != 0 or not out:
        return None
    try:
        return json.loads(out)
    except ValueError:
        return None


def as_list(x):
    if x is None:
        return []
    return x if isinstance(x, list) else [x]


def reg_read(path, name, hive=None):
    if winreg is None:
        return None
    try:
        with winreg.OpenKey(hive or winreg.HKEY_LOCAL_MACHINE, path, 0,
                            winreg.KEY_READ | winreg.KEY_WOW64_64KEY) as k:
            return winreg.QueryValueEx(k, name)[0]
    except OSError:
        return None


def reg_key_exists(path):
    if winreg is None:
        return False
    try:
        with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0,
                            winreg.KEY_READ | winreg.KEY_WOW64_64KEY):
            return True
    except OSError:
        return False


# --------------------------------------------------------------------------- #
# Informations OS
# --------------------------------------------------------------------------- #
def get_os_info():
    cv = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"
    build = int(reg_read(cv, "CurrentBuildNumber") or 0)
    name = reg_read(cv, "ProductName") or "Windows"
    major = 11 if build >= 22000 else 10
    if major == 11:
        name = name.replace("Windows 10", "Windows 11")
    return {
        "name": name,
        "major": major,
        "build": build,
        "ubr": reg_read(cv, "UBR"),
        "display_version": reg_read(cv, "DisplayVersion") or reg_read(cv, "ReleaseId") or "?",
        "edition": reg_read(cv, "EditionID") or "?",
        "arch": (os.environ.get("PROCESSOR_ARCHITEW6432")
                 or os.environ.get("PROCESSOR_ARCHITECTURE") or "?").upper(),
    }


# --------------------------------------------------------------------------- #
# Contrôles : système d'exploitation
# --------------------------------------------------------------------------- #
def check_os(info):
    section("Système d'exploitation")
    ubr = f".{info['ubr']}" if info["ubr"] is not None else ""
    add("os", "Version actuelle", "INFO",
        f"{info['name']} {info['display_version']} (build {info['build']}{ubr}), édition {info['edition']}")

    if info["arch"] in ("AMD64", "ARM64"):
        add("os", "Architecture 64 bits", "PASS", info["arch"])
    else:
        add("os", "Architecture 64 bits", "FAIL", info["arch"],
            "Windows 11 n'existe qu'en 64 bits (x64 / ARM64).")

    if info["major"] == 10:
        if info["build"] >= MIN_WIN10_BUILD:
            add("os", "Windows 10 >= 2004", "PASS", f"build {info['build']}")
        else:
            add("os", "Windows 10 >= 2004", "FAIL", f"build {info['build']} < {MIN_WIN10_BUILD}",
                "Passer d'abord Windows 10 en 22H2 (build 19045) avant la mise à niveau.")
        add("os", "Support Windows 10", "INFO",
            "Windows 10 est hors support standard depuis le 14/10/2025 (ESU éventuel) : migration recommandée.")
    else:
        label = WIN11_BUILDS.get(info["build"], "?")
        if info["build"] >= 26200:
            add("os", "Cible 25H2", "INFO", "Déjà en 25H2 (ou version ultérieure).")
        elif info["build"] == 26100:
            add("os", "Cible 25H2", "INFO",
                "24H2 -> 25H2 : mise à jour par enablement package (petit paquet, "
                "nécessite 24H2 avec les derniers cumulatifs installés).")
        else:
            add("os", "Cible 25H2", "INFO",
                f"Version {label} : mise à jour de fonctionnalités complète vers 25H2 "
                "(plus longue, plus d'espace disque nécessaire).")


# --------------------------------------------------------------------------- #
# Contrôles : matériel (exigences Windows 11)
# --------------------------------------------------------------------------- #
def get_ram_gib():
    kb = ctypes.c_ulonglong(0)
    try:
        if ctypes.windll.kernel32.GetPhysicallyInstalledSystemMemory(ctypes.byref(kb)):
            return kb.value / 1024 / 1024
    except Exception:
        pass

    class MEMSTATUS(ctypes.Structure):
        _fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
                    ("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
                    ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
                    ("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
                    ("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
    st = MEMSTATUS()
    st.dwLength = ctypes.sizeof(MEMSTATUS)
    ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(st))
    return st.ullTotalPhys / GIB


def cpu_generation_hint(name):
    """Heuristique grossière : la liste officielle des CPU reste la référence."""
    m = re.search(r"i[3579]-(\d{4,5})", name)
    if m:
        gen = int(m.group(1)) // 1000
        if gen < 8:
            return "WARN", f"Intel Core génération {gen} : probablement NON supporté (8e gén. minimum, quelques exceptions)."
        return "PASS", f"Intel Core génération {gen} : probablement compatible."
    m = re.search(r"Ryzen\s+\d\s+(\d)\d{3}", name)
    if m:
        if int(m.group(1)) < 2:
            return "WARN", "AMD Ryzen série 1000 (Zen 1) : NON supporté."
        return "PASS", "AMD Ryzen série 2000+ : probablement compatible."
    return "INFO", "Génération non déterminée automatiquement."


def check_cpu_ram():
    section("Processeur et mémoire")
    procs = as_list(ps_json("Get-CimInstance Win32_Processor | "
                            "Select-Object Name,NumberOfCores,MaxClockSpeed"))
    name = (procs[0].get("Name") or "?").strip() if procs else (
        reg_read(r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", "ProcessorNameString") or "?")
    cores = sum(int(p.get("NumberOfCores") or 0) for p in procs) or (os.cpu_count() or 0)
    mhz = max((int(p.get("MaxClockSpeed") or 0) for p in procs), default=0)

    add("hardware", "Processeur", "INFO", name)
    add("hardware", f"Coeurs (>= {MIN_CORES})", "PASS" if cores >= MIN_CORES else "FAIL", str(cores),
        "Windows 11 exige 2 coeurs physiques ou plus.")
    add("hardware", f"Fréquence (>= {MIN_CPU_MHZ} MHz)", "PASS" if mhz >= MIN_CPU_MHZ else "FAIL", f"{mhz} MHz")

    if (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).upper() == "AMD64":
        try:
            sse42 = bool(ctypes.windll.kernel32.IsProcessorFeaturePresent(38))  # PF_SSE4_2
        except Exception:
            sse42 = None
        if sse42 is None:
            add("hardware", "SSE4.2 / POPCNT (24H2+)", "WARN", "indéterminé")
        else:
            add("hardware", "SSE4.2 / POPCNT (24H2+)", "PASS" if sse42 else "FAIL",
                "présent" if sse42 else "absent",
                "Windows 11 24H2 et suivants exigent SSE4.2 et POPCNT.")
    status, msg = cpu_generation_hint(name)
    add("hardware", "CPU sur liste officielle", status, msg,
        "Vérifier la liste officielle Microsoft des processeurs pris en charge (ou PC Health Check).")

    ram = get_ram_gib()
    add("hardware", f"RAM (>= {MIN_RAM_GIB} Go)", "PASS" if ram >= MIN_RAM_GIB - 0.05 else "FAIL",
        f"{ram:.1f} Go", "Windows 11 exige 4 Go minimum.")


def check_firmware_tpm():
    section("Firmware, Secure Boot, TPM")
    admin = is_admin()

    fw = reg_read(r"SYSTEM\CurrentControlSet\Control", "PEFirmwareType")  # 1 = BIOS, 2 = UEFI
    if fw == 2:
        add("hardware", "Firmware UEFI", "PASS", "UEFI")
    elif fw == 1:
        add("hardware", "Firmware UEFI", "FAIL", "BIOS Legacy (CSM)",
            "UEFI requis : convertir le disque en GPT (mbr2gpt /validate /allowFullOS) puis basculer le BIOS en UEFI.")
    else:
        add("hardware", "Firmware UEFI", "WARN", "indéterminé")

    sb = reg_read(r"SYSTEM\CurrentControlSet\Control\SecureBoot\State", "UEFISecureBootEnabled")
    if sb == 1:
        add("hardware", "Secure Boot", "PASS", "activé")
    elif fw == 2:
        add("hardware", "Secure Boot", "WARN", "désactivé ou non détecté",
            "Windows 11 exige un système compatible Secure Boot : l'activer dans le BIOS/UEFI.")
    else:
        add("hardware", "Secure Boot", "FAIL", "non disponible (BIOS Legacy)")

    rc, out = ps("(Get-Partition -DriveLetter ($env:SystemDrive.TrimEnd(':')) | Get-Disk).PartitionStyle.ToString()")
    if rc == 0 and out:
        add("hardware", "Style de partition (système)", "PASS" if out == "GPT" else "WARN", out,
            "GPT requis en UEFI : mbr2gpt /validate /allowFullOS puis mbr2gpt /convert /allowFullOS.")

    tpm = ps_json(r"Get-CimInstance -Namespace root\cimv2\security\microsofttpm -ClassName Win32_Tpm "
                  r"-ErrorAction SilentlyContinue | Select-Object IsEnabled_InitialValue,"
                  r"IsActivated_InitialValue,SpecVersion,ManufacturerVersion")
    tpm = as_list(tpm)
    if not tpm:
        if admin:
            add("hardware", "TPM 2.0", "FAIL", "aucun TPM détecté ou désactivé dans le BIOS",
                "Activer le TPM (Intel PTT / AMD fTPM) dans le BIOS/UEFI.")
        else:
            add("hardware", "TPM 2.0", "WARN", "non déterminé sans droits administrateur",
                "Relancer le script en administrateur.")
    else:
        t = tpm[0]
        spec = str(t.get("SpecVersion") or "")
        major = spec.split(",")[0].strip()
        enabled, active = t.get("IsEnabled_InitialValue"), t.get("IsActivated_InitialValue")
        detail = f"spec {spec or '?'} | activé={enabled} | actif={active}"
        if major != "2.0":
            add("hardware", "TPM 2.0", "FAIL", detail, "TPM 2.0 requis (TPM 1.2 insuffisant).")
        elif not (enabled and active):
            add("hardware", "TPM 2.0", "WARN", detail, "TPM 2.0 présent mais pas activé : l'activer dans le BIOS.")
        else:
            add("hardware", "TPM 2.0", "PASS", detail)


def check_graphics_display(skip_dxdiag):
    section("Graphique et affichage")
    gpus = as_list(ps_json("Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion"))
    if gpus:
        add("hardware", "Carte graphique", "INFO",
            ", ".join(g.get("Name", "?") for g in gpus))

    if skip_dxdiag:
        add("hardware", "DirectX 12 / WDDM 2.0", "INFO", "ignoré (--skip-dxdiag)")
    else:
        path = os.path.join(tempfile.gettempdir(), f"dxdiag_{os.getpid()}.txt")
        try:
            os.remove(path)
        except OSError:
            pass
        try:
            subprocess.Popen(["dxdiag", "/whql:off", "/t", path])
            deadline, last, done = time.time() + 90, -1, False
            while time.time() < deadline:
                time.sleep(2)
                if os.path.exists(path):
                    size = os.path.getsize(path)
                    if size > 0 and size == last:
                        done = True
                        break
                    last = size
            if not done:
                add("hardware", "DirectX 12 / WDDM 2.0", "WARN", "dxdiag a dépassé le délai (90 s)")
            else:
                raw = open(path, "rb").read()
                txt = raw.decode("utf-16", "replace") if raw[:2] in (b"\xff\xfe", b"\xfe\xff") \
                    else raw.decode("mbcs", "replace")
                wddm = [tuple(int(x) for x in m.split(".")) for m in re.findall(r"WDDM\s+(\d+\.\d+)", txt)]
                ddi12 = any(int(v) >= 12 for v in re.findall(r"DDI[^:\n]*:\s*(\d+)", txt))
                fl12 = bool(re.search(r"\b12_[0-2]\b", txt))
                best = max(wddm) if wddm else None
                detail = f"WDDM {best[0]}.{best[1]}" if best else "WDDM inconnu"
                detail += f" | DirectX 12 : {'oui' if (ddi12 or fl12) else 'non détecté'}"
                if best and best >= (2, 0) and (ddi12 or fl12):
                    add("hardware", "DirectX 12 / WDDM 2.0", "PASS", detail)
                else:
                    add("hardware", "DirectX 12 / WDDM 2.0", "WARN", detail,
                        "Mettre à jour le pilote graphique ; DirectX 12 et WDDM 2.0 sont requis.")
        except OSError as e:
            add("hardware", "DirectX 12 / WDDM 2.0", "WARN", f"dxdiag indisponible : {e}")
        finally:
            try:
                os.remove(path)
            except OSError:
                pass

    try:
        ctypes.windll.user32.SetProcessDPIAware()
        w, h = ctypes.windll.user32.GetSystemMetrics(0), ctypes.windll.user32.GetSystemMetrics(1)
    except Exception:
        w = h = 0
    if w == 0:
        add("hardware", "Résolution écran (>= 720p)", "INFO", "non mesurable (session sans affichage)")
    else:
        ok = w >= MIN_RES[0] and h >= MIN_RES[1]
        add("hardware", "Résolution écran (>= 720p)", "PASS" if ok else "WARN", f"{w}x{h}",
            "Écran HD (720p) minimum requis.")


# --------------------------------------------------------------------------- #
# Contrôles : état du système (tous scénarios)
# --------------------------------------------------------------------------- #
def check_disks(scenario_key, total_required):
    section("Espace disque")
    drive = os.environ.get("SystemDrive", "C:") + "\\"
    usage = shutil.disk_usage(drive)
    free_gib, total_gib = usage.free / GIB, usage.total / GIB

    if total_required:
        add("disk", f"Capacité {drive} (>= {MIN_TOTAL_DISK_GIB} Go)",
            "PASS" if total_gib >= MIN_TOTAL_DISK_GIB else "FAIL", f"{total_gib:.0f} Go",
            "Windows 11 exige 64 Go de stockage minimum.")
    fmin, frec = DISK_FREE_MIN[scenario_key], DISK_FREE_REC[scenario_key]
    if free_gib < fmin:
        st, hint = "FAIL", f"Libérer de l'espace (minimum {fmin} Go, {frec} Go recommandés)."
    elif free_gib < frec:
        st, hint = "WARN", f"{frec} Go recommandés pour une marge confortable."
    else:
        st, hint = "PASS", ""
    add("disk", f"Espace libre {drive}", st, f"{free_gib:.1f} Go libres / {total_gib:.0f} Go", hint)

    disks = as_list(ps_json("Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | "
                            "Select-Object DeviceID,FreeSpace,Size"))
    for d in disks:
        try:
            add("disk", f"Volume {d['DeviceID']}", "INFO",
                f"{int(d['FreeSpace']) / GIB:.1f} Go libres / {int(d['Size']) / GIB:.0f} Go")
        except (KeyError, TypeError, ValueError):
            pass


def check_pending_reboot():
    section("État du système")
    blocking = []
    if reg_key_exists(r"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"):
        blocking.append("CBS RebootPending")
    if reg_key_exists(r"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"):
        blocking.append("WindowsUpdate RebootRequired")
    pfro = reg_read(r"SYSTEM\CurrentControlSet\Control\Session Manager", "PendingFileRenameOperations")

    if blocking:
        add("system", "Redémarrage en attente", "FAIL", ", ".join(blocking),
            "Redémarrer la machine avant de lancer la mise à jour majeure.")
    elif pfro:
        # Signal faible : souvent laissé par des installeurs/antivirus, sans blocage réel
        entries = [e.replace("\\??\\", "") for e in pfro if e]
        sample = "; ".join(entries[:4]) + (" ..." if len(entries) > 4 else "")
        add("system", "Redémarrage en attente", "WARN",
            f"PendingFileRenameOperations ({len(entries)} entrée(s)) : {sample}",
            "Redémarrer si possible. Si la clé se repeuple après redémarrage, "
            "un logiciel tiers (antivirus, agent) la recrée : généralement sans impact bloquant.")
    else:
        add("system", "Redémarrage en attente", "PASS", "aucun")


def check_update_services():
    svcs = as_list(ps_json(
        "Get-Service -Name wuauserv,bits,cryptsvc,TrustedInstaller -ErrorAction SilentlyContinue | "
        "Select-Object Name,@{n='Status';e={$_.Status.ToString()}},@{n='StartType';e={$_.StartType.ToString()}}"))
    if not svcs:
        add("system", "Services Windows Update", "WARN", "impossible de lire l'état des services")
        return
    disabled = [s["Name"] for s in svcs if s.get("StartType") == "Disabled"]
    detail = ", ".join(f"{s['Name']}={s['Status']}/{s['StartType']}" for s in svcs)
    if disabled:
        add("system", "Services Windows Update", "FAIL", detail,
            f"Service(s) désactivé(s) : {', '.join(disabled)}. Les remettre en Manuel/Automatique.")
    else:
        add("system", "Services Windows Update", "PASS", detail)


def check_component_store(deep):
    if not is_admin():
        add("system", "Intégrité magasin de composants", "WARN", "droits administrateur requis (DISM)")
        return
    arg = "/ScanHealth" if deep else "/CheckHealth"
    rc, out = run(["DISM", "/Online", "/Cleanup-Image", arg], timeout=1800 if deep else 300)
    if re.search(r"repairable|réparable", out, re.I) and not re.search(r"no component store corruption|aucune alt", out, re.I):
        add("system", f"DISM {arg}", "FAIL", "magasin de composants endommagé",
            "Exécuter : DISM /Online /Cleanup-Image /RestoreHealth puis sfc /scannow.")
    elif re.search(r"no component store corruption|aucune alt", out, re.I):
        add("system", f"DISM {arg}", "PASS", "aucune corruption détectée")
    else:
        add("system", f"DISM {arg}", "WARN", f"résultat non interprété (code {rc})")


def check_last_updates():
    hf = as_list(ps_json(
        "Get-HotFix -ErrorAction SilentlyContinue | Where-Object InstalledOn | "
        "Sort-Object InstalledOn -Descending | Select-Object -First 5 HotFixID,"
        "@{n='On';e={$_.InstalledOn.ToString('yyyy-MM-dd')}}", timeout=180))
    if not hf:
        add("system", "Dernières mises à jour", "WARN", "aucun correctif lisible")
        return
    last = hf[0]
    try:
        age = (datetime.date.today() - datetime.datetime.strptime(last["On"], "%Y-%m-%d").date()).days
    except (KeyError, ValueError):
        age = None
    detail = ", ".join(f"{h.get('HotFixID')} ({h.get('On')})" for h in hf[:3])
    if age is not None and age > LAST_UPDATE_MAX_DAYS:
        add("system", "Dernières mises à jour", "WARN", f"{detail} - dernier correctif il y a {age} j",
            "Installer les derniers cumulatifs avant la mise à jour majeure.")
    else:
        add("system", "Dernières mises à jour", "PASS", detail)


def check_misc(info):
    try:
        socket.create_connection(("www.microsoft.com", 443), timeout=5).close()
        add("system", "Connexion Internet", "PASS", "www.microsoft.com:443 joignable")
    except OSError:
        add("system", "Connexion Internet", "WARN", "www.microsoft.com injoignable",
            "Vérifier proxy/pare-feu ; WSUS/Medulla peut être utilisé selon votre infrastructure.")
    if info["edition"].lower() in ("core", "corecountryspecific", "coresinglelanguage"):
        add("system", "Édition Home", "INFO",
            "Windows 11 Home exige une connexion Internet et un compte Microsoft à l'installation.")


# --------------------------------------------------------------------------- #
# Spécifique Medulla / Pulse
# --------------------------------------------------------------------------- #
def check_medulla():
    section("Medulla / Pulse (état avant mise à jour)")
    svcs = as_list(ps_json(
        "Get-Service | Where-Object { $_.Name -match 'medullaagent' -or $_.DisplayName -match 'medullaagent' } | "
        "Select-Object Name,@{n='Status';e={$_.Status.ToString()}}"))
    if svcs:
        stopped = [s["Name"] for s in svcs if s.get("Status") != "Running"]
        detail = ", ".join(f"{s['Name']}={s['Status']}" for s in svcs)
        add("medulla", "Services Medulla", "WARN" if stopped else "PASS", detail,
            "Certains services de l'agent ne tournent pas." if stopped else "")
    else:
        add("medulla", "Services Medulla", "INFO", "aucun service pulse/medulla détecté")

    procs = as_list(ps_json("Get-Process | Where-Object { $_.ProcessName -match 'pulse|medulla' } | "
                            "Select-Object ProcessName,Id"))
    add("medulla", "Processus Medulla", "INFO",
        ", ".join(f"{p['ProcessName']}({p['Id']})" for p in procs) if procs else "aucun")

    found = None
    for path in (r"HKLM\SOFTWARE\Pulse", r"HKLM\SOFTWARE\WOW6432Node\Pulse"):
        rc, out = run(["reg", "query", path, "/s"])
        if rc == 0:
            found = (path, len(out.splitlines()))
            break
    add("medulla", "Clés de registre Pulse", "INFO",
        f"{found[0]} ({found[1]} lignes)" if found else "HKLM\\SOFTWARE\\Pulse introuvable")

    rules = as_list(ps_json("Get-NetFirewallRule -ErrorAction SilentlyContinue | "
                            "Where-Object { $_.DisplayName -match 'pulse|medulla' } | Select-Object DisplayName"))
    add("medulla", "Règles pare-feu Pulse", "INFO", f"{len(rules)} règle(s)")


# --------------------------------------------------------------------------- #
# Collecte de diagnostic brut (équivalent de la liste wmic, en PowerShell/CIM)
# --------------------------------------------------------------------------- #
def diag_commands(with_medulla):
    tbl = " | Format-Table -AutoSize | Out-String -Width 250"
    cmds = {
        "system": [
            ("System Info", "cmd", ["systeminfo"]),
            ("Disk Space", "ps", "Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID,VolumeName,"
                                 "@{n='FreeGB';e={[math]::Round($_.FreeSpace/1GB,1)}},"
                                 "@{n='SizeGB';e={[math]::Round($_.Size/1GB,1)}}" + tbl),
            ("Installed Services", "ps", "Get-CimInstance Win32_Service | Select-Object Name,State,StartMode,StartName" + tbl),
            ("Processes", "cmd", ["tasklist", "/v"]),
            ("Windows Updates", "ps", "Get-HotFix | Sort-Object InstalledOn -Descending | "
                                      "Select-Object HotFixID,Description,InstalledOn,InstalledBy" + tbl),
            ("EventLog Errors", "ps", "Get-WinEvent -FilterHashtable @{LogName='System';Level=1,2} -MaxEvents 10 "
                                      "-ErrorAction SilentlyContinue | Format-List TimeCreated,Id,ProviderName,Message "
                                      "| Out-String -Width 250"),
        ]
    }
    if with_medulla:
        cmds["medulla_specific"] = [
            ("Medulla Services", "ps", "Get-Service | Where-Object { $_.Name -match 'pulse' -or "
                                       "$_.DisplayName -match 'pulse' } | Format-Table -AutoSize | Out-String -Width 250"),
            ("Medulla Processes", "ps", "Get-Process | Where-Object { $_.ProcessName -match 'pulse' } | "
                                        "Format-Table -AutoSize | Out-String -Width 250"),
            ("Medulla Registry", "cmd", ["reg", "query", r"HKLM\SOFTWARE\Pulse", "/s"]),
            ("Medulla Firewall Rules", "ps", "Get-NetFirewallRule | Where-Object { $_.DisplayName -match 'pulse' } | "
                                             "Format-Table DisplayName,Enabled,Direction,Action -AutoSize | Out-String -Width 250"),
        ]
    return cmds


def collect_diagnostics(path, with_medulla):
    print(f"\nCollecte des diagnostics détaillés -> {path}")
    with open(path, "w", encoding="utf-8") as f:
        for group, items in diag_commands(with_medulla).items():
            for title, kind, cmd in items:
                print(f"  - {title}")
                rc, out = ps(cmd, 300) if kind == "ps" else run(cmd, 300)
                f.write(f"\n{'=' * 80}\n[{group}] {title}\n{'=' * 80}\n{out}\n")


# --------------------------------------------------------------------------- #
# Programme principal
# --------------------------------------------------------------------------- #
ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")


class Tee:
    """Duplique tout ce qui est affiché dans un buffer (sans codes couleur) pour le rapport .txt."""

    def __init__(self, stream):
        self.stream = stream
        self.buffer = []

    def write(self, s):
        self.stream.write(s)
        self.buffer.append(ANSI_RE.sub("", s))
        return len(s)

    def flush(self):
        self.stream.flush()

    def __getattr__(self, name):
        return getattr(self.stream, name)


def main():
    global USE_COLOR
    parser = argparse.ArgumentParser(description="Vérification des prérequis Windows avant mise à jour majeure.")
    parser.add_argument("--full", action="store_true", help="forcer les contrôles matériels Windows 11 même sous Windows 11")
    parser.add_argument("--deep", action="store_true", help="DISM /ScanHealth (long) au lieu de /CheckHealth")
    parser.add_argument("--skip-dxdiag", action="store_true", help="ne pas lancer dxdiag (DirectX 12/WDDM)")
    parser.add_argument("--medulla", action="store_true", help="relever l'état de l'agent Medulla/Pulse")
    parser.add_argument("--collect", action="store_true", help="collecter les diagnostics bruts (systeminfo, services, etc.)")
    parser.add_argument("--output-dir", default=".", help="dossier des rapports (défaut : dossier courant)")
    parser.add_argument("--no-report", action="store_true", help="ne pas écrire le rapport .txt")
    parser.add_argument("--no-color", action="store_true", help="désactiver les couleurs")
    args = parser.parse_args()

    if os.name != "nt":
        print("Ce script doit être exécuté sous Windows.")
        return 2
    USE_COLOR = not args.no_color and sys.stdout.isatty()
    if USE_COLOR:
        os.system("")  # active les séquences ANSI dans la console Windows
    try:
        sys.stdout.reconfigure(errors="replace")
    except Exception:
        pass
    tee = Tee(sys.stdout)
    sys.stdout = tee

    host = os.environ.get("COMPUTERNAME", "unknown")
    now = datetime.datetime.now()
    print(f"Contrôle pré-mise à jour Windows - {host} - {now:%Y-%m-%d %H:%M:%S}")
    if not is_admin():
        print("ATTENTION : session non administrateur - TPM et DISM ne pourront pas être contrôlés.")

    info = get_os_info()
    check_os(info)

    full_hw = info["major"] == 10 or args.full
    enablement = info["major"] == 11 and info["build"] == 26100
    scenario_key = "enablement" if enablement and not args.full else "upgrade"

    if full_hw:
        check_cpu_ram()
        check_firmware_tpm()
        check_graphics_display(args.skip_dxdiag)
    else:
        print("\n(Windows 11 déjà installé : contrôles matériels ignorés, utiliser --full pour les forcer.)")

    check_disks(scenario_key, total_required=full_hw)
    check_pending_reboot()
    check_update_services()
    check_component_store(args.deep)
    check_last_updates()
    check_misc(info)
    if args.medulla:
        check_medulla()

    counts = {k: sum(1 for r in RESULTS if r["status"] == k) for k in ("PASS", "INFO", "WARN", "FAIL")}
    if counts["FAIL"]:
        verdict, code = "NON PRÊT - corriger les blocages (FAIL) avant la mise à jour", 2
    elif counts["WARN"]:
        verdict, code = "PRÊT SOUS RÉSERVE - vérifier les avertissements (WARN)", 1
    else:
        verdict, code = "PRÊT pour la mise à jour majeure", 0

    print(f"\n{'=' * 60}\nRésumé : {counts['PASS']} OK | {counts['WARN']} avertissement(s) | "
          f"{counts['FAIL']} blocage(s) | {counts['INFO']} info(s)\nVerdict : {verdict}\n{'=' * 60}")

    stamp = f"{host}_{now:%Y%m%d_%H%M%S}"
    os.makedirs(args.output_dir, exist_ok=True)
    if args.collect:
        collect_diagnostics(os.path.join(args.output_dir, f"upgrade_diag_{stamp}.txt"), args.medulla)
    if not args.no_report:
        report = os.path.join(args.output_dir, f"upgrade_check_{stamp}.txt")
        print(f"Rapport TXT : {report}")
        # utf-8-sig : accents correctement affichés dans le Bloc-notes
        with open(report, "w", encoding="utf-8-sig") as f:
            f.write("".join(tee.buffer))
    return code


if __name__ == "__main__":
    sys.exit(main())
