EoL-Functions.ps1

# ============================================================================
# MODULE DE GESTION END-OF-LIFE (EoL)
# ============================================================================
# Gestion des données EoL via fichier JSON externe et API endoflife.date
# ============================================================================

#region Configuration
$script:EoLJsonFileName = "EoL-Database.json"
$script:EoLApiBaseUrl = "https://endoflife.date/api"
$script:EoLDatabaseCache = $null   # Cache en mémoire — évite les lectures disque répétées
#endregion

#region Fonctions utilitaires

<#
.SYNOPSIS
Obtient le chemin du fichier JSON EoL.

.DESCRIPTION
Recherche le fichier eol-database.json dans plusieurs emplacements par ordre de priorité.

.OUTPUTS
String - Chemin complet vers le fichier JSON
#>

function Get-EoLDatabasePath {
    [CmdletBinding()]
    param()
    
    # 1. À côté du script (priorité)
    $scriptPath = Join-Path $PSScriptRoot $script:EoLJsonFileName
    if (Test-Path $scriptPath) {
        return $scriptPath
    }
    
    # 2. Dans le répertoire courant
    $currentPath = Join-Path (Get-Location) $script:EoLJsonFileName
    if (Test-Path $currentPath) {
        return $currentPath
    }
    
    # 3. Créer à côté du script si n'existe pas
    Write-Warning "Fichier eol-database.json introuvable. Il sera créé lors du prochain Update-EoLFromAPI à: $scriptPath"
    return $scriptPath
}

<#
.SYNOPSIS
Charge la base de données EoL depuis le fichier JSON.

.DESCRIPTION
Lit et parse le fichier JSON contenant les données End-of-Life des systèmes d'exploitation.

.OUTPUTS
PSCustomObject - Objet contenant toute la base de données EoL
#>

function Get-EoLDatabase {
    [CmdletBinding()]
    param(
        [switch]$ForceReload  # Force la relecture disque (à utiliser après Update-EoLFromAPI)
    )
    
    # Retourner le cache si disponible et rechargement non forcé
    if ($script:EoLDatabaseCache -and -not $ForceReload) {
        Write-Verbose "Base de données EoL retournée depuis le cache mémoire"
        return $script:EoLDatabaseCache
    }
    
    try {
        $jsonPath = Get-EoLDatabasePath
        
        if (-not (Test-Path $jsonPath)) {
            Write-Warning "Fichier JSON EoL introuvable: $jsonPath"
            Write-Host "Créez le fichier ou utilisez: Update-EoLFromAPI" -ForegroundColor Yellow
            return $null
        }
        
        $jsonContent = Get-Content -Path $jsonPath -Raw -Encoding UTF8 -ErrorAction Stop
        $database = $jsonContent | ConvertFrom-Json -ErrorAction Stop
        
        # Mettre en cache pour les appels suivants
        $script:EoLDatabaseCache = $database
        
        Write-Verbose "Base de données EoL chargée depuis: $jsonPath"
        Write-Verbose "Dernière mise à jour: $($database.metadata.last_updated)"
        Write-Verbose "Nombre d'entrées: $($database.os_database.PSObject.Properties.Count)"
        
        return $database
        
    } catch {
        Write-Error "Erreur lors du chargement de la base EoL: $($_.Exception.Message)"
        return $null
    }
}

<#
.SYNOPSIS
Invalide le cache mémoire de la base EoL.

.DESCRIPTION
Force le prochain appel à Get-EoLDatabase à relire le fichier JSON depuis le disque.
À appeler après toute modification de la base (Update-EoLFromAPI, ajout manuel).
#>

function Clear-EoLDatabaseCache {
    [CmdletBinding()]
    param()
    $script:EoLDatabaseCache = $null
    Write-Verbose "Cache de la base EoL invalidé"
}

<#
.SYNOPSIS
Recherche les informations EoL pour un système d'exploitation donné.

.DESCRIPTION
Interroge la base de données JSON pour trouver les dates EoL d'un OS spécifique.

.PARAMETER OSName
Nom du système d'exploitation tel qu'il apparaît dans AD (ex: "Windows Server 2022")

.OUTPUTS
PSCustomObject - Informations EoL de l'OS ou $null si non trouvé
#>

function Get-OSEoLInfo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$OSName
    )
    
    $database = Get-EoLDatabase
    if (-not $database) {
        return $null
    }
    
    $dbKeys = $database.os_database.PSObject.Properties.Name
    
    # 1. Recherche exacte — mais on ignore les entrees AD-style fantomes (api_cycle=unknown,
    # eol_date=None) creees automatiquement lors de la detection AD sans donnees reelles.
    # Ex: "Windows 11 22H2" → entree vide, la vraie donnee est dans "Windows 11-22h2-w".
    if ($dbKeys -contains $OSName) {
        $candidate = $database.os_database.$OSName
        $isPlaceholder = ($candidate.api_cycle -eq 'unknown' -and $null -eq $candidate.eol_date)
        if (-not $isPlaceholder) {
            return $candidate
        }
        # Entree fantome : on continue vers les vraies entrees
    }
    
    # 2. Normalisation du nom : "Windows 10 22H2" -> "windows 10-22h2"
    # Remplace espace+version(xxHx ou 4 chiffres) par tiret+version en minuscules
    $normalized = $OSName -replace ' ([0-9]{2}[hH][0-9])', '-$1'
    $normalized = $normalized -replace ' ([0-9]{4})', '-$1'
    $normalized = $normalized.ToLower()
    
    # 3. Correspondance exacte normalisee
    foreach ($key in $dbKeys) {
        if ($key.ToLower() -eq $normalized) {
            return $database.os_database.$key
        }
    }
    
    # 3a. Variante suffixe -w (workstation) pour Windows 10/11 client
    # endoflife.date distingue Home/Pro (-w) et Entreprise (-e).
    # On utilise -w comme representant generique des versions AD detectees.
    $normalizedW = "$normalized-w"
    foreach ($key in $dbKeys) {
        if ($key.ToLower() -eq $normalizedW) {
            return $database.os_database.$key
        }
    }
    
    # 3b. Normalisation tiret/espace pour Windows Server legacy (ancienne base avec clés API brutes)
    # L'API endoflife.date retourne "2008-r2-sp1", "2012-r2", "2008-sp2" mais AD retourne
    # "Windows Server 2008 R2", "Windows Server 2012 R2", "Windows Server 2008".
    # Depuis FIX7a, les nouvelles bases stockent les clés au format AD — mais pour les bases
    # déjà existantes avec les anciens noms, on génère les variants directement depuis $OSName.ToLower()
    # (PAS depuis $normalized qui a déjà transformé les espaces et peut casser le matching).
    $osRawLow = $OSName.ToLower()
    $candidatesDash = @(
        ($osRawLow -replace '\s+r2\s*$', '-r2'),
        ($osRawLow -replace '\s+r2\s*$', '-r2-sp1'),
        ($osRawLow -replace '\s+r2\s*$', '-r2-sp2'),
        ($osRawLow -replace '\s+sp\d+\s*$', '')   # enlever suffixe SP si présent
    ) | Select-Object -Unique
    
    foreach ($candidate in $candidatesDash) {
        foreach ($key in $dbKeys) {
            if ($key.ToLower() -eq $candidate) {
                return $database.os_database.$key
            }
        }
    }
    
    # 4. Correspondance par prefixe normalise
    # Ancienne logique (*OSName* OR *key*) trop large : risque de faux positifs
    # Nouvelle logique : le NOM OS doit commencer par la clé (ou vice-versa), pas simplement la contenir
    foreach ($key in $dbKeys) {
        $keyLow = $key.ToLower()
        $osLow  = $OSName.ToLower()
        # La clé doit être un préfixe du nom OS demandé (ex: "ubuntu" dans "ubuntu 22")
        # ou le nom OS un préfixe de la clé — mais jamais une simple sous-chaîne au milieu
        if ($osLow.StartsWith($keyLow) -or $keyLow.StartsWith($osLow)) {
            return $database.os_database.$key
        }
    }
    
    Write-Verbose "OS non trouve dans la base: $OSName"
    return $null
}

<#
.SYNOPSIS
Calcule le statut EoL d'un système d'exploitation.

.DESCRIPTION
Détermine si un OS est supporté, en fin de support bientôt, ou en fin de vie.

.PARAMETER OSName
Nom du système d'exploitation

.PARAMETER DaysBeforeWarning
Nombre de jours avant la fin de support pour déclencher un avertissement (défaut: 90)

.OUTPUTS
String - "Supported", "J-XX", "EOL", ou "Unknown"
#>

function Get-OSEoLStatus {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$OSName,
        
        [Parameter(Mandatory = $false)]
        [int]$DaysBeforeWarning = 90
    )
    
    $osInfo = Get-OSEoLInfo -OSName $OSName
    if (-not $osInfo) {
        return "Unknown"
    }
    
    $today = Get-Date

    # Vérifier le support étendu d'abord (seulement si c'est une date réelle, pas False/N/A)
    $eolDateToCheck = $osInfo.eol_date
    if ($osInfo.extended_support -and
        $osInfo.extended_support -ne $false -and
        $osInfo.extended_support -ne 'False' -and
        $osInfo.extended_support -ne 'N/A') {
        $eolDateToCheck = $osInfo.extended_support
    }

    # Cas explicite : eol_date = False → pas de date annoncée, OS encore supporté
    if (-not $eolDateToCheck -or $eolDateToCheck -eq $false -or
        $eolDateToCheck -eq 'False' -or $eolDateToCheck -eq 'N/A') {
        return "Supported"
    }

    try {
        $eolDate = [DateTime]::Parse($eolDateToCheck)
        $daysUntilEoL = ($eolDate - $today).Days

        if ($daysUntilEoL -lt 0) {
            return "EOL"
        } elseif ($daysUntilEoL -le $DaysBeforeWarning) {
            # Retourner les jours RÉELS restants (pas le seuil du paramètre)
            # Ex: OS expire dans 15j avec $DaysBeforeWarning=90 -> "J-15" et non "J-90"
            return "J-$daysUntilEoL"
        } else {
            return "Supported"
        }

    } catch {
        Write-Warning "Date EoL invalide ou non parseable pour [$OSName] : $eolDateToCheck - statut retourné: Unknown"
        return "Unknown"
    }
}

#endregion

#region Fonctions de mise à jour API

<#
.SYNOPSIS
Appelle l'API endoflife.date pour un produit spécifique.

.DESCRIPTION
Interroge l'API publique endoflife.date pour obtenir les informations de cycle de vie.

.PARAMETER Product
Identifiant du produit dans l'API (ex: "windows", "windows-server", "ubuntu")

.OUTPUTS
Array - Liste des cycles de vie pour le produit
#>

function Invoke-EoLAPI {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Product
    )
    
    try {
        $url = "$script:EoLApiBaseUrl/$Product.json"
        Write-Verbose "Appel API: $url"
        
        $response = Invoke-RestMethod -Uri $url -Method Get -ErrorAction Stop
        return $response
        
    } catch {
        Write-Warning "Echec de l'appel API pour '$Product' : $($_.Exception.Message)"
        return $null
    }
}

<#
.SYNOPSIS
Met à jour la base de données EoL depuis l'API endoflife.date.

.DESCRIPTION
Récupère les dernières informations EoL depuis l'API et met à jour le fichier JSON.

.PARAMETER Products
Liste des produits à mettre à jour (par défaut: windows, windows-server)

.PARAMETER Force
Force la mise à jour même si le fichier existe déjà

.EXAMPLE
Update-EoLFromAPI -Products @("windows", "windows-server")

.EXAMPLE
Update-EoLFromAPI -Force
#>

function Update-EoLFromAPI {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [string[]]$Products = @("windows", "windows-server", "ubuntu", "macos", "debian", "rhel", "sles"),

        [Parameter(Mandatory = $false)]
        [switch]$Force,

        # Reçu depuis Update-ADEoLDatabase (défini au niveau module dans le psm1)
        # Evite toute dépendance au scope — plus besoin de chercher la variable
        [Parameter(Mandatory = $false)]
        [hashtable]$FamilyMap = $null
    )

    # Résolution $OSFamilyMap : paramètre prioritaire, sinon fallback minimal
    if ($FamilyMap -and $FamilyMap.Count -gt 0) {
        $script:OSFamilyMap = $FamilyMap
    } else {
        Write-Warning "OSFamilyMap non recu en parametre - utilisation du fallback minimal"
        $script:OSFamilyMap = @{
            'Windows'        = 'Client'
            'Windows Server' = 'Server'
            'macOS'          = 'Client'
            'Ubuntu'         = 'Server'
            'Debian'         = 'Server'
            'RHEL'           = 'Server'
            'SLES'           = 'Server'
        }
    }

    Write-Host ""
    Write-Host " #=======================================================================" -ForegroundColor DarkCyan
    Write-Host " # MISE A JOUR BASE End-of-Life" -ForegroundColor Cyan
    Write-Host " #=======================================================================" -ForegroundColor DarkCyan
    Write-Host " Source : endoflife.date API" -ForegroundColor Gray
    Write-Host ""

    # Charger la base existante ou créer une nouvelle
    $jsonPath = Get-EoLDatabasePath
    $database = Get-EoLDatabase

    $isNewDatabase = (-not $database)
    if ($isNewDatabase) {
        Write-Host " ⏳ Creation d'une nouvelle base de donnees..." -ForegroundColor Yellow
        # IMPORTANT : PSCustomObject obligatoire (pas @{} hashtable) pour que Add-Member
        # fonctionne correctement et que ConvertTo-Json sérialise toutes les entrées.
        # Avec une hashtable, Add-Member ne modifie pas PSObject.Properties
        # -> os_database resterait vide dans le JSON sauvegardé.
        $database = [PSCustomObject]@{
            metadata = [PSCustomObject]@{
                version         = "1.0"
                last_updated    = (Get-Date -Format "yyyy-MM-dd")
                last_api_update = $null
                source          = "endoflife.date API"
            }
            os_database = [PSCustomObject]@{}
        }
    }

    $updateCount = 0
    $errorCount  = 0

    # ── Recuperation API ────────────────────────────────────────────────────────
    Write-Host " 🌐 RECUPERATION API" -ForegroundColor White
    Write-Host " ─────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

    foreach ($product in $Products) {
        Write-Host " ⏳ " -ForegroundColor Yellow -NoNewline
        Write-Host $product.PadRight(20) -ForegroundColor Gray -NoNewline

        $apiData = Invoke-EoLAPI -Product $product

        if ($apiData) {
            Write-Verbose "API retournee: $($apiData.Count) cycles"
            $count = 0

            $macOSNames = @{ '10.15'='Catalina'; '11'='Big Sur'; '12'='Monterey'; '13'='Ventura'; '14'='Sonoma'; '15'='Sequoia' }

            # ── Déduplication : plusieurs cycles API peuvent se normaliser vers le même osName
            # Exemple : '2003' et '2003-sp1' -> 'Windows Server 2003' avec des dates différentes
            # -> rotation infinie à chaque appel (A détecte changement de B, B détecte changement de A)
            # Solution : pour chaque osName en collision, ne garder que le cycle avec eol_date la plus tardive
            $cycleByName = @{}
            foreach ($c in $apiData) {
                $n = $c.cycle
                if ($product -eq 'windows-server') {
                    $n = $n -replace '-r2-sp\d+$',' R2' -replace '-r2$',' R2' -replace '-sp\d+$','' 
                    $n = "Windows Server $($n.Trim())"
                } elseif ($product -eq 'windows') {
                    $n = "Windows $n"
                } elseif ($product -eq 'macos') {
                    $mn = $macOSNames[$c.cycle]
                    $n  = if ($mn) { "macOS $($c.cycle) $mn" } else { "macOS $($c.cycle)" }
                } else { $n = "$product $n" }

                if ($cycleByName.ContainsKey($n)) {
                    # Garder le cycle avec la eol_date la plus tardive
                    $existing = $cycleByName[$n]
                    $existEol = if ($existing.eol -and $existing.eol -isnot [bool]) { [string]$existing.eol } else { '' }
                    $newEol   = if ($c.eol       -and $c.eol       -isnot [bool]) { [string]$c.eol       } else { '' }
                    if ($newEol -gt $existEol) { $cycleByName[$n] = $c }
                } else {
                    $cycleByName[$n] = $c
                }
            }
            # Remplacer apiData par la liste dédupliquée
            $apiData = $cycleByName.Values

            foreach ($cycle in $apiData) {
                if ($product -eq "windows") {
                    $osName = "Windows $($cycle.cycle)"
                } elseif ($product -eq "windows-server") {
                    # L'API endoflife.date retourne des cycles avec tirets et suffixes SP :
                    # ex: "2008-r2-sp1" → "2012-r2" → mais AD et Normalize-OperatingSystem
                    # produisent "Windows Server 2008 R2" / "Windows Server 2012 R2" (espaces, sans -sp1/sp2).
                    # On normalise ici pour que la clé en base corresponde exactement à OS_Full.
                    $serverCycle = $cycle.cycle
                    $serverCycle = $serverCycle -replace '-r2-sp\d+$', ' R2'   # 2008-r2-sp1 → "2008 R2"
                    $serverCycle = $serverCycle -replace '-r2$',       ' R2'   # 2012-r2 → "2012 R2"
                    $serverCycle = $serverCycle -replace '-sp\d+$',    ''      # 2008-sp2 → "2008"
                    $serverCycle = $serverCycle.Trim()
                    $osName = "Windows Server $serverCycle"
                } elseif ($product -eq "macos") {
                    $macName = $macOSNames[$cycle.cycle]
                    $osName  = if ($macName) { "macOS $($cycle.cycle) $macName" } else { "macOS $($cycle.cycle)" }
                } else {
                    $osName = "$product $($cycle.cycle)"
                }

                $productName = if ($product -eq "windows")        { "Windows" }
                              elseif ($product -eq "windows-server") { "Windows Server" }
                              elseif ($product -eq "macos")          { "macOS" }
                              else { (Get-Culture).TextInfo.ToTitleCase($product) }

                $mapKey = $script:OSFamilyMap.Keys | Where-Object { $_ -ieq $productName } | Select-Object -First 1
                $osType = if ($mapKey) { $script:OSFamilyMap[$mapKey].ToLower() }
                          elseif ($product -eq "windows-server") { "server" }
                          else { "client" }
                # Uniformisation : les distros Linux ont type="linux" dans les stubs PreCheck.
                # OSFamilyMap retourne "server" pour Ubuntu/Debian/RHEL/SLES -> normaliser ici
                # pour garantir la cohérence de la base JSON (même valeur partout pour un même OS).
                $linuxProducts = @("ubuntu","debian","rhel","sles","fedora","centos","alpine","rocky","almalinux")
                if ($linuxProducts -contains $product.ToLower()) { $osType = "linux" }

                # Normaliser les dates en ISO yyyy-MM-dd dès la construction de l'entrée
                # pour éviter que Invoke-RestMethod ne stocke des DateTime locale en DB
                $toIsoRaw = {
                    param($v)
                    if ($null -eq $v -or $v -eq $false -or $v -eq '' -or $v -eq 'False') { return $null }
                    if ($v -is [datetime]) { return $v.ToString('yyyy-MM-dd') }
                    return [string]$v
                }
                $osEntry = @{
                    product_name     = $productName
                    version          = $cycle.cycle
                    type             = $osType
                    eol_date         = (& $toIsoRaw $cycle.eol)
                    extended_support = (& $toIsoRaw $cycle.extendedSupport)
                    api_product      = $product
                    api_cycle        = $cycle.cycle
                }

                $isNew = $false; $hasChanged = $false

                # Chercher l'entrée existante — 3 stratégies par ordre de priorité :
                # 1. Nom exact normalisé (cas nominal)
                # 2. Ancien nom brut API (migration bases pré-FIX7a)
                # 3. Recherche par api_cycle (cas macOS 10.13/10.14 sans nom marketing en DB,
                # ou tout autre cycle dont le nom affiché a changé entre deux versions)
                $existingKey = $null
                if ($database.os_database.PSObject.Properties.Name -contains $osName) {
                    $existingKey = $osName
                } else {
                    # Stratégie 2 : ancien nom brut API
                    $legacyName = "$(if ($product -eq 'windows-server') { 'Windows Server' } else { $product }) $($cycle.cycle)"
                    if ($legacyName -ne $osName -and
                        $database.os_database.PSObject.Properties.Name -contains $legacyName) {
                        $existingKey = $legacyName
                    }
                }
                # Stratégie 3 : recherche par api_product + api_cycle (filet de sécurité)
                if (-not $existingKey) {
                    foreach ($prop in $database.os_database.PSObject.Properties) {
                        if ($prop.Value.api_product -eq $product -and
                            $prop.Value.api_cycle   -eq $cycle.cycle) {
                            $existingKey = $prop.Name
                            break
                        }
                    }
                }

                if ($existingKey) {
                    $oldEntry = $database.os_database.$existingKey
                    # Normalisation ISO avant comparaison :
                    # Invoke-RestMethod peut parser certaines dates en DateTime selon la locale Windows.
                    # [string]DateTime = "10/13/2026 00:00:00" (locale) != "2026-10-13" (ISO DB)
                    # -> on force le format yyyy-MM-dd pour les deux côtés.
                    $toIso = {
                        param($v)
                        if ($null -eq $v -or $v -eq $false -or $v -eq '' -or $v -eq 'False') { return '' }
                        if ($v -is [datetime]) { return $v.ToString('yyyy-MM-dd') }
                        # String déjà en ISO ou autre format -> retourner tel quel
                        return [string]$v
                    }
                    $oldEol = & $toIso $oldEntry.eol_date
                    $newEol = & $toIso $osEntry.eol_date
                    $oldExt = & $toIso $oldEntry.extended_support
                    $newExt = & $toIso $osEntry.extended_support
                    if ($oldEol -ne $newEol -or $oldExt -ne $newExt) {
                        $hasChanged = $true
                    }
                    # Normaliser aussi les valeurs stockées pour éviter les rechargements futurs
                    $osEntry.eol_date         = $newEol
                    $osEntry.extended_support = if ($newExt -eq '') { $null } else { $newExt }
                    # Supprimer l'ancienne clé (qu'elle soit l'ancienne ou la même)
                    $database.os_database.PSObject.Properties.Remove($existingKey)
                } else { $isNew = $true }

                $database.os_database | Add-Member -MemberType NoteProperty -Name $osName -Value $osEntry -Force
                if ($isNew -or $hasChanged) { $count++ }
            }

            if ($count -gt 0) {
                Write-Host "✓ " -ForegroundColor Green -NoNewline
                Write-Host "$count modification(s)" -ForegroundColor Green
            } else {
                Write-Host "✓ " -ForegroundColor DarkGreen -NoNewline
                Write-Host "A jour" -ForegroundColor DarkGray
            }
            $updateCount += $count

        } else {
            Write-Host "✗ Echec API" -ForegroundColor Red
            $errorCount++
        }
    }

    # ── Couverture OS : PreCheck ou équivalent ─────────────────────────────────
    # Si aucun fichier PreCheck disponible, on lance Test-OSInEoLDatabase
    # (même logique que PreCheck, source canonique unique) pour détecter les OS
    # du domaine absents de la base, et générer le fichier ModernAD-MissingOS.json.
    # Cette étape se fait AVANT la sauvegarde pour que les OS soient intégrés.
    $database.metadata.last_updated = Get-Date -Format "yyyy-MM-dd"
    $missingOSFile = Join-Path $env:TEMP "ModernAD-MissingOS.json"

    # Analyse couverture OS : uniquement si base NOUVELLE (premier lancement)
    # ou si un fichier PreCheck est présent (laissé par Invoke-ADReportPreCheck)
    # → jamais lors d'un Update-ADEoLDatabase normal sur une base existante
    if ($isNewDatabase -and -not (Test-Path $missingOSFile)) {
        Write-Host ""
        Write-Host " 🖥 ANALYSE COUVERTURE OS (equivalent PreCheck)" -ForegroundColor White
        Write-Host " ─────────────────────────────────────────────────────────────" -ForegroundColor DarkGray
        Write-Host " ⏳ Interrogation de l'annuaire Active Directory..." -ForegroundColor Gray

        # Alimenter le cache avec la base déjà construite en mémoire
        # pour éviter les warnings "fichier introuvable" dans Get-OSEoLInfo
        $script:EoLDatabaseCache = $database

        $osCheck = Test-OSInEoLDatabase -DaysBeforeWarning 90 -DatabaseOverride $database

        if ($osCheck.Status -eq "ERROR") {
            Write-Host " ⚠ " -ForegroundColor Yellow -NoNewline
            Write-Host $osCheck.Message -ForegroundColor Yellow
        } elseif ($osCheck.MissingOS.Count -gt 0) {
            Write-Host " ⚠ " -ForegroundColor Yellow -NoNewline
            Write-Host "$($osCheck.MissingOS.Count) OS detectes dans AD non references dans la base" -ForegroundColor Yellow
            # Sauvegarder pour la phase d'intégration ci-dessous
            try {
                $osCheck.MissingOS | ConvertTo-Json -Depth 5 |
                    Set-Content -Path $missingOSFile -Encoding UTF8 -ErrorAction Stop
            } catch {
                Write-Host " ⚠ Impossible de sauvegarder la liste : $($_.Exception.Message)" -ForegroundColor Yellow
            }
        } else {
            Write-Host " ✓ " -ForegroundColor DarkGreen -NoNewline
            Write-Host "Tous les OS du domaine sont references dans la base" -ForegroundColor DarkGray
        }
    }

    # S'assurer que le cache pointe sur $database (enrichi par API, pas encore sur disque)
    $script:EoLDatabaseCache = $database

    if (Test-Path $missingOSFile) {
        Write-Host ""
        $fromPreCheck = $true   # le fichier existait avant notre analyse = vient d'un vrai PreCheck
        Write-Host " 🖥 INTEGRATION OS MANQUANTS" -ForegroundColor White
        Write-Host " ─────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

        try {
            $missingOSList = Get-Content $missingOSFile -Raw | ConvertFrom-Json
            Write-Host " ⏳ " -ForegroundColor Yellow -NoNewline
            Write-Host "$($missingOSList.Count) OS a integrer..." -ForegroundColor Gray

            $addedCount = 0
            foreach ($item in $missingOSList) {
                $os = $item.OS
                if ($os -eq "unknown" -or -not $os) { continue }

                $exists = $false
                foreach ($prop in $database.os_database.PSObject.Properties) {
                    if ($prop.Name -eq $os) { $exists = $true; break }
                }

                if ($exists -eq $false) {
                    $existingInfo = Get-OSEoLInfo -OSName $os
                    if ($existingInfo -and $existingInfo.eol_date) {
                        $database.os_database | Add-Member -MemberType NoteProperty -Name $os -Value $existingInfo -Force
                        Write-Host " ↪ " -ForegroundColor DarkCyan -NoNewline
                        Write-Host "$os".PadRight(44) -ForegroundColor Gray -NoNewline
                        Write-Host "alias resolu → eol_date $($existingInfo.eol_date)" -ForegroundColor DarkCyan
                        $addedCount++
                        continue
                    }
                }

                if (-not $exists) {
                    Write-Host " ▪ " -ForegroundColor DarkGray -NoNewline
                    Write-Host $os.PadRight(44) -ForegroundColor Gray -NoNewline
                    Write-Host "$($item.Count) machine(s)" -ForegroundColor DarkYellow

                    $productName = "Unknown"; $osType = "client"; $version = "N/A"
                    if      ($os -like "*Windows Server*") { $productName = "Windows Server"; $osType = "server" }
                    elseif  ($os -like "*Windows*")        { $productName = "Windows";        $osType = "client" }
                    elseif  ($os -like "*Ubuntu*")         { $productName = "Ubuntu";         $osType = "linux"  }
                    elseif  ($os -like "*Debian*")         { $productName = "Debian";         $osType = "linux"  }
                    elseif  ($os -like "*Red Hat*" -or $os -like "*RHEL*") { $productName = "RHEL";  $osType = "linux" }
                    elseif  ($os -like "*SUSE*"   -or $os -like "*SLES*")  { $productName = "SLES";  $osType = "linux" }
                    elseif  ($os -like "*macOS*"  -or $os -like "*Mac OS*"){ $productName = "macOS"; $osType = "client" }

                    if ($os -match '(\d{4}(?:\.\d+)?|\d{2}\.\d{2}(?:\.\d+)?|\d+)') { $version = $matches[1] }

                    $osEntry = @{
                        product_name     = $productName
                        version          = $version
                        type             = $osType
                        eol_date         = $null
                        extended_support = $null
                        api_product      = "ad-detected"
                        api_cycle        = "unknown"
                    }
                    $database.os_database | Add-Member -MemberType NoteProperty -Name $os -Value $osEntry -Force
                    $addedCount++
                }
            }

            if ($addedCount -gt 0) {
                Write-Host " ✓ " -ForegroundColor Green -NoNewline
                Write-Host "$addedCount OS ajoutes a la base" -ForegroundColor Green
                $updateCount += $addedCount
            } else {
                Write-Host " ✓ " -ForegroundColor DarkGreen -NoNewline
                Write-Host "Tous les OS etaient deja references" -ForegroundColor DarkGray
            }

            Remove-Item $missingOSFile -Force -ErrorAction SilentlyContinue

        } catch {
            Write-Host " ⚠ Impossible de lire la liste des OS manquants : $($_.Exception.Message)" -ForegroundColor Yellow
        }
    }

        # ── Sauvegarde ──────────────────────────────────────────────────────────────
    $database.metadata.last_api_update = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

    Write-Host ""
    Write-Host " 💾 SAUVEGARDE" -ForegroundColor White
    Write-Host " ─────────────────────────────────────────────────────────────" -ForegroundColor DarkGray

    try {
        $database | ConvertTo-Json -Depth 10 | Set-Content -Path $jsonPath -Encoding UTF8 -ErrorAction Stop
        Clear-EoLDatabaseCache
        $null = Get-EoLDatabase -ForceReload

        Write-Host " ✓ " -ForegroundColor Green -NoNewline
        Write-Host "Base sauvegardee" -ForegroundColor Green -NoNewline
        Write-Host " ($jsonPath)" -ForegroundColor DarkGray

        Write-Host ""
        Write-Host " #=======================================================================" -ForegroundColor DarkCyan

        if ($updateCount -gt 0) {
            Write-Host ""
            Write-Host " ✓ $updateCount modification(s) appliquee(s)" -ForegroundColor Green -NoNewline
            if ($errorCount -gt 0) { Write-Host " ⚠ $errorCount erreur(s)" -ForegroundColor Yellow }
            else { Write-Host "" }
        } else {
            Write-Host ""
            Write-Host " ✓ Base deja a jour — aucune modification necessaire" -ForegroundColor DarkGray
        }

        Write-Host ""
        Write-Host " >> Pour generer le rapport avec les nouvelles donnees :" -ForegroundColor DarkGray
        Write-Host " Get-MADReport -UnlimitedSearch -SavePath 'C:\Reports'" -ForegroundColor DarkCyan
        Write-Host ""

    } catch {
        Write-Error "Erreur lors de la sauvegarde : $($_.Exception.Message)"
    }
}

<#
.SYNOPSIS
Recherche et ajoute les OS manquants détectés dans AD.

.DESCRIPTION
Compare les OS trouvés dans AD avec la base JSON et propose d'ajouter les manquants via l'API.

.PARAMETER DetectedOSList
Liste des OS détectés dans AD

.PARAMETER Interactive
Mode interactif - demande confirmation avant chaque ajout

.EXAMPLE
Search-MissingOSInAPI -DetectedOSList $osListFromAD -Interactive
#>

function Search-MissingOSInAPI {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [array]$DetectedOSList,
        
        [Parameter(Mandatory = $false)]
        [switch]$Interactive
    )
    
    Write-Host "🔍 Recherche des OS manquants dans la base EoL..." -ForegroundColor Cyan
    
    $database = Get-EoLDatabase
    if (-not $database) {
        Write-Error "Impossible de charger la base de données EoL"
        return
    }
    
    $missingOS = @()
    
    foreach ($os in $DetectedOSList) {
        if (-not $os) { continue }
        
        $osInfo = Get-OSEoLInfo -OSName $os
        if (-not $osInfo) {
            $missingOS += $os
        }
    }
    
    if ($missingOS.Count -eq 0) {
        Write-Host "✅ Tous les OS détectés sont présents dans la base" -ForegroundColor Green
        return
    }
    
    Write-Host "⚠️ $($missingOS.Count) OS manquant(s) détecté(s):" -ForegroundColor Yellow
    $missingOS | ForEach-Object { Write-Host " - $_" -ForegroundColor Gray }
    
    if ($Interactive) {
        Write-Host ""
        Write-Host "Recherche dans l'API endoflife.date pour ces OS..." -ForegroundColor Cyan
        
        foreach ($os in $missingOS) {
            Write-Host ""
            Write-Host "OS: $os" -ForegroundColor White
            
            # Tentative de détection du produit
            $suggestedProduct = $null
            if ($os -like "*Windows Server*") {
                $suggestedProduct = "windows-server"
            } elseif ($os -like "*Windows*") {
                $suggestedProduct = "windows"
            } elseif ($os -like "*Ubuntu*") {
                $suggestedProduct = "ubuntu"
            }
            
            if ($suggestedProduct) {
                Write-Host "Produit suggéré: $suggestedProduct" -ForegroundColor Gray
                $response = Read-Host "Rechercher dans l'API pour ce produit? (O/N)"
                
                if ($response -eq "O" -or $response -eq "o") {
                    $apiData = Invoke-EoLAPI -Product $suggestedProduct
                    if ($apiData) {
                        Write-Host "Cycles disponibles:" -ForegroundColor Green
                        $apiData | Select-Object -First 5 | ForEach-Object {
                            Write-Host " - $($_.cycle) (EoL: $($_.eol))" -ForegroundColor Gray
                        }
                    }
                }
            } else {
                Write-Host "Impossible de suggérer un produit API automatiquement" -ForegroundColor Yellow
                Write-Host "Consultez: https://endoflife.date/docs/api/" -ForegroundColor Gray
            }
        }
    } else {
        Write-Host ""
        Write-Host "💡 Pour rechercher ces OS dans l'API, utilisez:" -ForegroundColor Yellow
        Write-Host " Search-MissingOSInAPI -DetectedOSList `$list -Interactive" -ForegroundColor Gray
    }
}

#endregion

#region Fonction d'export

<#
.SYNOPSIS
Exporte la base de données EoL actuelle.

.DESCRIPTION
Crée une copie de sauvegarde ou exporte la base dans un autre format.

.PARAMETER OutputPath
Chemin du fichier de sortie

.EXAMPLE
Export-EoLDatabase -OutputPath "C:\Backup\eol-backup.json"
#>

function Export-EoLDatabase {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$OutputPath
    )
    
    $database = Get-EoLDatabase
    if (-not $database) {
        Write-Error "Impossible de charger la base de données"
        return
    }
    
    try {
        $database | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8
        Write-Host "✅ Base exportée vers: $OutputPath" -ForegroundColor Green
    } catch {
        Write-Error "Erreur lors de l'export: $($_.Exception.Message)"
    }
}


<#
.SYNOPSIS
Analyse les OS présents dans AD et identifie ceux absents de la base EoL.

.DESCRIPTION
Interroge Active Directory, normalise les noms OS (avec Normalize-OperatingSystem si disponible,
sinon fallback interne), puis compare avec la base EoL.
Utilisée par PreCheck ET par Update-ADEoLDatabase (logique unique, source canonique).

.PARAMETER DaysBeforeWarning
Seuil en jours pour le statut d'avertissement (défaut: 90)

.PARAMETER DatabaseOverride
Optionnel : passer directement un objet $database en cours de construction
(évite les lectures disque et warnings quand la DB n'est pas encore sauvegardée)

.OUTPUTS
PSCustomObject { Status, Message, MissingOS, FoundOS, TotalOSDetected, CoveragePercent, Details }
#>

function Test-OSInEoLDatabase {
    param(
        [int]$DaysBeforeWarning = 90,
        [object]$DatabaseOverride = $null
    )
    try {
        if (-not (Get-Command -Name 'Get-ADComputer' -ErrorAction SilentlyContinue)) {
            return [PSCustomObject]@{
                Status          = "ERROR"
                Message         = "Module ActiveDirectory non disponible"
                MissingOS       = @()
                TotalOSDetected = 0
            }
        }

        $computers = Get-ADComputer -Filter * -Properties OperatingSystem,OperatingSystemVersion -ResultSetSize $null

        # Utiliser le DatabaseOverride si fourni (base en cours de construction, pas encore sur disque)
        # sinon charger depuis le cache/disque normalement
        if ($DatabaseOverride) {
            $script:EoLDatabaseCache = $DatabaseOverride
        }
        $database = Get-EoLDatabase
        if (-not $database) {
            return [PSCustomObject]@{
                Status          = "ERROR"
                Message         = "Base de donnees EoL non trouvee"
                MissingOS       = @()
                TotalOSDetected = 0
            }
        }

        $buildMap = @{
            '10240'='1507';'10586'='1511';'14393'='1607';'15063'='1703';'16299'='1709'
            '17134'='1803';'17763'='1809';'18362'='1903';'18363'='1909'
            '19041'='2004';'19042'='20H2';'19043'='21H1';'19044'='21H2';'19045'='22H2'
            '22000'='21H2';'22621'='22H2';'22631'='23H2';'26100'='24H2'
        }

        $computersWithFull = $computers | Where-Object { $_.OperatingSystem } | ForEach-Object {
            $rawOS  = $_.OperatingSystem.Trim()
            $rawVer = if ($_.OperatingSystemVersion) { $_.OperatingSystemVersion.Trim() } else { '' }
            $osFull = $null

            if (Get-Command -Name 'Normalize-OperatingSystem' -ErrorAction SilentlyContinue) {
                $norm   = Normalize-OperatingSystem -OperatingSystem $rawOS -OperatingSystemVersion $rawVer
                $osFull = if ($norm.Release) { "$($norm.Product) $($norm.Release)" } else { $norm.Product }
            } else {
                $build = $null
                if ($rawVer -match '^\s*\d+\.\d+\s*\((\d+)\)') { $build = $matches[1] }

                if ($rawOS -match '^Windows (10|11)') {
                    $winVer  = $matches[1]
                    $release = if ($build) { $buildMap[$build] } else { $null }
                    $osFull  = if ($release) { "Windows $winVer $release" } else { "Windows $winVer" }
                } elseif ($rawOS -match '^Windows Server\s+(2008 R2|2012 R2|2025|2022|2019|2016|2012|2008|2003)') {
                    $osFull = "Windows Server $($matches[1])"
                } elseif ($rawOS -match '^(Mac\s*OS\s*X|macOS)') {
                    $macNames = @{'10.15'='Catalina';'10.14'='Mojave';'10.13'='High Sierra'
                                  '11'='Big Sur';'12'='Monterey';'13'='Ventura';'14'='Sonoma';'15'='Sequoia'}
                    $allText = "$rawOS $rawVer"
                    if ($allText -match '(Sequoia|Sonoma|Ventura|Monterey|Big\s*Sur|Catalina|Mojave|High\s*Sierra)') {
                        $name  = $matches[1] -replace '\s+',' '
                        $cycle = ($macNames.GetEnumerator() | Where-Object { $_.Value -eq $name } | Select-Object -First 1).Key
                        $osFull = if ($cycle) { "macOS $cycle $name" } else { "macOS $name" }
                    } elseif ($allText -match '(?<!\d)(1[1-9]|\d{2,})(?:\.\d+)?(?!\d)') {
                        $major = [int]($matches[0] -replace '\..*','')
                        $name  = $macNames[$major]
                        $osFull = if ($name) { "macOS $major $name" } else { "macOS $major" }
                    } else { $osFull = "macOS" }
                } elseif ($rawOS -match '(Ubuntu|Debian|CentOS|Red\s*Hat|RHEL|SUSE|SLES|Fedora)') {
                    $prod = switch -Regex ($matches[1]) {
                        'Red\s*Hat|RHEL' { 'RHEL' }
                        default           { $matches[1] }
                    }
                    if      ($rawOS -match '(?:RHEL|Red\s*Hat[^\d]*|CentOS[^\d]*|Debian[^\d]*|Fedora[^\d]*)(\d{1,2})(?:\.\d+)?') { $osFull = "$prod $($matches[1])" }
                    elseif  ($rawOS -match '(Ubuntu|SUSE|SLES)\s+(\d{2}\.\d{2})')                                                 { $osFull = "$prod $($matches[2])" }
                    elseif  ($rawVer -match '^(\d{1,2})(?:\.\d+)?$')                                                              { $osFull = "$prod $($matches[1])" }
                    else    { $osFull = $prod }
                } else {
                    $osFull = $rawOS
                }
            }
            [PSCustomObject]@{ Computer = $_.Name; RawOS = $rawOS; OS_Full = $osFull.Trim() }
        }

        $osGroups   = $computersWithFull | Group-Object OS_Full
        $detectedOS = $osGroups | Select-Object -ExpandProperty Name | Sort-Object
        $missingOS  = @()
        $foundOS    = @()
        $osDetails  = @()

        foreach ($osGroup in $osGroups) {
            $os     = $osGroup.Name
            $count  = $osGroup.Count
            $osInfo = Get-OSEoLInfo -OSName $os
            if ($osInfo) {
                $foundOS   += $os
                $osDetails += [PSCustomObject]@{ OS=$os; Count=$count; Found=$true;
                    Status=(Get-OSEoLStatus -OSName $os -DaysBeforeWarning $DaysBeforeWarning)
                    EoLDate=$osInfo.eol_date }
            } else {
                $missingOS += [PSCustomObject]@{ OS = $os; Count = $count }
                $osDetails += [PSCustomObject]@{ OS=$os; Count=$count; Found=$false; Status="Unknown"; EoLDate="N/A" }
            }
        }

        $coveragePercent = if ($detectedOS.Count -gt 0) {
            [math]::Round(($foundOS.Count / $detectedOS.Count) * 100, 1)
        } else { 100 }

        return [PSCustomObject]@{
            Status          = if ($missingOS.Count -eq 0) { "OK" } else { "WARNING" }
            Message         = if ($missingOS.Count -eq 0) { "Tous les OS sont dans la base EoL" } else { "$($missingOS.Count) OS manquants dans la base EoL" }
            MissingOS       = $missingOS
            FoundOS         = $foundOS
            TotalOSDetected = $detectedOS.Count
            CoveragePercent = $coveragePercent
            Details         = $osDetails
        }
    } catch {
        return [PSCustomObject]@{ Status = "ERROR"; Message = $_.Exception.Message; MissingOS = @() }
    }
}

#region Fonctions de mapping AD -> EoL (déplacées depuis MAD-EoL.ps1 — B06 fix)

<#
.SYNOPSIS
Retourne les informations EoL d'un ordinateur AD en résolvant sa clé dans la base EoL.

.DESCRIPTION
Calcule la clé EoL à partir des propriétés OS_Product, OS_Release et Operating System
de l'objet computer enrichi par MAD-Computers.ps1, puis interroge la base via Get-EoLDatabase.
Remplace la version précédente qui dépendait des variables de contexte $EoL_Base/$EoL_Extended.

.PARAMETER Computer
Objet PSCustomObject issu de $ComputersTable (doit avoir : Vendor, Family, OS_Product, OS_Release, 'Operating System')

.PARAMETER DaysBeforeEoL
Seuil en jours pour le statut d'avertissement (transmis par $DaysBeforeEoL du psm1)

.OUTPUTS
PSCustomObject { Key, EoL, Days, Status, EoL_ExtendedType, EoL_ExtendedEnd } ou $null
#>

function Get-ComputerEoLInfo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [pscustomobject]$Computer,

        [Parameter(Mandatory=$false)]
        [int]$DaysBeforeEoL = 90
    )

    # --- Charger la base via le cache Get-EoLDatabase (zero relecture disque si déjà en mémoire) ---
    $db = Get-EoLDatabase
    if (-not $db) { return $null }

    # Construire les hashtables $EoL_Base et $EoL_Extended depuis la base
    $EoL_Base     = @{}
    $EoL_Extended = @{}
    foreach ($prop in $db.os_database.PSObject.Properties) {
        $entry = $prop.Value
        if ($entry.eol_date)         { $EoL_Base[$prop.Name]     = $entry.eol_date }
        if ($entry.extended_support -and $entry.extended_support -ne 'N/A') {
            $EoL_Extended[$prop.Name] = @{ Type = 'Extended'; End = $entry.extended_support }
        }
    }

    $prod    = $Computer.OS_Product
    $release = $Computer.OS_Release
    $raw     = $Computer.'Operating System'
    $osver   = $Computer.'Operating System Version'   # ex: "22.04" ou "22.04.1"

    $key             = $null
    $editionUndefined = $false

    if ($Computer.Vendor -eq 'Microsoft' -and $Computer.Family -eq 'Client') {
        if (($prod -eq 'Windows 10' -or $prod -eq 'Windows 11') -and $release) {
            $relLower = $release.ToLower()
            $prefix   = if ($prod -eq 'Windows 10') { 'Windows 10' } else { 'Windows 11' }

            if    ($raw -match 'LTSC')                              { $key = "$prefix-$relLower-e-lts" }
            elseif($raw -match 'IoT')                               { $key = "$prefix-$relLower-iot-lts" }
            elseif($raw -match 'Enterprise')                        { $key = "$prefix-$relLower-e" }
            elseif($raw -match 'Pro|Professional|Professionnel')    { $key = "$prefix-$relLower-w" }
            else {
                $editionUndefined = $true
                $keyW    = "$prefix-$relLower-w"
                $keyBase = "$prefix-$relLower"
                $key = if ($EoL_Base.ContainsKey($keyW)) { $keyW } else { $keyBase }
            }

            if ($key -and -not $EoL_Base.ContainsKey($key)) {
                $editionUndefined = $true
                $keyBase = "$prefix-$relLower"
                $key = if ($EoL_Base.ContainsKey($keyBase)) { $keyBase } else { $null }
            }
        }
        elseif ($prod -eq 'Windows XP')  { $key = 'Windows 5-sp3' }
        elseif ($prod -eq 'Windows 7')   { $key = 'Windows 7-sp1' }
        elseif ($prod -eq 'Windows 8')   { $key = 'Windows 8' }
        elseif ($prod -eq 'Windows 8.1') { $key = 'Windows 8.1' }
    }
    elseif ($Computer.Vendor -eq 'Microsoft' -and $Computer.Family -eq 'Server') {
        $cleanProd = if ($prod) { $prod.Trim() } else { '' }
        if     ($cleanProd -eq 'Windows Server 2025')       { $key = 'Windows Server 2025' }
        elseif ($cleanProd -eq 'Windows Server 2022')       { $key = 'Windows Server 2022' }
        elseif ($cleanProd -eq 'Windows Server 2019')       { $key = 'Windows Server 2019' }
        elseif ($cleanProd -eq 'Windows Server 2016')       { $key = 'Windows Server 2016' }
        elseif ($cleanProd -match 'Windows Server 2012') {
            # FIX7a : la base stocke maintenant "Windows Server 2012 R2" (espaces) et non "Windows Server 2012-r2"
            $key = if ($cleanProd -match 'R2') { 'Windows Server 2012 R2' } else { 'Windows Server 2012' }
        }
        elseif ($cleanProd -match 'Windows Server 2008') {
            # FIX7a : "Windows Server 2008 R2" et "Windows Server 2008" (sans suffixe -sp1/-sp2)
            $key = if ($cleanProd -match 'R2') { 'Windows Server 2008 R2' } else { 'Windows Server 2008' }
        }
        elseif ($cleanProd -match 'Windows Server 2003') { $key = 'Windows Server 2003' }
        else { $key = $null }
    }
    elseif ($Computer.Vendor -eq 'Apple' -and $prod -eq 'macOS') {
        # Release = "14 Sonoma" ou "10.15 Catalina" (extrait par Normalize-OperatingSystem)
        # Clé DB = "macOS 14 Sonoma" ou "macOS 10.15 Catalina"
        if ($release) {
            $key = "macOS $release"
        }
    }
    elseif ($Computer.Vendor -eq 'Linux') {
        if ($prod -match 'Ubuntu') {
            # Release = "22.04" (extrait par Normalize-OperatingSystem depuis toutes les sources AD)
            if ($release) { $key = "ubuntu $release" }
        } elseif ($prod -match 'Debian') {
            if ($release) {
                # Release peut être "12", "12.4" → on prend la majeure
                $major = ($release -split '\.')[0]
                $key = "debian $major"
            }
        } elseif ($prod -match '^RHEL$' -or $raw -match 'Red Hat|RHEL') {
            if ($release) {
                $major = ($release -split '\.')[0]
                $key = "rhel $major"
            }
        } elseif ($prod -match 'SUSE|SLES') {
            if ($release -match '(\d+\.\d+)') { $key = "sles $($matches[1])" }
            elseif ($release) { $key = "sles $release" }
        } elseif ($prod -match 'Fedora') {
            if ($release) { $key = "fedora $release" }
        } elseif ($prod -match 'CentOS') {
            if ($release) {
                $major = ($release -split '\.')[0]
                $key = "centos $major"
            }
        }
    }

    # Récupérer les dates EoL base et extended
    $baseDate = $null
    if ($key -and $EoL_Base.ContainsKey($key)) { $baseDate = Get-Date $EoL_Base[$key] }

    $extType = $null; $extEnd = $null
    if ($key -and $EoL_Extended.ContainsKey($key)) {
        $extType = $EoL_Extended[$key].Type
        $extEnd  = Get-Date $EoL_Extended[$key].End
    }

    if ($null -ne $baseDate) {
        # Le STATUT est calculé sur la date effective la plus tardive :
        # si un extended_support valide existe (ESU/ESM/LTSS), il repousse la date d'expiration réelle.
        # Cela garantit la cohérence avec Get-OSEoLStatus (utilisé dans PreCheck).
        # La propriété EoL conserve la eol_date standard (support de base) pour l'affichage du tableau.
        $effectiveDate = $baseDate
        if ($null -ne $extEnd) {
            $extEntryRaw = if ($key) { $db.os_database.$key.extended_support } else { $null }
            $isValidExtDate = $extEntryRaw -and
                              $extEntryRaw -ne $false -and
                              $extEntryRaw -ne 'False' -and
                              $extEntryRaw -ne 'N/A' -and
                              $extEntryRaw -ne ''
            if ($isValidExtDate -and $extEnd -gt $baseDate) {
                $effectiveDate = $extEnd
            }
        }

        $days   = [int]((New-TimeSpan -Start (Get-Date).Date -End $effectiveDate.Date).TotalDays)
        $status = if ($days -lt 0) { 'EOL' } elseif ($days -le $DaysBeforeEoL) { "J-$DaysBeforeEoL" } else { 'Supported' }
        if ($editionUndefined -and -not $extType) { $extType = 'NotDefined' }
        return [pscustomobject]@{
            Key              = $key
            EoL              = $baseDate
            Days             = $days
            Status           = $status
            EoL_ExtendedType = $extType
            EoL_ExtendedEnd  = $extEnd
        }
    }

    # Clé trouvée en DB mais sans date EoL (eol_date=False/null) = encore supporté, date non annoncée
    if ($key) {
        $keyExistsInDB = $db.os_database.PSObject.Properties.Name -contains $key
        if ($keyExistsInDB) {
            return [pscustomobject]@{
                Key              = $key
                EoL              = $null
                Days             = $null
                Status           = 'Supported'
                EoL_ExtendedType = 'NoDateAnnounced'
                EoL_ExtendedEnd  = $null
            }
        }
    }

    return $null
}

<#
.SYNOPSIS
Normalise un tableau de statuts EoL en un objet structuré avec compteurs garantis.

.DESCRIPTION
Prend un tableau { Status, Count } et retourne un PSCustomObject avec les 4 statuts
(Supported, J-XX, EOL, Unknown) toujours présents (0 si absent).
Déplacée depuis MAD-EoL.ps1 pour centraliser la logique EoL dans ce module.

.PARAMETER Stats
Tableau PSCustomObject issu de Group-Object EoL_Status

.PARAMETER WarningStatusName
Nom dynamique du statut d'avertissement (ex: "J-90") — doit correspondre à $WarningStatusName du contexte
#>

function ConvertTo-StatusCounts {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [array]$Stats,

        [Parameter(Mandatory=$false)]
        [string]$WarningStatusName = 'J-90'
    )
    $h = @{}
    foreach ($s in $Stats) { $h[$s.Status] = $s.Count }
    return [pscustomobject]@{
        Supported = $(if ($h.ContainsKey('Supported'))          { [int]$h['Supported'] }          else { 0 })
        J90       = $(if ($h.ContainsKey($WarningStatusName))   { [int]$h[$WarningStatusName] }   else { 0 })
        EOL       = $(if ($h.ContainsKey('EOL'))                { [int]$h['EOL'] }                else { 0 })
        Unknown   = $(if ($h.ContainsKey('Unknown'))            { [int]$h['Unknown'] }            else { 0 })
    }
}

#endregion

# L'alias Update-EoL est défini dans le PSM1 (→ Update-ADEoLDatabase)
# Ne pas le redéfinir ici pour éviter un conflit de cible au chargement du module.

#endregion