LicenseMeterScan.psm1

#requires -Version 7.0

# LicenseMeter Scan
# Free, read-only Microsoft 365 license-waste scanner. Runs locally in your own tenant.
# https://github.com/ugurkocde/licensemeter

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# ----------------------------------------------------------------------------
# Constants
# ----------------------------------------------------------------------------

$script:ModuleVersion         = '1.5.1'
$script:GraphBase             = 'https://graph.microsoft.com/beta'
$script:UserSelect            = 'id,userPrincipalName,displayName,accountEnabled,userType,assignedLicenses,createdDateTime,signInActivity'
$script:UserSelectNoSignIn    = 'id,userPrincipalName,displayName,accountEnabled,userType,assignedLicenses,createdDateTime'
# AuditLog.Read.All powers signInActivity (the preferred activity signal). Reports.Read.All powers the
# usage-report fallback for tenants without Entra ID P1. Both are read-only and optional; the disabled
# check runs without either. Organization.Read.All also covers subscribedSkus and the tenant name.
$script:DefaultScopes         = @('User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All', 'Reports.Read.All')
$script:NeverSignedInGraceDays = 30

$script:RuleLabels = @{
    DisabledLicensed      = 'Disabled account, still licensed'
    InactiveLicensed      = 'Inactive {0}+ days, still licensed'
    NeverSignedInLicensed = 'Never signed in, still licensed'
    RedundantLicensed     = 'Redundant license, already in a suite'
}

$script:RuleOrder = @{
    DisabledLicensed      = 1
    InactiveLicensed      = 2
    NeverSignedInLicensed = 3
    RedundantLicensed     = 4
}

# R4 overlap map: standalone skuPartNumber -> the suite SKUs that already include it. Deliberately
# conservative; only high-confidence, unambiguous inclusions so the tool never cries waste on a
# license the user actually needs. A user holding both the standalone and any listed suite has the
# standalone flagged as redundant (priced at the standalone's own price).
# Keep every part number used here (standalone and suite) present in $script:SkuFriendlyName too, so
# the report's "covered by" label shows a friendly suite name rather than the raw part number.
$script:RedundantInSuite = @{
    # Entra ID P1 is included in EMS, M365 E3/E5, and is a subset of Entra ID P2.
    AAD_PREMIUM          = @('AAD_PREMIUM_P2', 'EMS', 'EMSPREMIUM', 'SPE_E3', 'SPE_E5')
    # Entra ID P2 is included in EMS E5 and M365 E5 (note: O365 E5 does NOT include Entra P2).
    AAD_PREMIUM_P2       = @('EMSPREMIUM', 'SPE_E5')
    # EMS E3 is included in M365 E3/E5; EMS E5 is included in M365 E5.
    EMS                  = @('SPE_E3', 'SPE_E5')
    EMSPREMIUM           = @('SPE_E5')
    # Intune is included in EMS, M365 E3/E5, and the Intune Suite.
    INTUNE_A             = @('EMS', 'EMSPREMIUM', 'SPE_E3', 'SPE_E5', 'Microsoft_Intune_Suite')
    # Exchange Online (Plan 1) ships in every O365/M365 suite that includes email.
    EXCHANGESTANDARD     = @('STANDARDPACK', 'ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5',
        'O365_BUSINESS_ESSENTIALS', 'O365_BUSINESS_PREMIUM', 'SPB')
    # Exchange Online (Plan 2) ships in the E3/E5 tiers.
    EXCHANGEENTERPRISE   = @('ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5')
    # SharePoint (Plan 1) ships in every O365/M365 suite.
    SHAREPOINTSTANDARD   = @('STANDARDPACK', 'ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5',
        'O365_BUSINESS_ESSENTIALS', 'O365_BUSINESS_PREMIUM', 'SPB')
    # SharePoint (Plan 2) ships in the E3/E5 tiers.
    SHAREPOINTENTERPRISE = @('ENTERPRISEPACK', 'ENTERPRISEPREMIUM', 'SPE_E3', 'SPE_E5')
    # Power BI Pro is included in O365/M365 E5.
    POWER_BI_PRO         = @('ENTERPRISEPREMIUM', 'SPE_E5')
}

# Friendly display names for common SKUs. Falls back to the raw skuPartNumber.
$script:SkuFriendlyName = @{
    STANDARDPACK                      = 'Office 365 E1'
    ENTERPRISEPACK                    = 'Office 365 E3'
    ENTERPRISEPREMIUM                 = 'Office 365 E5'
    SPE_E3                            = 'Microsoft 365 E3'
    SPE_E5                            = 'Microsoft 365 E5'
    SPE_F1                            = 'Microsoft 365 F1'
    SPE_F3                            = 'Microsoft 365 F3'
    DESKLESSPACK                      = 'Office 365 F3'
    DEVELOPERPACK_E5                  = 'Microsoft 365 E5 (Developer)'
    SPB                               = 'Microsoft 365 Business Premium'
    O365_BUSINESS_PREMIUM             = 'Microsoft 365 Business Standard'
    O365_BUSINESS_ESSENTIALS          = 'Microsoft 365 Business Basic'
    O365_BUSINESS                     = 'Microsoft 365 Apps for Business'
    OFFICESUBSCRIPTION                = 'Microsoft 365 Apps for Enterprise'
    EXCHANGESTANDARD                  = 'Exchange Online (Plan 1)'
    EXCHANGEENTERPRISE                = 'Exchange Online (Plan 2)'
    EXCHANGEDESKLESS                  = 'Exchange Online Kiosk'
    SHAREPOINTSTANDARD                = 'SharePoint Online (Plan 1)'
    SHAREPOINTENTERPRISE              = 'SharePoint Online (Plan 2)'
    WACONEDRIVESTANDARD               = 'OneDrive for Business (Plan 1)'
    WACONEDRIVEENTERPRISE             = 'OneDrive for Business (Plan 2)'
    POWER_BI_PRO                      = 'Power BI Pro'
    PBI_PREMIUM_PER_USER              = 'Power BI Premium Per User'
    PROJECT_PLAN1                     = 'Project Plan 1'
    PROJECTPROFESSIONAL               = 'Project Plan 3'
    PROJECTPREMIUM                    = 'Project Plan 5'
    VISIOONLINE_PLAN1                 = 'Visio Plan 1'
    VISIOCLIENT                       = 'Visio Plan 2'
    Microsoft_365_Copilot             = 'Microsoft 365 Copilot'
    AAD_PREMIUM                       = 'Microsoft Entra ID P1'
    AAD_PREMIUM_P2                    = 'Microsoft Entra ID P2'
    EMS                               = 'Enterprise Mobility + Security E3'
    EMSPREMIUM                        = 'Enterprise Mobility + Security E5'
    INTUNE_A                          = 'Microsoft Intune Plan 1'
    Microsoft_Intune_Suite            = 'Microsoft Intune Suite'
    DEFENDER_ENDPOINT_P1              = 'Defender for Endpoint P1'
    WIN_DEF_ATP                       = 'Defender for Endpoint P2'
    ATP_ENTERPRISE                    = 'Defender for Office 365 (Plan 1)'
    THREAT_INTELLIGENCE               = 'Defender for Office 365 (Plan 2)'
    IDENTITY_THREAT_PROTECTION        = 'Microsoft 365 E5 Security'
    INFORMATION_PROTECTION_COMPLIANCE = 'Microsoft 365 E5 Compliance'
    RIGHTSMANAGEMENT                  = 'Azure Information Protection P1'
    WIN10_VDA_E3                      = 'Windows 10/11 Enterprise E3'
    WIN10_VDA_E5                      = 'Windows 10/11 Enterprise E5'
    MCOEV                             = 'Microsoft Teams Phone Standard'
    MCOMEETADV                        = 'Microsoft 365 Audio Conferencing'
    MCOPSTN1                          = 'Skype for Business PSTN Domestic Calling'
    TEAMS_PREMIUM                     = 'Microsoft Teams Premium'
    POWERAPPS_PER_USER                = 'Power Apps per user plan'
    FLOW_PER_USER                     = 'Power Automate per user plan'
    FLOW_FREE                         = 'Microsoft Power Automate Free'
    POWER_BI_STANDARD                 = 'Power BI (free)'
    TEAMS_EXPLORATORY                 = 'Microsoft Teams Exploratory'
    TEAMS_FREE                        = 'Microsoft Teams (Free)'
    POWERAPPS_VIRAL                   = 'Power Apps Plan 2 Trial'
}

# ----------------------------------------------------------------------------
# Helpers (pure)
# ----------------------------------------------------------------------------

function Get-LMProp {
    # Read a property from either a Hashtable or a PSObject, returning $null when absent.
    # Keeps the rule engine robust to PSObject (live Graph / fixtures) and Hashtable shapes.
    param($Object, [string]$Name)
    if ($null -eq $Object) { return $null }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return $Object[$Name] }
        return $null
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($prop) { return $prop.Value }
    return $null
}

function Format-LMMoney {
    param([long]$Cents, [string]$Currency = 'EUR')
    $value = [decimal]$Cents / 100
    '{0} {1}' -f $Currency, $value.ToString('N2', [System.Globalization.CultureInfo]::InvariantCulture)
}

function ConvertTo-LMDateOffset {
    param([string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return $null }
    try {
        return [datetimeoffset]::Parse(
            $Value,
            [System.Globalization.CultureInfo]::InvariantCulture,
            [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
    } catch {
        return $null
    }
}

function Get-LMLastActivity {
    # Most recent of the available signInActivity timestamps, or $null when none.
    param($SignInActivity)
    if ($null -eq $SignInActivity) { return $null }
    $raw = @(
        (Get-LMProp $SignInActivity 'lastSignInDateTime')
        (Get-LMProp $SignInActivity 'lastNonInteractiveSignInDateTime')
        (Get-LMProp $SignInActivity 'lastSuccessfulSignInDateTime')
    )
    $dates = foreach ($r in $raw) { $d = ConvertTo-LMDateOffset $r; if ($d) { $d } }
    if (-not $dates) { return $null }
    return ($dates | Sort-Object | Select-Object -Last 1)
}

function Get-LMSkuFriendlyName {
    param([string]$PartNumber)
    if ($PartNumber -and $script:SkuFriendlyName.ContainsKey($PartNumber)) { return $script:SkuFriendlyName[$PartNumber] }
    if ($PartNumber) { return $PartNumber }
    return '(unknown)'
}

function Get-LMCentTotal {
    # Integer accumulation in cents. Avoids Measure-Object -Sum, which returns a [double].
    param($Items)
    $sum = [long]0
    foreach ($item in @($Items)) { if ($null -ne $item) { $sum += [long]$item.MonthlyCents } }
    return $sum
}

# ----------------------------------------------------------------------------
# Pricing (pure)
# ----------------------------------------------------------------------------

function Get-LMPriceBook {
    # Load a price-book JSON file and normalise prices to integer cents.
    param([Parameter(Mandatory)][string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { throw "Price file not found: $Path" }
    $json = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
    $currency = if ($json.PSObject.Properties['currency']) { [string]$json.currency } else { 'EUR' }
    $period   = if ($json.PSObject.Properties['period'])   { [string]$json.period }   else { 'monthly' }
    $prices = @{}
    if ($json.PSObject.Properties['prices'] -and $json.prices) {
        foreach ($entry in $json.prices.PSObject.Properties) {
            $cents = [long][math]::Round(([decimal]$entry.Value) * 100, [System.MidpointRounding]::AwayFromZero)
            $prices[$entry.Name] = $cents
        }
    }
    [pscustomobject]@{
        Currency = $currency
        Period   = $period
        Prices   = $prices
        Path     = $Path
    }
}

function Resolve-LMPriceMap {
    # Build skuId -> { PartNumber; Cents; Priced } from subscribedSkus and a price book.
    param(
        [Parameter(Mandatory)]$SubscribedSkus,
        [Parameter(Mandatory)]$PriceBook
    )
    $map = @{}
    foreach ($sku in @($SubscribedSkus)) {
        $skuId = Get-LMProp $sku 'skuId'
        if (-not $skuId) { continue }
        $part  = Get-LMProp $sku 'skuPartNumber'
        $cents = [long]0
        $known = $false
        if ($part -and $PriceBook.Prices.ContainsKey($part)) {
            $known = $true
            $cents = [long]$PriceBook.Prices[$part]
        }
        $map[$skuId] = [pscustomobject]@{
            SkuId      = $skuId
            PartNumber = $part
            Cents      = $cents
            Known      = $known
            Priced     = ($cents -gt 0)
        }
    }
    return $map
}

function Get-LMInventory {
    # Per-SKU license inventory for the report: assigned (consumed) vs available (prepaid),
    # plus the catalog price and whether it is known. Covers every subscribed SKU.
    param(
        [Parameter(Mandatory)]$SubscribedSkus,
        [Parameter(Mandatory)]$PriceMap
    )
    $list = [System.Collections.Generic.List[object]]::new()
    foreach ($sku in @($SubscribedSkus)) {
        $skuId = Get-LMProp $sku 'skuId'
        if (-not $skuId) { continue }
        $part    = Get-LMProp $sku 'skuPartNumber'
        $prepaid = Get-LMProp $sku 'prepaidUnits'
        $enabled = if ($prepaid) { Get-LMProp $prepaid 'enabled' } else { $null }
        $entry   = if ($PriceMap.ContainsKey($skuId)) { $PriceMap[$skuId] } else { $null }
        $list.Add([pscustomobject]@{
                SkuId     = $skuId
                Part      = [string]$part
                Name      = Get-LMSkuFriendlyName $part
                Assigned  = [int](Get-LMProp $sku 'consumedUnits')
                Available = if ($null -ne $enabled) { [int]$enabled } else { 0 }
                Cents     = if ($entry) { [long]$entry.Cents } else { [long]0 }
                Known     = if ($entry) { [bool]$entry.Known } else { $false }
            })
    }
    return $list.ToArray()
}

# ----------------------------------------------------------------------------
# Rule engine (pure, no I/O)
# ----------------------------------------------------------------------------

function Invoke-LMRule {
    <#
    .SYNOPSIS
        Apply the waste rules (R1-R3) to already-fetched users and return findings.
    .DESCRIPTION
        Pure function. Takes plain objects plus an explicit -AsOf so results are
        deterministic and unit-testable. One finding per (user, paid SKU).
        Each user matches at most one rule (R1 disabled takes precedence).
    #>

    param(
        [Parameter(Mandatory)]$Users,
        [Parameter(Mandatory)][hashtable]$PriceMap,
        [Parameter(Mandatory)][datetimeoffset]$AsOf,
        [int]$InactiveDays = 90,
        [bool]$HasSignInData = $true,
        [int]$GraceDays = 30,
        [hashtable]$ActivityByUpn = $null
    )
    $findings = [System.Collections.Generic.List[object]]::new()

    foreach ($user in @($Users)) {
        $licenses = @(Get-LMProp $user 'assignedLicenses')
        if ($licenses.Count -eq 0) { continue }

        $billable = [System.Collections.Generic.List[object]]::new()
        foreach ($lic in $licenses) {
            $skuId = Get-LMProp $lic 'skuId'
            if ($skuId -and $PriceMap.ContainsKey($skuId)) {
                $entry = $PriceMap[$skuId]
                # Flag priced AND not-yet-priced (unknown) SKUs so the report can price them; skip known-free SKUs.
                if (-not $entry.Known -or $entry.Cents -gt 0) { $billable.Add($entry) }
            }
        }
        if ($billable.Count -eq 0) { continue }

        $enabled      = [bool](Get-LMProp $user 'accountEnabled')
        $userType     = [string](Get-LMProp $user 'userType')
        $rule         = $null
        $lastActivity = $null
        $daysInactive = $null

        if (-not $enabled) {
            # R1: disabled account still holding a paid license. Needs no activity data.
            $rule = 'DisabledLicensed'
        } else {
            # Resolve this user's activity. Two sources, mutually exclusive:
            # - $ActivityByUpn (usage-report fallback): authoritative PER USER. A user absent from the
            # map has no signal (the report only lists users with a covered workload), so R2/R3 are
            # skipped for them to avoid false-flagging e.g. an Entra-only account as never signed in.
            # - signInActivity on the user object (preferred path) when $HasSignInData.
            $activityAvailable = $false
            if ($ActivityByUpn) {
                $upn = [string](Get-LMProp $user 'userPrincipalName')
                $key = if ($upn) { $upn.ToLowerInvariant() } else { $null }
                if ($key -and $ActivityByUpn.ContainsKey($key)) {
                    $activityAvailable = $true
                    $raw = $ActivityByUpn[$key]
                    $lastActivity = if ($raw) { ConvertTo-LMDateOffset $raw } else { $null }
                }
            } elseif ($HasSignInData) {
                $activityAvailable = $true
                $lastActivity = Get-LMLastActivity (Get-LMProp $user 'signInActivity')
            }

            if ($activityAvailable) {
                if ($null -eq $lastActivity) {
                    # R3: never signed in, but only past a grace period so brand-new accounts are spared.
                    $created = ConvertTo-LMDateOffset (Get-LMProp $user 'createdDateTime')
                    if ($created -and (($AsOf - $created).TotalDays -gt $GraceDays)) {
                        $rule = 'NeverSignedInLicensed'
                    }
                } else {
                    # R2: licensed but inactive for at least InactiveDays.
                    $daysInactive = [int][math]::Floor(($AsOf - $lastActivity).TotalDays)
                    if ($daysInactive -ge $InactiveDays) { $rule = 'InactiveLicensed' }
                }
            }
            # No activity signal for this account => R2/R3 cannot be evaluated; the user is skipped
            # and the report says so.
        }

        if ($null -eq $rule) { continue }

        foreach ($p in $billable) {
            $findings.Add([pscustomobject]@{
                Rule              = $rule
                UserId            = Get-LMProp $user 'id'
                UserPrincipalName = Get-LMProp $user 'userPrincipalName'
                DisplayName       = Get-LMProp $user 'displayName'
                AccountEnabled    = $enabled
                UserType          = $userType
                SkuId             = $p.SkuId
                SkuPartNumber     = $p.PartNumber
                SkuName           = Get-LMSkuFriendlyName $p.PartNumber
                MonthlyCents      = [long]$p.Cents
                PriceKnown        = $p.Known
                LastActivity      = if ($lastActivity) { $lastActivity.UtcDateTime.ToString('yyyy-MM-dd') } else { $null }
                DaysInactive      = $daysInactive
                CoveredBy         = $null
            })
        }
    }
    return $findings.ToArray()
}

function Get-LMRedundantFinding {
    <#
    .SYNOPSIS
        R4: flag standalone licenses already covered by a suite the same user holds. Pure.
    .DESCRIPTION
        Runs only against users NOT already flagged by R1-R3 (passed via -ExcludeUserIds), so a
        disabled or inactive account's licenses are never double-counted. One finding per redundant
        standalone license, priced at the standalone's own cost. Uses the conservative
        $script:RedundantInSuite map so it never flags a license the user genuinely needs.
    #>

    param(
        [Parameter(Mandatory)]$Users,
        [Parameter(Mandatory)][hashtable]$PriceMap,
        [string[]]$ExcludeUserIds = @()
    )
    $exclude = @{}
    foreach ($id in @($ExcludeUserIds)) { if ($id) { $exclude[$id] = $true } }

    $findings = [System.Collections.Generic.List[object]]::new()
    foreach ($user in @($Users)) {
        $uid = Get-LMProp $user 'id'
        if ($uid -and $exclude.ContainsKey($uid)) { continue }

        $licenses = @(Get-LMProp $user 'assignedLicenses')
        if ($licenses.Count -lt 2) { continue }

        # Resolve held SKUs to part numbers via the price map (skuId -> PartNumber).
        $heldParts   = @{}
        $partToEntry = @{}
        foreach ($lic in $licenses) {
            $skuId = Get-LMProp $lic 'skuId'
            if ($skuId -and $PriceMap.ContainsKey($skuId)) {
                $entry = $PriceMap[$skuId]
                if ($entry.PartNumber) { $heldParts[$entry.PartNumber] = $true; $partToEntry[$entry.PartNumber] = $entry }
            }
        }
        if ($heldParts.Count -lt 2) { continue }

        foreach ($standalone in $script:RedundantInSuite.Keys) {
            if (-not $heldParts.ContainsKey($standalone)) { continue }
            $covering = $null
            foreach ($s in $script:RedundantInSuite[$standalone]) {
                if ($heldParts.ContainsKey($s)) { $covering = $s; break }
            }
            if (-not $covering) { continue }

            $entry = $partToEntry[$standalone]
            # Mirror R1-R3: surface priced and not-yet-priced SKUs (so the report can price them); skip known-free.
            if ($entry.Known -and $entry.Cents -le 0) { continue }

            $findings.Add([pscustomobject]@{
                Rule              = 'RedundantLicensed'
                UserId            = $uid
                UserPrincipalName = Get-LMProp $user 'userPrincipalName'
                DisplayName       = Get-LMProp $user 'displayName'
                AccountEnabled    = [bool](Get-LMProp $user 'accountEnabled')
                UserType          = [string](Get-LMProp $user 'userType')
                SkuId             = $entry.SkuId
                SkuPartNumber     = $entry.PartNumber
                SkuName           = Get-LMSkuFriendlyName $entry.PartNumber
                MonthlyCents      = [long]$entry.Cents
                PriceKnown        = $entry.Known
                LastActivity      = $null
                DaysInactive      = $null
                CoveredBy         = Get-LMSkuFriendlyName $covering
            })
        }
    }
    return $findings.ToArray()
}

function Get-LMScanSummary {
    # Aggregate findings into the report summary. Pure.
    param(
        $Findings,
        [int]$UsersScanned,
        [string]$Currency = 'EUR',
        [bool]$HasSignInData = $true,
        [int]$InactiveDays = 90,
        [string]$ActivitySource = 'signInActivity'
    )
    $all = @($Findings | Where-Object { $null -ne $_ })
    $totalCents = Get-LMCentTotal $all

    $byRule = foreach ($group in ($all | Group-Object Rule)) {
        $label = $script:RuleLabels[$group.Name] -f $InactiveDays
        [pscustomobject]@{
            Rule         = $group.Name
            Label        = $label
            Users        = (@($group.Group | Select-Object -ExpandProperty UserId -Unique)).Count
            Findings     = $group.Count
            MonthlyCents = Get-LMCentTotal $group.Group
        }
    }

    $bySku = foreach ($group in ($all | Group-Object SkuPartNumber)) {
        [pscustomobject]@{
            SkuPartNumber = $group.Name
            SkuName       = Get-LMSkuFriendlyName $group.Name
            Count         = $group.Count
            MonthlyCents  = Get-LMCentTotal $group.Group
        }
    }

    $guestFindings = @($all | Where-Object { [string](Get-LMProp $_ 'UserType') -eq 'Guest' })

    [pscustomobject]@{
        Currency          = $Currency
        UsersScanned      = $UsersScanned
        HasSignInData     = $HasSignInData
        ActivitySource    = $ActivitySource
        InactiveDays      = $InactiveDays
        TotalMonthlyCents = $totalCents
        TotalAnnualCents  = $totalCents * 12
        FindingCount      = $all.Count
        GuestFindingCount = $guestFindings.Count
        ByRule            = @($byRule | Sort-Object { $script:RuleOrder[$_.Rule] })
        BySku             = @($bySku | Sort-Object -Property MonthlyCents -Descending)
    }
}

# ----------------------------------------------------------------------------
# Scope guard (pure)
# ----------------------------------------------------------------------------

function Assert-LMReadOnlyScope {
    # Refuse to run with any write-capable scope. LicenseMeter Scan only ever reads.
    param([string[]]$Scopes)
    $bad = foreach ($s in @($Scopes)) {
        if ($s -match '(?i)(write|\.send|\.manage|manage\.|delete|owns|fullcontrol)') { $s }
    }
    if ($bad) {
        throw "LicenseMeter Scan is read-only and refuses write scope(s): $($bad -join ', ')"
    }
}

function Assert-LMGraphContextReadOnly {
    # Reused Graph sessions must satisfy the same read-only invariant as sessions we create.
    param($Context)
    $granted = Get-LMProp $Context 'Scopes'
    if ($granted) { Assert-LMReadOnlyScope -Scopes @($granted) }
}

# ----------------------------------------------------------------------------
# Microsoft Graph I/O (via MgGraphCommunity)
# ----------------------------------------------------------------------------

function Connect-LMGraph {
    <#
    .SYNOPSIS
        Sign in to Microsoft Graph with read-only scopes via MgGraphCommunity.
    .DESCRIPTION
        Wraps Connect-MgGraphCommunity. Interactive browser sign-in by default;
        -UseDeviceCode for headless sessions. No app registration is required.
    #>

    [CmdletBinding()]
    param(
        [string[]]$Scopes = $script:DefaultScopes,
        [string]$TenantId,
        [string]$ClientId,
        [switch]$UseDeviceCode
    )
    if (-not (Get-Command Connect-MgGraphCommunity -ErrorAction SilentlyContinue)) {
        throw "MgGraphCommunity is not installed. Run: Install-Module MgGraphCommunity -Scope CurrentUser"
    }
    Assert-LMReadOnlyScope -Scopes $Scopes

    $params = @{ Scopes = $Scopes; NoWelcome = $true; ErrorAction = 'Stop' }
    if ($UseDeviceCode) { $params['UseDeviceCode'] = $true }
    if ($TenantId)      { $params['TenantId'] = $TenantId }
    if ($ClientId)      { $params['ClientId'] = $ClientId }

    Connect-MgGraphCommunity @params | Out-Null

    $ctx = Get-MgGraphCommunityContext
    Assert-LMGraphContextReadOnly -Context $ctx
    return $ctx
}

function Test-LMConnected {
    $ctx = $null
    try { $ctx = Get-MgGraphCommunityContext } catch { return $false }
    return [bool]$ctx
}

function Invoke-LMGraphWithRetry {
    # Run a Graph request with bounded exponential backoff on transient failures. MgGraphCommunity
    # already retries 401/429/504 once internally; this adds an outer guard so a large-tenant scan
    # survives sustained throttling (429/503/504/timeouts) instead of aborting on a single burst.
    param([Parameter(Mandatory)][scriptblock]$Action, [int]$MaxAttempts = 5)
    $attempt = 0
    while ($true) {
        $attempt++
        try {
            return & $Action
        } catch {
            $msg = "$($_.Exception.Message)"
            $transient = $msg -match '(?i)(\b429\b|\b503\b|\b504\b|throttl|too many requests|timed out|temporarily|service unavailable|gateway)'
            if (-not $transient -or $attempt -ge $MaxAttempts) { throw }
            $delay = [int][math]::Min(60, [math]::Pow(2, $attempt))
            Write-Verbose "Transient Graph error (attempt $attempt/$MaxAttempts): $msg. Backing off $delay s."
            Start-Sleep -Seconds $delay
        }
    }
}

function Invoke-LMGraphList {
    # GET a Graph collection, following @odata.nextLink with host validation, with progress and retry.
    param([Parameter(Mandatory)][string]$Uri, [string]$Activity = 'Reading from Microsoft Graph')
    $results = [System.Collections.Generic.List[object]]::new()
    $next = $Uri
    $page = 0
    try {
        while ($next) {
            # Validate every request URL (the initial one and each @odata.nextLink) is an
            # absolute Graph URL. Rejects off-host follow links and relative-link bypasses.
            if ($next -notmatch '^https://graph\.microsoft\.com/') {
                throw "Refusing to request a non-Graph URL: $next"
            }
            $page++
            Write-Progress -Id 1 -Activity $Activity -Status ("Page {0} ({1} records so far)" -f $page, $results.Count)
            $response = Invoke-LMGraphWithRetry { Invoke-MgGraphCommunityRequest -Method GET -Uri $next -OutputType PSObject }
            $value = Get-LMProp $response 'value'
            if ($value) { foreach ($item in @($value)) { $results.Add($item) } }
            $next = Get-LMProp $response '@odata.nextLink'
        }
    } finally {
        Write-Progress -Id 1 -Activity $Activity -Completed
    }
    return $results.ToArray()
}

function Test-LMSignInActivityUnavailableError {
    # Only degrade when the signInActivity select itself is rejected for permission/license reasons.
    param([string]$Reason)
    return ($Reason -match '(?i)(signInActivity|AuditLog\.Read\.All|premium|Entra ID P1|tenant.*licen|authorization_requestdenied|forbidden|\b403\b|insufficient privileges)')
}

function Get-LMUsagePeriod {
    # Smallest supported usage-report window (D7/D30/D90/D180) that covers the inactivity threshold.
    param([int]$InactiveDays = 90)
    foreach ($d in 7, 30, 90, 180) { if ($InactiveDays -le $d) { return "D$d" } }
    return 'D180'
}

function ConvertFrom-LMUsageReportCsv {
    # Parse the getOffice365ActiveUserDetail CSV into UPN(lower) -> last-activity 'yyyy-MM-dd' (or $null
    # when the user appears with no activity in the window). Detects concealed-name tenants, where the
    # UPN column holds opaque tokens instead of addresses, making the per-user join impossible.
    param([Parameter(Mandatory)][string]$Csv)
    # The Graph usage report is served as UTF-8 with a byte-order mark; strip a leading BOM so the
    # first CSV header parses cleanly (verified against a live tenant: the body starts with EF BB BF).
    $Csv = $Csv.TrimStart([char]0xFEFF)
    $rows = @($Csv | ConvertFrom-Csv)
    $activityCols = @(
        'Exchange Last Activity Date', 'OneDrive Last Activity Date', 'SharePoint Last Activity Date',
        'Skype For Business Last Activity Date', 'Yammer Last Activity Date', 'Teams Last Activity Date')
    $map = @{}
    $total = 0
    $emailish = 0
    foreach ($row in $rows) {
        $upn = [string](Get-LMProp $row 'User Principal Name')
        if ([string]::IsNullOrWhiteSpace($upn)) { continue }
        $total++
        if ($upn -match '@') { $emailish++ }
        $newest = $null
        foreach ($col in $activityCols) {
            $d = ConvertTo-LMDateOffset ([string](Get-LMProp $row $col))
            if ($d -and (-not $newest -or $d -gt $newest)) { $newest = $d }
        }
        $map[$upn.ToLowerInvariant()] = if ($newest) { $newest.UtcDateTime.ToString('yyyy-MM-dd') } else { $null }
    }
    # Concealed-name reports replace UPNs with opaque IDs; if few values look like addresses, give up
    # rather than mis-join. Admins can turn this off in the M365 admin center (we never change it).
    $concealed = ($total -gt 0 -and ($emailish / [double]$total) -lt 0.5)
    [pscustomobject]@{ Map = $map; Concealed = $concealed; Available = ($total -gt 0 -and -not $concealed) }
}

function Get-LMUsageActivityMap {
    # Fallback activity signal for tenants without Entra ID P1: the Microsoft 365 active-user usage
    # report (Reports.Read.All). Returns the parsed map plus availability/concealment flags. Never
    # throws: any failure (no scope, admin role, concealment, unexpected shape) degrades to unavailable.
    param([int]$InactiveDays = 90)
    $period = Get-LMUsagePeriod -InactiveDays $InactiveDays
    $uri = "$script:GraphBase/reports/getOffice365ActiveUserDetail(period='$period')"
    try {
        Write-Progress -Id 1 -Activity 'Reading from Microsoft Graph' -Status 'Usage report (sign-in fallback)'
        # PSObject output returns the raw body for non-JSON responses; the report is CSV. MgGraphCommunity
        # follows the 302 to the preauthenticated download URL internally (PS7 strips auth cross-origin).
        $resp = Invoke-LMGraphWithRetry { Invoke-MgGraphCommunityRequest -Method GET -Uri $uri -OutputType PSObject }
        $csv = if ($resp -is [string]) { $resp } else { [string](Get-LMProp $resp 'Content') }
        if ([string]::IsNullOrWhiteSpace($csv) -or $csv -notmatch 'User Principal Name') {
            $shape = if ($null -ne $resp) { $resp.GetType().FullName } else { 'null' }
            Write-Verbose "Usage report returned no usable CSV (response shape: $shape); inactivity checks stay disabled."
            return [pscustomobject]@{ Map = @{}; Concealed = $false; Available = $false }
        }
        return ConvertFrom-LMUsageReportCsv -Csv $csv
    } catch {
        Write-Verbose "Usage-report fallback unavailable: $($_.Exception.Message)"
        return [pscustomobject]@{ Map = @{}; Concealed = $false; Available = $false }
    }
}

function Get-LMUser {
    # Fetch licensed-relevant user fields. Prefers signInActivity (Entra ID P1 + AuditLog.Read.All).
    # If that select is rejected, retries without it and tries the usage-report fallback so inactivity
    # checks still work on tenants without P1. Reports which activity signal (if any) was used.
    [CmdletBinding()]
    param([int]$InactiveDays = 90)
    $uriFull = "$script:GraphBase/users?`$select=$script:UserSelect&`$top=999"
    try {
        $users = Invoke-LMGraphList -Uri $uriFull -Activity 'Reading users'
        return [pscustomobject]@{ Users = $users; HasSignInData = $true; ActivitySource = 'signInActivity'; ActivityByUpn = $null }
    } catch {
        # Degrade gracefully only for the "no Entra P1 / no AuditLog.Read.All" case, where
        # selecting signInActivity is rejected. Re-throw anything else (throttling, 5xx,
        # network, or our own non-Graph-URL guard) so real failures are never hidden.
        $reason = "$($_.Exception.Message)"
        if (-not (Test-LMSignInActivityUnavailableError -Reason $reason)) {
            throw
        }
        Write-Verbose "signInActivity unavailable (permission or license): $reason. Retrying without it."
        $uriNoSignIn = "$script:GraphBase/users?`$select=$script:UserSelectNoSignIn&`$top=999"
        $users = Invoke-LMGraphList -Uri $uriNoSignIn -Activity 'Reading users'

        # Try the usage-report fallback so inactive / never-signed-in checks still run without P1.
        $usage = Get-LMUsageActivityMap -InactiveDays $InactiveDays
        if ($usage.Available) {
            return [pscustomobject]@{ Users = $users; HasSignInData = $true; ActivitySource = 'usageReport'; ActivityByUpn = $usage.Map }
        }
        $src = if ($usage.Concealed) { 'concealed' } else { 'none' }
        return [pscustomobject]@{ Users = $users; HasSignInData = $false; ActivitySource = $src; ActivityByUpn = $null }
    }
}

function Get-LMSubscribedSku {
    return Invoke-LMGraphList -Uri "$script:GraphBase/subscribedSkus" -Activity 'Reading subscribed SKUs'
}

# ----------------------------------------------------------------------------
# Reporting
# ----------------------------------------------------------------------------

function Get-LMActivitySourceLabel {
    # Human-readable activity-signal source for the report header.
    param([string]$ActivitySource)
    switch ($ActivitySource) {
        'signInActivity' { 'sign-in logs (Entra ID P1)' }
        'usageReport'    { 'Microsoft 365 usage reports' }
        'concealed'      { 'unavailable (usage report names are concealed)' }
        default          { 'unavailable (no Entra ID P1)' }
    }
}

function Format-LMConsoleReport {
    param($Summary, [string]$TenantLabel = 'your tenant', [string]$ReportPath)
    $cur = $Summary.Currency
    $srcLabel = Get-LMActivitySourceLabel ([string]$Summary.ActivitySource)

    Write-Host ''
    Write-Host 'LicenseMeter Scan' -ForegroundColor White -NoNewline
    Write-Host " v$script:ModuleVersion" -ForegroundColor DarkGray
    Write-Host ("Tenant: {0} Users scanned: {1} Activity: {2}" -f $TenantLabel, $Summary.UsersScanned, $srcLabel) -ForegroundColor DarkGray
    Write-Host ''
    Write-Host ("Identified waste: {0} / month ({1} / year)" -f (Format-LMMoney $Summary.TotalMonthlyCents $cur), (Format-LMMoney $Summary.TotalAnnualCents $cur)) -ForegroundColor Yellow
    Write-Host ("Across {0} findings in {1} categories." -f $Summary.FindingCount, $Summary.ByRule.Count)
    Write-Host ''
    foreach ($r in $Summary.ByRule) {
        Write-Host (" {0,-42}{1,4} users {2}" -f $r.Label, $r.Users, (Format-LMMoney $r.MonthlyCents $cur))
    }
    if ($Summary.BySku.Count -gt 0) {
        Write-Host ''
        Write-Host 'Top wasted licenses:'
        foreach ($s in (@($Summary.BySku) | Select-Object -First 5)) {
            Write-Host (" {0,-30}{1,3} {2}" -f $s.SkuName, $s.Count, (Format-LMMoney $s.MonthlyCents $cur))
        }
    }
    if (-not $Summary.HasSignInData) {
        Write-Host ''
        $why = if ([string]$Summary.ActivitySource -eq 'concealed') {
            'the usage report has concealed user names'
        } else {
            'this tenant lacks Entra ID P1 / AuditLog.Read.All and the usage-report fallback was unavailable'
        }
        Write-Host ("Note: inactive and never-signed-in checks were skipped ({0}). The disabled and redundant checks still ran." -f $why) -ForegroundColor DarkYellow
    }
    if ($Summary.GuestFindingCount -gt 0) {
        Write-Host ''
        Write-Host ("Note: {0} finding(s) are on guest (external) accounts." -f $Summary.GuestFindingCount) -ForegroundColor DarkYellow
    }
    Write-Host ''
    Write-Host 'Figures use list prices. Edit prices.json for your contract.' -ForegroundColor DarkGray
    $tail = 'Read-only scan. Nothing left your tenant.'
    if ($ReportPath) { $tail += " Report: $ReportPath" }
    Write-Host $tail -ForegroundColor DarkGray
    Write-Host ''
}

function Protect-LMCsvCell {
    # Neutralise spreadsheet formula injection for tenant-controlled text fields: a leading
    # =, +, -, @, tab, or CR makes Excel/Sheets evaluate the cell as a formula on open.
    param([string]$Value)
    if ([string]::IsNullOrEmpty($Value)) { return $Value }
    if ($Value -match '^[=+\-@\t\r]') { return "'" + $Value }
    return $Value
}

function ConvertTo-LMFindingRow {
    # Flatten findings into CSV export rows.
    param($Findings, [string]$Currency = 'EUR')
    foreach ($f in @($Findings)) {
        [pscustomobject]@{
            Rule              = $f.Rule
            DisplayName       = Protect-LMCsvCell $f.DisplayName
            UserPrincipalName = Protect-LMCsvCell $f.UserPrincipalName
            AccountEnabled    = $f.AccountEnabled
            UserType          = Protect-LMCsvCell ([string]$f.UserType)
            SkuPartNumber     = Protect-LMCsvCell $f.SkuPartNumber
            SkuName           = Protect-LMCsvCell $f.SkuName
            MonthlyCost       = ([decimal]$f.MonthlyCents / 100).ToString('0.00', [System.Globalization.CultureInfo]::InvariantCulture)
            Currency          = $Currency
            LastActivity      = $f.LastActivity
            DaysInactive      = $f.DaysInactive
            CoveredBy         = Protect-LMCsvCell ([string]$f.CoveredBy)
        }
    }
}

function ConvertTo-LMHtmlEncoded {
    param([string]$Text)
    if ($null -eq $Text) { return '' }
    return [System.Net.WebUtility]::HtmlEncode($Text)
}

function Get-LMHtmlReport {
    # Interactive, self-contained HTML report: a full license inventory (assigned vs available)
    # plus the waste findings. Prices are editable in the browser and every total recalculates
    # live (these edits are for exploration; use -PriceFile for permanent prices).
    param($Summary, $Findings, $Inventory, [string]$TenantLabel = 'your tenant')
    $cur = if ($Summary.Currency) { [string]$Summary.Currency } else { 'EUR' }
    $generated = ([datetimeoffset]::UtcNow).ToString('yyyy-MM-dd HH:mm') + ' UTC'

    $shortLabel = @{
        DisabledLicensed      = 'Disabled'
        InactiveLicensed      = 'Inactive'
        NeverSignedInLicensed = 'Never signed in'
        RedundantLicensed     = 'Redundant'
    }

    $fData = foreach ($f in @($Findings)) {
        # The "detail" column shows the last activity for R1-R3, and the covering suite for R4.
        $detail = if ([string]$f.Rule -eq 'RedundantLicensed') {
            if ($f.CoveredBy) { 'in ' + [string]$f.CoveredBy } else { 'in a suite' }
        } elseif ($f.LastActivity) { [string]$f.LastActivity } else { 'never' }
        [pscustomobject]@{
            name    = [string]$f.DisplayName
            upn     = [string]$f.UserPrincipalName
            rule    = [string]$f.Rule
            label   = $(if ($shortLabel.ContainsKey([string]$f.Rule)) { $shortLabel[[string]$f.Rule] } else { [string]$f.Rule })
            last    = $detail
            guest   = ([string]$f.UserType -eq 'Guest')
            sku     = [string]$f.SkuPartNumber
            skuName = [string]$f.SkuName
        }
    }

    $invData = foreach ($it in @($Inventory)) {
        if (-not $it) { continue }
        [pscustomobject]@{
            name      = [string]$it.Name
            part      = [string]$it.Part
            assigned  = [int]$it.Assigned
            available = [int]$it.Available
            amount    = [math]::Round(([decimal]$it.Cents / 100), 2)
            known     = [bool]$it.Known
        }
    }

    $data = [pscustomobject]@{
        currency       = $cur
        tenant         = [string]$TenantLabel
        usersScanned   = [int]$Summary.UsersScanned
        p1             = [bool]$Summary.HasSignInData
        activitySource = Get-LMActivitySourceLabel ([string]$Summary.ActivitySource)
        inactiveDays   = [int]$Summary.InactiveDays
        generated      = $generated
        ruleLabels     = [ordered]@{
            DisabledLicensed      = 'Disabled account, still licensed'
            InactiveLicensed      = "Inactive $([int]$Summary.InactiveDays)+ days, still licensed"
            NeverSignedInLicensed = 'Never signed in, still licensed'
            RedundantLicensed     = 'Redundant license, already in a suite'
        }
        findings  = @($fData)
        inventory = @($invData)
    }

    $dataJson = ($data | ConvertTo-Json -Depth 6 -Compress)
    # Neutralise <, >, & so tenant-controlled strings cannot break out of the <script> element.
    $dataJson = $dataJson.Replace('<', '\u003c').Replace('>', '\u003e').Replace('&', '\u0026')

    $template = @'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LicenseMeter Scan report</title>
<style>
  :root { --paper:#faf8f3; --ink:#1c1a16; --rust:#bc3e12; --muted:#6f6a5f; --line:#e7e2d8; --card:#fffdf9; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--paper); color:var(--ink);
         font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
  .serif { font-family:'Iowan Old Style','Palatino Linotype',Palatino,'Hoefler Text',Georgia,ui-serif,serif; }
  .mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.78rem; color:var(--muted); }
  .dim { color:var(--muted); }
  .wrap { max-width:860px; margin:0 auto; padding:52px 44px 60px; }
  .topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:8px; }
  .brand { display:flex; align-items:center; gap:11px; }
  .wordmark { font-size:1.45rem; font-weight:600; letter-spacing:-0.01em; }
  .pill { border:1px solid var(--rust); color:var(--rust); border-radius:999px; font-size:.67rem;
          font-weight:700; text-transform:uppercase; letter-spacing:.08em; padding:5px 11px; white-space:nowrap; }
  .meta { color:var(--muted); font-size:.82rem; margin:0 0 28px; }
  .hero { border:1px solid var(--line); background:var(--card); border-radius:16px; padding:28px 30px; margin-bottom:13px; }
  .hero-label { text-transform:uppercase; letter-spacing:.09em; font-size:.71rem; font-weight:700; color:var(--muted); margin-bottom:12px; }
  .hero-num { font-size:3.5rem; line-height:.95; font-weight:700; color:var(--rust); font-variant-numeric:tabular-nums lining-nums; }
  .hero-num .per { font-size:1.25rem; color:var(--muted); font-weight:600; font-family:-apple-system,'Segoe UI',sans-serif; }
  .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; }
  .card { border:1px solid var(--line); background:var(--card); border-radius:13px; padding:17px 19px; }
  .card-num { font-size:1.65rem; font-weight:700; line-height:1.1; font-variant-numeric:tabular-nums lining-nums; }
  .card-lbl { color:var(--muted); font-size:.77rem; margin-top:3px; }
  h2 { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--muted); font-weight:700; margin:32px 0 9px; }
  .prices-hd { display:flex; align-items:center; justify-content:space-between; gap:14px; margin-top:34px; }
  .prices-hd h2 { margin:0; }
  .hint { text-transform:none; letter-spacing:0; font-weight:400; color:var(--muted); font-size:.74rem; margin-left:10px; }
  button { font:inherit; font-size:.78rem; font-weight:600; color:var(--rust); background:var(--paper);
           border:1px solid var(--rust); border-radius:8px; padding:6px 13px; cursor:pointer; white-space:nowrap; }
  button:hover { background:var(--rust); color:var(--paper); }
  .controls { display:flex; align-items:center; gap:10px; }
  .curlbl { font-size:.76rem; color:var(--muted); display:flex; align-items:center; gap:6px; white-space:nowrap; }
  select { font:inherit; font-size:.8rem; color:var(--ink); background:#fff; border:1px solid var(--line); border-radius:8px; padding:5px 8px; cursor:pointer; }
  select:focus { outline:none; border-color:var(--rust); }
  table { border-collapse:collapse; width:100%; font-size:.85rem; }
  thead th { text-align:left; font-size:.66rem; text-transform:uppercase; letter-spacing:.05em; white-space:nowrap;
             color:var(--muted); font-weight:700; padding:0 10px 8px; border-bottom:2px solid var(--ink); }
  tbody td { padding:7px 10px; border-bottom:1px solid var(--line); vertical-align:middle; }
  td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
  input[type=number] { width:7rem; text-align:right; font:inherit; font-variant-numeric:tabular-nums;
                       border:1px solid var(--line); border-radius:6px; padding:4px 7px; background:#fff; }
  input[type=number]:focus { outline:none; border-color:var(--rust); }
  .needs { color:var(--rust); font-size:.66rem; font-weight:700; text-transform:uppercase; letter-spacing:.04em; margin-left:8px; }
  .badge { display:inline-block; margin-left:6px; padding:1px 6px; border-radius:999px; font-size:.6rem; font-weight:700;
           text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border:1px solid var(--line); vertical-align:middle; }
  .clear { font-size:1.05rem; margin-top:24px; color:var(--ink); }
  .foot { margin-top:38px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:.78rem; line-height:1.5; }
  .foot a { color:var(--rust); text-decoration:none; }
  @media print {
    body { background:#fff; }
    .wrap { max-width:none; padding:0 12px; }
    .pill, button, .controls { display:none !important; }
    .hero, .card, table { break-inside:avoid; }
    input[type=number] { border:none; padding:0; background:transparent; width:auto; }
    thead th { border-bottom:1px solid #000; }
    .foot a { color:inherit; text-decoration:underline; }
  }
</style>
</head>
<body>
  <div class="wrap">
    <div class="topbar">
      <div class="brand">
        <svg width="29" height="29" viewBox="0 0 32 32" aria-hidden="true"><rect x="6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="14.3" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><rect x="22.6" y="7" width="3.4" height="18" rx="1.2" fill="#1c1a16"/><line x1="4" y1="24.5" x2="28" y2="7.5" stroke="#bc3e12" stroke-width="3.2" stroke-linecap="round"/></svg>
        <span class="wordmark serif">LicenseMeter Scan</span>
      </div>
      <span class="pill">Read-only</span>
    </div>
    <p class="meta" id="meta"></p>

    <div class="hero">
      <div class="hero-label">Identified Microsoft 365 license waste</div>
      <div class="hero-num serif" id="heroNum"></div>
    </div>

    <div class="cards">
      <div class="card"><div class="card-num serif" id="cardAnnual"></div><div class="card-lbl">identified per year</div></div>
      <div class="card"><div class="card-num serif" id="cardAccounts"></div><div class="card-lbl">accounts flagged</div></div>
      <div class="card"><div class="card-num serif" id="cardUsers"></div><div class="card-lbl">users scanned</div></div>
    </div>

    <div class="prices-hd">
      <h2>Licenses in your tenant <span class="hint">edit any price to update the figures</span></h2>
      <div class="controls">
        <label class="curlbl">Currency <select id="curSel"></select></label>
      </div>
    </div>
    <table>
      <thead><tr><th>License</th><th>SKU part number</th><th class="num">Assigned</th><th class="num">Available</th><th class="num">Price / seat</th><th class="num">Monthly (assigned)</th></tr></thead>
      <tbody id="invRows"></tbody>
    </table>

    <div id="report">
      <h2>Waste by category</h2>
      <table><thead><tr><th>Category</th><th class="num">Users</th><th class="num">Monthly</th></tr></thead><tbody id="catRows"></tbody></table>

      <h2>Waste by license</h2>
      <table><thead><tr><th>License</th><th class="num">Seats</th><th class="num">Monthly</th></tr></thead><tbody id="skuRows"></tbody></table>

      <h2>Flagged accounts</h2>
      <table><thead><tr><th>Name</th><th>Account</th><th>Finding</th><th>Last activity / detail</th><th>License</th><th class="num">Monthly</th></tr></thead><tbody id="acctRows"></tbody></table>
    </div>

    <p class="clear" id="empty" style="display:none">No license waste found. Every paid license is assigned to an active, enabled account. Your full license inventory is shown above.</p>

    <p class="foot">Prices start from public list-price estimates; edit any of them above to fit your contract. Read-only scan, generated locally. Nothing left your tenant. For continuous, cross-vendor monitoring see <a href="https://www.licensemeter.com">licensemeter.com</a>.</p>
  </div>

<script id="lm-data" type="application/json">__LM_DATA__</script>
<script>
(function () {
  var data = JSON.parse(document.getElementById('lm-data').textContent);
  var cur = data.currency || 'EUR';
  function $(id) { return document.getElementById(id); }
  function esc(s) { var d = document.createElement('div'); d.textContent = (s == null ? '' : String(s)); return d.innerHTML; }
  function fmt(cents) { return cur + ' ' + (cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
  function num(n) { return Number(n || 0).toLocaleString('en-US'); }

  $('meta').textContent = 'Tenant: ' + data.tenant + ' \u00b7 Users scanned: ' + data.usersScanned +
    ' \u00b7 Activity: ' + (data.activitySource || (data.p1 ? 'sign-in logs' : 'unavailable')) +
    ' \u00b7 Generated: ' + data.generated;

  // License inventory (always shown) doubles as the editable price table.
  var inv = (data.inventory || []).slice().sort(function (a, b) {
    return (a.name || a.part).localeCompare(b.name || b.part);
  });
  var invByPart = {};
  inv.forEach(function (it) { invByPart[it.part] = it; });
  $('invRows').innerHTML = inv.map(function (it) {
    var avail = it.available >= 100000 ? '<span class="dim">unlimited</span>' : num(it.available);
    return '<tr><td>' + esc(it.name || it.part) + '</td><td class="mono">' + esc(it.part) + '</td>' +
      '<td class="num">' + num(it.assigned) + '</td><td class="num">' + avail + '</td>' +
      '<td class="num"><input type="number" min="0" step="0.01" data-sku="' + esc(it.part) + '" value="' + (it.amount || 0) + '">' +
      (it.known ? '' : '<span class="needs">set price</span>') + '</td>' +
      '<td class="num" data-cost="' + esc(it.part) + '"></td></tr>';
  }).join('');

  // [code, name, region]; region drives the flag emoji (EUR gives the EU/European flag).
  var currencies = [
    ['USD', 'US Dollar', 'US'], ['EUR', 'Euro', 'EU'], ['GBP', 'British Pound', 'GB'], ['CAD', 'Canadian Dollar', 'CA'],
    ['AUD', 'Australian Dollar', 'AU'], ['CHF', 'Swiss Franc', 'CH'], ['JPY', 'Japanese Yen', 'JP'], ['INR', 'Indian Rupee', 'IN'],
    ['BRL', 'Brazilian Real', 'BR'], ['SEK', 'Swedish Krona', 'SE'], ['NOK', 'Norwegian Krone', 'NO'], ['DKK', 'Danish Krone', 'DK'],
    ['SGD', 'Singapore Dollar', 'SG'], ['NZD', 'New Zealand Dollar', 'NZ'], ['ZAR', 'South African Rand', 'ZA'], ['AED', 'UAE Dirham', 'AE']
  ];
  // Build a flag emoji from a 2-letter region code via regional-indicator code points (keeps the source ASCII).
  function flagOf(cc) {
    if (!cc) return '';
    return cc.toUpperCase().replace(/[A-Z]/g, function (ch) { return String.fromCodePoint(0x1F1E6 + ch.charCodeAt(0) - 65); });
  }
  if (!currencies.some(function (c) { return c[0] === cur; })) currencies.unshift([cur, cur, '']);
  var curSel = $('curSel');
  curSel.innerHTML = currencies.map(function (c) {
    var f = flagOf(c[2]);
    return '<option value="' + c[0] + '"' + (c[0] === cur ? ' selected' : '') + '>' + (f ? f + ' ' : '') + c[0] + ' (' + c[1] + ')</option>';
  }).join('');
  curSel.addEventListener('change', function () { cur = curSel.value; recompute(); });

  function readPrices() {
    var p = {}, inputs = document.querySelectorAll('input[data-sku]');
    for (var i = 0; i < inputs.length; i++) {
      var v = parseFloat(inputs[i].value);
      p[inputs[i].getAttribute('data-sku')] = isNaN(v) ? 0 : Math.round(v * 100);
    }
    return p;
  }

  var order = ['DisabledLicensed', 'InactiveLicensed', 'NeverSignedInLicensed', 'RedundantLicensed'];

  function recompute() {
    var prices = readPrices();

    // Inventory: monthly cost of assigned seats, live.
    var costCells = document.querySelectorAll('td[data-cost]');
    for (var i = 0; i < costCells.length; i++) {
      var part = costCells[i].getAttribute('data-cost');
      var it = invByPart[part];
      costCells[i].textContent = fmt((prices[part] || 0) * (it ? it.assigned : 0));
    }

    // Waste, from the findings.
    var total = 0, byRule = {}, ruleUsers = {}, bySku = {}, accounts = {};
    data.findings.forEach(function (f) {
      var c = prices[f.sku] || 0;
      total += c;
      byRule[f.rule] = (byRule[f.rule] || 0) + c;
      (ruleUsers[f.rule] = ruleUsers[f.rule] || {})[f.upn] = 1;
      if (!bySku[f.sku]) bySku[f.sku] = { count: 0, cents: 0, name: f.skuName };
      bySku[f.sku].count++; bySku[f.sku].cents += c;
      accounts[f.upn] = 1;
    });
    $('heroNum').innerHTML = fmt(total) + ' <span class="per">/ month</span>';
    $('cardAnnual').textContent = fmt(total * 12);
    $('cardAccounts').textContent = Object.keys(accounts).length;
    $('cardUsers').textContent = data.usersScanned;

    if (data.findings.length === 0) {
      $('report').style.display = 'none';
      $('empty').style.display = '';
      return;
    }

    $('catRows').innerHTML = order.filter(function (r) { return ruleUsers[r]; }).map(function (r) {
      return '<tr><td>' + esc(data.ruleLabels[r] || r) + '</td><td class="num">' + Object.keys(ruleUsers[r]).length +
        '</td><td class="num">' + fmt(byRule[r] || 0) + '</td></tr>';
    }).join('');

    $('skuRows').innerHTML = Object.keys(bySku).sort(function (a, b) { return bySku[b].cents - bySku[a].cents; }).map(function (s) {
      return '<tr><td>' + esc(bySku[s].name || s) + '</td><td class="num">' + bySku[s].count + '</td><td class="num">' + fmt(bySku[s].cents) + '</td></tr>';
    }).join('');

    var rows = data.findings.map(function (f) { return { f: f, c: prices[f.sku] || 0 }; });
    rows.sort(function (a, b) { return (b.c - a.c) || a.f.name.localeCompare(b.f.name); });
    $('acctRows').innerHTML = rows.map(function (x) {
      var guest = x.f.guest ? '<span class="badge">guest</span>' : '';
      return '<tr><td>' + esc(x.f.name) + '</td><td>' + esc(x.f.upn) + guest + '</td><td>' + esc(x.f.label) + '</td><td>' +
        esc(x.f.last) + '</td><td>' + esc(x.f.skuName) + '</td><td class="num">' + fmt(x.c) + '</td></tr>';
    }).join('');
  }

  document.addEventListener('input', function (e) {
    if (e.target && e.target.matches && e.target.matches('input[data-sku]')) recompute();
  });

  recompute();
})();
</script>
</body>
</html>
'@


    return $template.Replace('__LM_DATA__', $dataJson)
}

function Initialize-LMParentDirectory {
    # Ensure the parent folder of an output path exists, so -Output* into a new subfolder doesn't fail.
    param([Parameter(Mandatory)][string]$Path)
    $parent = Split-Path -Parent $Path
    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }
}

function Write-LMReport {
    param(
        $Summary,
        $Findings,
        $Inventory,
        [string]$TenantLabel = 'your tenant',
        [string]$OutputCsv,
        [string]$OutputJson,
        [string]$OutputHtml,
        [switch]$NoConsole
    )
    $reportPath = if ($OutputHtml) { $OutputHtml } elseif ($OutputCsv) { $OutputCsv } elseif ($OutputJson) { $OutputJson } else { $null }

    if (-not $NoConsole) {
        Format-LMConsoleReport -Summary $Summary -TenantLabel $TenantLabel -ReportPath $reportPath
    }
    foreach ($out in @($OutputCsv, $OutputJson, $OutputHtml)) {
        if ($out) { Initialize-LMParentDirectory -Path $out }
    }
    if ($OutputCsv) {
        ConvertTo-LMFindingRow -Findings $Findings -Currency $Summary.Currency |
            Export-Csv -LiteralPath $OutputCsv -NoTypeInformation -Encoding utf8
    }
    if ($OutputJson) {
        [pscustomobject]@{
            generatedAtUtc = ([datetimeoffset]::UtcNow).ToString('o')
            tenant         = $TenantLabel
            summary        = $Summary
            findings       = @($Findings)
            inventory      = @($Inventory)
        } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputJson -Encoding utf8
    }
    if ($OutputHtml) {
        Get-LMHtmlReport -Summary $Summary -Findings $Findings -Inventory $Inventory -TenantLabel $TenantLabel |
            Set-Content -LiteralPath $OutputHtml -Encoding utf8
    }
}

# ----------------------------------------------------------------------------
# Price-file resolution
# ----------------------------------------------------------------------------

function Get-LMPriceStorePath {
    # Per-user location for an optional reusable prices.json, independent of the working directory.
    # Cross-platform via ApplicationData.
    $base = [Environment]::GetFolderPath('ApplicationData')
    if (-not $base) { $base = Join-Path $HOME '.config' }
    return (Join-Path (Join-Path $base 'LicenseMeter') 'prices.json')
}

function Resolve-LMPriceFilePath {
    param([string]$PriceFile)
    if ($PriceFile) {
        if (-not (Test-Path -LiteralPath $PriceFile)) { throw "Price file not found: $PriceFile" }
        return (Resolve-Path -LiteralPath $PriceFile).Path
    }
    $local = Join-Path (Get-Location).Path 'prices.json'
    if (Test-Path -LiteralPath $local) { return (Resolve-Path -LiteralPath $local).Path }

    $store = Get-LMPriceStorePath
    if ($store -and (Test-Path -LiteralPath $store)) { return (Resolve-Path -LiteralPath $store).Path }

    foreach ($candidate in @(
            (Join-Path $PSScriptRoot '..' 'data' 'prices.sample.json'),
            (Join-Path $PSScriptRoot 'data' 'prices.sample.json'))) {
        if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path }
    }
    throw "No price file found. Provide -PriceFile or place prices.json in the working directory."
}

function Get-LMTenantLabel {
    # Prefer the tenant's default verified domain for a readable header; fall back to the
    # tenant id, then a generic label. Never fails the scan if /organization is unavailable.
    param($Context)
    try {
        $org = @(Invoke-LMGraphList -Uri "$script:GraphBase/organization?`$select=displayName,verifiedDomains")
        if ($org.Count -gt 0) {
            $domains = @(Get-LMProp $org[0] 'verifiedDomains')
            $pick = $domains | Where-Object { (Get-LMProp $_ 'isDefault') -eq $true } | Select-Object -First 1
            if (-not $pick) { $pick = $domains | Where-Object { (Get-LMProp $_ 'isInitial') -eq $true } | Select-Object -First 1 }
            if (-not $pick -and $domains.Count -gt 0) { $pick = $domains[0] }
            $name = if ($pick) { Get-LMProp $pick 'name' } else { $null }
            if ($name) { return $name }
        }
    } catch {
        Write-Verbose "Tenant domain lookup failed: $($_.Exception.Message)"
    }
    $tid = Get-LMProp $Context 'TenantId'
    if ($tid) { return $tid }
    return 'your tenant'
}

# ----------------------------------------------------------------------------
# Orchestrator (public)
# ----------------------------------------------------------------------------

function Invoke-LicenseMeterScan {
    <#
    .SYNOPSIS
        Scan a Microsoft 365 tenant for license waste and write an interactive report.
    .DESCRIPTION
        Read-only. Signs in via MgGraphCommunity if not already connected, reads users and
        subscribed SKUs, applies the waste rules, and writes a self-contained, interactive
        HTML report: prices are editable in the browser and totals recalculate live (use
        -PriceFile to set permanent prices). On completion it prints the report path. Use
        -PassThru to also return the summary object, -ShowConsole for a text summary in the
        terminal, -Quiet to print nothing.
    .PARAMETER TenantId
        Optional tenant id or domain to sign in against. For multi-tenant admins.
    .PARAMETER ClientId
        Optional app (client) id, for organizations that block the default Microsoft Graph
        PowerShell public client. No app registration is needed otherwise.
    .PARAMETER UseDeviceCode
        Sign in with the device-code flow instead of a system browser (headless/remote sessions).
    .PARAMETER InactiveDays
        Days without activity before a licensed account counts as inactive. Default 90.
    .PARAMETER PriceFile
        Path to a prices.json. Otherwise ./prices.json, then your saved prices, then the bundled list.
    .PARAMETER Currency
        Override the price book's currency code (relabels figures; no FX conversion).
    .PARAMETER OutputHtml
        Where to write the interactive HTML report. Defaults to ./licensemeter-scan.html.
    .PARAMETER OutputCsv
        Also write one CSV row per finding to this path.
    .PARAMETER OutputJson
        Also write structured findings plus the run summary to this path.
    .PARAMETER ExcludeGuests
        Skip guest (external) accounts entirely, so only members are scanned.
    .PARAMETER ShowConsole
        Also print a compact text summary in the terminal.
    .PARAMETER Quiet
        Print nothing, not even the report path.
    .PARAMETER PassThru
        Return the summary object (for scripting).
    .EXAMPLE
        LicenseMeterScan
    .EXAMPLE
        LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html
    .EXAMPLE
        LicenseMeterScan -PriceFile ./prices.json -OutputCsv ./waste.csv
    .EXAMPLE
        LicenseMeterScan -ExcludeGuests -Quiet -PassThru
    #>

    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$ClientId,
        [switch]$UseDeviceCode,
        [int]$InactiveDays = 90,
        [string]$PriceFile,
        [string]$Currency,
        [string]$OutputHtml,
        [string]$OutputCsv,
        [string]$OutputJson,
        [switch]$ExcludeGuests,
        [switch]$ShowConsole,
        [switch]$Quiet,
        [switch]$PassThru
    )

    # Always produce an interactive HTML report; default it to the working directory so the
    # run ends with a clickable result the user opens to review and edit prices in.
    if (-not $OutputHtml) { $OutputHtml = Join-Path (Get-Location).Path 'licensemeter-scan.html' }

    $priceFilePath = Resolve-LMPriceFilePath -PriceFile $PriceFile
    $book = Get-LMPriceBook -Path $priceFilePath
    if ($Currency) { $book.Currency = $Currency }

    if (-not (Test-LMConnected)) {
        $connectParams = @{}
        if ($TenantId)      { $connectParams['TenantId'] = $TenantId }
        if ($ClientId)      { $connectParams['ClientId'] = $ClientId }
        if ($UseDeviceCode) { $connectParams['UseDeviceCode'] = $true }
        Connect-LMGraph @connectParams | Out-Null
    }
    $context     = Get-MgGraphCommunityContext
    Assert-LMGraphContextReadOnly -Context $context
    $tenantLabel = Get-LMTenantLabel $context

    $skus      = Get-LMSubscribedSku
    $priceMap  = Resolve-LMPriceMap -SubscribedSkus $skus -PriceBook $book
    $inventory = @(Get-LMInventory -SubscribedSkus $skus -PriceMap $priceMap)

    $userResult = Get-LMUser -InactiveDays $InactiveDays
    $users      = @($userResult.Users)
    $hasSignIn  = $userResult.HasSignInData
    $activitySrc = [string]$userResult.ActivitySource
    $activityMap = $userResult.ActivityByUpn

    if ($ExcludeGuests) {
        $users = @($users | Where-Object { [string](Get-LMProp $_ 'userType') -ne 'Guest' })
    }

    $asOf = [datetimeoffset]::UtcNow
    # R1-R3 (per-user activity rules), then R4 (redundant suite overlap) for accounts not already flagged.
    $primary = @(Invoke-LMRule -Users $users -PriceMap $priceMap -AsOf $asOf `
            -InactiveDays $InactiveDays -HasSignInData $hasSignIn -GraceDays $script:NeverSignedInGraceDays `
            -ActivityByUpn $activityMap)
    $flaggedIds = @($primary | Select-Object -ExpandProperty UserId -Unique)
    $redundant = @(Get-LMRedundantFinding -Users $users -PriceMap $priceMap -ExcludeUserIds $flaggedIds)
    $findings = @($primary) + @($redundant)

    $summary = Get-LMScanSummary -Findings $findings -UsersScanned (@($users).Count) `
        -Currency $book.Currency -HasSignInData $hasSignIn -InactiveDays $InactiveDays -ActivitySource $activitySrc

    Write-LMReport -Summary $summary -Findings $findings -Inventory $inventory -TenantLabel $tenantLabel `
        -OutputCsv $OutputCsv -OutputJson $OutputJson -OutputHtml $OutputHtml -NoConsole:(-not $ShowConsole)

    if (-not $Quiet) {
        $resolved = (Resolve-Path -LiteralPath $OutputHtml).Path
        Write-Host ''
        Write-Host ' LicenseMeter Scan complete. Open your report:' -ForegroundColor Green
        Write-Host (" {0}" -f $resolved) -ForegroundColor Cyan
        Write-Host ''
    }

    if ($PassThru) { return $summary }
}

Set-Alias -Name LicenseMeterScan -Value Invoke-LicenseMeterScan
Export-ModuleMember -Function 'Invoke-LicenseMeterScan', 'Connect-LMGraph' -Alias 'LicenseMeterScan'