src/Private/Test-OpteraLicenseKey.ps1

function Test-OpteraLicenseKey {
    <#
    .SYNOPSIS
        Determines the report reveal mode (Free vs Full) from the license key.
    .DESCRIPTION
        The scan always runs fully and locally; this only decides whether the report renderer may
        reveal the per-account remediation list (Full) or just the aggregate headline (Free).

        Resolution order:
          1. No key (param or stored file) -> Free.
          2. Malformed key -> Free (before any network call).
          3. -Offline switch -> Full on a well-formed key, no network. Used by the
                                               offline fixture demo, Pester tests, and Unlock (which
                                               stores the key before any tenant is known).
          4. Online -> POST the key + tenant id + product version to the
                                               Optera AI validation endpoint, which checks the key is
                                               active, bound to this tenant, and unexpired. The result
                                               (including any subscription expiry the endpoint returns)
                                               is cached; if the endpoint is unreachable, a cached Full
                                               result within -GraceDays keeps the customer working
                                               offline - but never past a known subscription expiry, so
                                               a lapsed subscription can't ride the grace window. Only
                                               the key, tenant id, and version leave - never directory
                                               data.
    .OUTPUTS
        PSCustomObject: Mode ('Free'|'Full'), KeyPresent, Validated, Message
    #>

    [CmdletBinding()]
    param(
        [string] $LicenseKey,
        [string] $TenantId,
        [switch] $Offline,
        [int]    $GraceDays = 30
    )

    if (-not $LicenseKey) {
        $path = Get-OpteraLicenseKeyPath
        if (Test-Path -LiteralPath $path) {
            $LicenseKey = (Get-Content -LiteralPath $path -Raw).Trim()
        }
    }

    if (-not $LicenseKey) {
        return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $false; Validated = $false; Message = 'No license key found - running in free mode.' }
    }

    # Format gate (cheap, runs before any network). Real keys: OLR-XXXXX-XXXXX-XXXXX
    if ($LicenseKey -notmatch '^OLR-[A-Z0-9]{5}-[A-Z0-9]{5}-[A-Z0-9]{5}$') {
        return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'License key is malformed - staying in free mode.' }
    }

    # Offline path: format-only acceptance (fixtures / tests / unlock-time storage).
    if ($Offline) {
        return [pscustomobject]@{ Mode = 'Full'; KeyPresent = $true; Validated = $false; Message = 'License key accepted (offline format check).' }
    }

    # Online validation, bound to the tenant.
    if (-not $TenantId) {
        return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'No tenant context to validate the license against - staying in free mode.' }
    }

    $body = @{ key = $LicenseKey; tenantId = $TenantId; version = $script:OpteraLicenseReclaimVersion } | ConvertTo-Json -Compress
    try {
        $resp = Invoke-RestMethod -Method Post -Uri $script:OpteraLicenseEndpoint -Body $body -ContentType 'application/json' -TimeoutSec 15 -ErrorAction Stop
        $mode = if ($resp.mode -eq 'Full') { 'Full' } else { 'Free' }
        $reason = if ($resp.PSObject.Properties.Name -contains 'reason') { $resp.reason } else { 'not valid' }
        # Subscription keys come back with an expiry (period end); perpetual unlock keys return null.
        $exp = if (($resp.PSObject.Properties.Name -contains 'exp') -and $resp.exp) { [string]$resp.exp } else { '' }
        Save-OpteraLicenseValidation -LicenseKey $LicenseKey -TenantId $TenantId -Mode $mode -Exp $exp
        $msg = if ($mode -eq 'Full') { 'License validated.' } else { "License not valid for this tenant ($reason)." }
        return [pscustomobject]@{ Mode = $mode; KeyPresent = $true; Validated = $true; Message = $msg }
    }
    catch {
        # Endpoint unreachable - fall back to a cached Full result within the grace window.
        $cached = Get-OpteraLicenseValidation -LicenseKey $LicenseKey -TenantId $TenantId -GraceDays $GraceDays
        if ($cached -and $cached.Mode -eq 'Full') {
            return [pscustomobject]@{ Mode = 'Full'; KeyPresent = $true; Validated = $false; Message = "Validation endpoint unreachable; using cached entitlement (checked $([int]((Get-Date) - $cached.CheckedAt).TotalDays)d ago)." }
        }
        return [pscustomobject]@{ Mode = 'Free'; KeyPresent = $true; Validated = $false; Message = 'Could not reach the validation endpoint and no cached entitlement - staying in free mode.' }
    }
}

function Get-OpteraLicenseKeyPath {
    <# Returns the per-user path where the license key is stored. #>
    [CmdletBinding()]
    param()
    return Join-Path (Get-OpteraLicenseDir) 'license.key'
}

function Get-OpteraLicenseDir {
    <# Per-user Optera config dir (Windows %APPDATA%, else ~/.config). #>
    [CmdletBinding()]
    param()
    $appData = [Environment]::GetFolderPath('ApplicationData')
    if (-not $appData) { $appData = Join-Path $HOME '.config' }
    return Join-Path $appData 'Optera/LicenseReclaim'
}

function Save-OpteraLicenseValidation {
    <# Caches the last successful validation for offline grace. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $LicenseKey,
        [Parameter(Mandatory)] [string] $TenantId,
        [Parameter(Mandatory)] [string] $Mode,
        [string] $Exp
    )
    $dir = Get-OpteraLicenseDir
    if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
    $record = [pscustomobject]@{
        Key       = $LicenseKey
        TenantId  = $TenantId
        Mode      = $Mode
        Exp       = $Exp
        CheckedAt = (Get-Date).ToString('o')
    }
    $record | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $dir 'validation.json') -Encoding UTF8
}

function Get-OpteraLicenseValidation {
    <# Reads the cached validation if it matches this key+tenant and is within -GraceDays. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $LicenseKey,
        [Parameter(Mandatory)] [string] $TenantId,
        [int] $GraceDays = 30
    )
    $path = Join-Path (Get-OpteraLicenseDir) 'validation.json'
    if (-not (Test-Path -LiteralPath $path)) { return $null }
    try {
        $rec = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
        if ($rec.Key -ne $LicenseKey -or $rec.TenantId -ne $TenantId) { return $null }
        $checkedAt = [datetime]::Parse($rec.CheckedAt)
        if (((Get-Date) - $checkedAt).TotalDays -gt $GraceDays) { return $null }
        # A subscription key carries an expiry; never honor it past its known end, even inside the
        # grace window. Perpetual unlock keys store no expiry and skip this check. (Older cache files
        # predate the Exp field - treat them as no-expiry.)
        if (($rec.PSObject.Properties.Name -contains 'Exp') -and $rec.Exp) {
            try {
                if ((Get-Date) -gt [datetime]::Parse($rec.Exp)) { return $null }
            }
            catch { return $null }
        }
        return [pscustomobject]@{ Mode = $rec.Mode; CheckedAt = $checkedAt }
    }
    catch { return $null }
}