Private/NC-Hlp.Licenses.ps1

#Requires -Version 5.0
using namespace System.Management.Automation

# Nebula.Core: (Private) Licenses's Utilities =======================================================================================================

function Get-LicenseCacheInfo {
    <#
    .SYNOPSIS
        Returns license catalog cache paths.
    .DESCRIPTION
        Resolves (and optionally creates) the local directory used to persist the downloaded
        license catalog JSON files and related metadata.
    .PARAMETER Ensure
        Create the cache directory if it does not exist.
    .PARAMETER CacheFileName
        Name of the cached JSON file (defaults to M365_licenses.json).
    #>

    [CmdletBinding()]
    param(
        [switch]$Ensure,
        [string]$CacheFileName = 'M365_licenses.json'
    )

    $defaultRoot = if (($NCVars -is [System.Collections.IDictionary]) -and $NCVars.Contains('LicenseCacheDirectory') -and $NCVars.LicenseCacheDirectory) {
        [string]$NCVars.LicenseCacheDirectory
    }
    else {
        Join-Path $env:USERPROFILE '.NebulaCore\Cache'
    }

    if ($Ensure -and -not (Test-Path -LiteralPath $defaultRoot)) {
        New-Item -ItemType Directory -Path $defaultRoot -Force | Out-Null
    }

    $metaFileName = '{0}.meta.json' -f ([System.IO.Path]::GetFileNameWithoutExtension($CacheFileName))

    return @{
        Directory = $defaultRoot
        DataPath  = Join-Path $defaultRoot $CacheFileName
        MetaPath  = Join-Path $defaultRoot $metaFileName
    }
}

function Get-NormalizedLicenseKey {
    <#
    .SYNOPSIS
        Normalizes SKU identifiers for dictionary lookups.
    .DESCRIPTION
        Returns $null for blank strings; otherwise uppercases and replaces whitespace, dots and dashes with underscores.
    .PARAMETER Value
        SKU string to normalize.
    #>

    [CmdletBinding()]
    param([string]$Value)

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return $null
    }

    return (($Value -replace '[-\.\s]', '_').ToUpperInvariant())
}

function New-LicenseLookup {
    [CmdletBinding()]
    param([object[]]$Items)

    $lookup = @{}
    if (-not $Items) { return $lookup }

    foreach ($item in $Items) {
        $key = Get-NormalizedLicenseKey -Value $item.String_Id
        if (-not $key) { continue }

        $names = @()
        if ($null -ne $item.Product_Display_Name) {
            if ($item.Product_Display_Name -is [System.Collections.IEnumerable] -and -not ($item.Product_Display_Name -is [string])) {
                foreach ($name in $item.Product_Display_Name) {
                    if (-not [string]::IsNullOrWhiteSpace($name)) {
                        $names += $name
                    }
                }
            }
            else {
                $names += $item.Product_Display_Name
            }
        }

        $names = $names | Where-Object { $_ -and ($_ -ne '') } | Select-Object -Unique
        if ($names.Count -gt 0) {
            $lookup[$key] = ($names -join ' / ')
        }
    }

    return $lookup
}

function Get-LicenseSourceData {
    <#
    .SYNOPSIS
        Retrieves (and caches) license data from a specific source.
    .DESCRIPTION
        Handles cache validation, optional metadata retrieval, download, and persistence for a JSON feed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$CacheFileName,
        [Parameter(Mandatory)]
        [string]$FileUrl,
        [string]$ApiUrl,
        [switch]$ForceRefresh,
        [int]$MaxAttempts = 3,
        [int]$DelaySeconds = 5,
        [int]$CacheDays = 7
    )

    if ([string]::IsNullOrWhiteSpace($FileUrl)) {
        return $null
    }

    $cacheInfo = Get-LicenseCacheInfo -Ensure -CacheFileName $CacheFileName
    $cacheFile = $cacheInfo.DataPath
    $metaFile  = $cacheInfo.MetaPath

    $ttl = [TimeSpan]::FromDays([Math]::Max(1, $CacheDays))
    $nowUtc = (Get-Date).ToUniversalTime()

    $meta = $null
    if (Test-Path -LiteralPath $metaFile) {
        try {
            $meta = Get-Content -LiteralPath $metaFile -Raw | ConvertFrom-Json
        }
        catch {
            $meta = $null
        }
    }

    $tryParseUtc = {
        param($value)
        if (-not $value) { return $null }
        try { return [DateTime]::Parse($value, $null, [System.Globalization.DateTimeStyles]::AdjustToUniversal) }
        catch { return $null }
    }

    $currentCommitUtc = if ($meta) { & $tryParseUtc $meta.LastCommitUtc }
    $lastCheckedUtc = if ($meta) { & $tryParseUtc $meta.LastCheckedUtc }
    if (-not $lastCheckedUtc -and (Test-Path -LiteralPath $cacheFile)) {
        $lastCheckedUtc = (Get-Item -LiteralPath $cacheFile).LastWriteTimeUtc
    }

    $needDownload = $ForceRefresh.IsPresent -or -not (Test-Path -LiteralPath $cacheFile)
    if (-not $needDownload -and $lastCheckedUtc) {
        if ($nowUtc - $lastCheckedUtc -ge $ttl) {
            $needDownload = $true
        }
    }

    $remoteCommitUtc = $null
    if ($ApiUrl -and ($needDownload -or $ForceRefresh.IsPresent)) {
        try {
            $response = Invoke-NCRetry -Action {
                Invoke-RestMethod -Uri $ApiUrl -Headers @{ 'User-Agent' = 'Nebula.Core' } -ErrorAction Stop
            } -MaxAttempts $MaxAttempts -DelaySeconds $DelaySeconds -OperationDescription "retrieve license metadata" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed to retrieve license metadata, attempt $attempt of $max." -Level WARNING
            }

            if ($response -and $response[0]) {
                $lastCommitDate = $response[0].commit.committer.date
                try {
                    $remoteCommitUtc = [DateTime]::Parse($lastCommitDate, $null, [System.Globalization.DateTimeStyles]::AdjustToUniversal)
                }
                catch {
                    $remoteCommitUtc = $null
                }
            }
        }
        catch {
            if ($needDownload -and -not (Test-Path -LiteralPath $cacheFile)) {
                throw "Unable to contact GitHub to retrieve license metadata or catalog. $($_.Exception.Message)"
            }
        }
    }

    if ($needDownload -and -not $ForceRefresh.IsPresent -and $remoteCommitUtc -and $currentCommitUtc) {
        if ($remoteCommitUtc -le $currentCommitUtc) {
            $needDownload = $false
        }
    }

    $licenseItems = $null
    $source = 'Cache'

    if (-not $needDownload) {
        try {
            $licenseItems = Get-Content -LiteralPath $cacheFile -Raw | ConvertFrom-Json
        }
        catch {
            Write-NCMessage "Unable to read cached license catalog ($CacheFileName). Attempting to download a fresh copy ..." -Level WARNING
            $needDownload = $true
        }
    }

    if ($needDownload) {
        try {
            $licenseItems = Invoke-NCRetry -Action {
                Invoke-RestMethod -Method Get -Uri $FileUrl -ErrorAction Stop
            } -MaxAttempts $MaxAttempts -DelaySeconds $DelaySeconds -OperationDescription "download license file" -OnError {
                param($attempt, $max, $err)
                Write-NCMessage "Failed downloading license file ($CacheFileName), attempt $attempt of $max." -Level ERROR
            }

            $licenseItems | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $cacheFile -Encoding UTF8
            $source = 'Remote'
            Write-NCMessage "License file downloaded and cached at $cacheFile." -Level VERBOSE

            if (-not $remoteCommitUtc) {
                $remoteCommitUtc = $nowUtc
            }
            $currentCommitUtc = $remoteCommitUtc
        }
        catch {
            throw "Downloading license file failed after $MaxAttempts attempts."
        }
    }

    if (-not $currentCommitUtc -and $remoteCommitUtc) {
        $currentCommitUtc = $remoteCommitUtc
    }

    $metaObject = [ordered]@{
        LastCheckedUtc = $nowUtc.ToString('o')
        LastCommitUtc  = if ($currentCommitUtc) { $currentCommitUtc.ToString('o') } else { $null }
    }
    try {
        $metaObject | ConvertTo-Json | Set-Content -LiteralPath $metaFile -Encoding UTF8
    }
    catch {
        Write-NCMessage "Unable to update license cache metadata for $($CacheFileName): $($_.Exception.Message)" -Level WARNING
    }

    return [pscustomobject]@{
        Items         = $licenseItems
        Source        = $source
        CachePath     = $cacheFile
        LastCommitUtc = $currentCommitUtc
    }
}

function Get-LicenseCatalog {
    <#
    .SYNOPSIS
        Retrieves (and caches) the license catalog JSON (with custom fallback).
    .DESCRIPTION
        Loads the primary catalog from a local cache when possible, refreshes it from GitHub when forced
        or when stale, and optionally loads a custom catalog to resolve missing SKUs.
    .PARAMETER IncludeMetadata
        Adds last commit/update details to the output (and logs them).
    .PARAMETER ForceRefresh
        Forces a re-download of the catalog(s) regardless of cache age.
    .PARAMETER MaxAttempts
        Maximum number of retries while calling the remote endpoints.
    .PARAMETER DelaySeconds
        Delay between retries.
    #>

    [CmdletBinding()]
    param(
        [switch]$IncludeMetadata,
        [switch]$ForceRefresh,
        [int]$MaxAttempts = 3,
        [int]$DelaySeconds = 5
    )

    $cacheDays = 7
    if (($NCVars -is [System.Collections.IDictionary]) -and $NCVars.Contains('LicenseCacheDays') -and $NCVars.LicenseCacheDays) {
        [void][int]::TryParse([string]$NCVars.LicenseCacheDays, [ref]$cacheDays)
        if ($cacheDays -lt 1) { $cacheDays = 1 }
    }

    $primarySource = $script:NCLicenseSources.Primary
    if (-not $primarySource) {
        throw "Primary license source configuration missing."
    }

    $primaryData = Get-LicenseSourceData -CacheFileName $primarySource.CacheFileName `
        -FileUrl $primarySource.FileUrl `
        -ApiUrl $primarySource.ApiUrl `
        -ForceRefresh:$ForceRefresh.IsPresent `
        -MaxAttempts $MaxAttempts `
        -DelaySeconds $DelaySeconds `
        -CacheDays $cacheDays

    if (-not $primaryData -or -not $primaryData.Items) {
        throw "Unable to load the primary license catalog."
    }

    $primaryLookup = New-LicenseLookup -Items $primaryData.Items

    $customLookup = $null
    $customData = $null
    $customSource = $script:NCLicenseSources.Custom
    if ($customSource -and $customSource.FileUrl) {
        $customData = Get-LicenseSourceData -CacheFileName $customSource.CacheFileName `
            -FileUrl $customSource.FileUrl `
            -ApiUrl $customSource.ApiUrl `
            -ForceRefresh:$ForceRefresh.IsPresent `
            -MaxAttempts $MaxAttempts `
            -DelaySeconds $DelaySeconds `
            -CacheDays $cacheDays

        if ($customData -and $customData.Items) {
            $customLookup = New-LicenseLookup -Items $customData.Items
        }
    }

    if ($IncludeMetadata.IsPresent -and $primaryData.LastCommitUtc) {
        Write-Verbose "License catalog last updated: $($primaryData.LastCommitUtc.ToLocalTime().ToString($NCVars.DateTimeString_Full)) (source: $primaryData.Source)"
    }
    if ($IncludeMetadata.IsPresent -and $customData -and $customData.LastCommitUtc) {
        Write-Verbose "Custom license catalog last updated: $($customData.LastCommitUtc.ToLocalTime().ToString($NCVars.DateTimeString_Full)) (source: $customData.Source)"
    }

    return [pscustomobject]@{
        Items               = $primaryData.Items
        Lookup              = $primaryLookup
        LastCommitUtc       = $primaryData.LastCommitUtc
        Source              = $primaryData.Source
        CachePath           = $primaryData.CachePath
        CustomLookup        = $customLookup
        CustomLastCommitUtc = $customData?.LastCommitUtc
        CustomSource        = $customData?.Source
        CustomCachePath     = $customData?.CachePath
    }
}

function Get-LicenseDisplayName {
    <#
    .SYNOPSIS
        Resolves a SKU part number into a friendly display name.
    .DESCRIPTION
        Uses the lookup dictionary built by Get-LicenseCatalog to find mapped names, with optional fallback lookup.
    .PARAMETER Lookup
        Hashtable that maps normalized SKU keys to friendly product names.
    .PARAMETER SkuPartNumber
        SKU string to look up.
    .PARAMETER FallbackLookup
        Secondary lookup (e.g. custom catalog) used when the primary lookup does not contain the SKU.
    .PARAMETER MatchSource
        [ref] string that receives the source used: 'Primary', 'Fallback', or $null when unresolved.
    .PARAMETER FallbackSourceLabel
        Label assigned to the fallback lookup when MatchSource is requested (defaults to 'Custom').
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Lookup,
        [Parameter(Mandatory)]
        [string]$SkuPartNumber,
        [hashtable]$FallbackLookup,
        [ref]$MatchSource,
        [string]$FallbackSourceLabel = 'Custom'
    )

    $key = Get-NormalizedLicenseKey -Value $SkuPartNumber
    if (-not $key) {
        if ($PSBoundParameters.ContainsKey('MatchSource')) { $MatchSource.Value = $null }
        return $null
    }

    if ($Lookup -and $Lookup.ContainsKey($key)) {
        if ($PSBoundParameters.ContainsKey('MatchSource')) { $MatchSource.Value = 'Primary' }
        return $Lookup[$key]
    }

    if ($FallbackLookup -and $FallbackLookup.ContainsKey($key)) {
        if ($PSBoundParameters.ContainsKey('MatchSource')) { $MatchSource.Value = $FallbackSourceLabel }
        return $FallbackLookup[$key]
    }

    if ($PSBoundParameters.ContainsKey('MatchSource')) { $MatchSource.Value = $null }
    return $null
}