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.1.0'
$script:GraphBase             = 'https://graph.microsoft.com/beta'
$script:UserSelect            = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime,signInActivity'
$script:UserSelectNoSignIn    = 'id,userPrincipalName,displayName,accountEnabled,assignedLicenses,createdDateTime'
$script:DefaultScopes         = @('User.Read.All', 'Organization.Read.All', 'AuditLog.Read.All')
$script:NeverSignedInGraceDays = 30

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

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

# 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
}

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

function Invoke-LMRule {
    <#
    .SYNOPSIS
        Apply the v0.1 waste rules 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
    )
    $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')
        $rule         = $null
        $lastActivity = $null
        $daysInactive = $null

        if (-not $enabled) {
            # R1: disabled account still holding a paid license. Needs no sign-in data.
            $rule = 'DisabledLicensed'
        } elseif ($HasSignInData) {
            $lastActivity = Get-LMLastActivity (Get-LMProp $user 'signInActivity')
            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' }
            }
        }
        # When sign-in data is unavailable and the account is enabled, R2/R3 cannot be
        # evaluated in v0.1; 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
                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
            })
        }
    }
    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
    )
    $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
        }
    }

    [pscustomobject]@{
        Currency          = $Currency
        UsersScanned      = $UsersScanned
        HasSignInData     = $HasSignInData
        InactiveDays      = $InactiveDays
        TotalMonthlyCents = $totalCents
        TotalAnnualCents  = $totalCents * 12
        FindingCount      = $all.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 ', ')"
    }
}

# ----------------------------------------------------------------------------
# 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
    $granted = Get-LMProp $ctx 'Scopes'
    if ($granted) {
        $writeish = foreach ($s in @($granted)) {
            if ($s -match '(?i)(write|\.send|\.manage|delete|fullcontrol)') { $s }
        }
        if ($writeish) {
            Write-Warning "The signed-in token carries non-read scopes ($($writeish -join ', ')). LicenseMeter Scan only reads."
        }
    }
    return $ctx
}

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

function Invoke-LMGraphList {
    # GET a Graph collection, following @odata.nextLink with host validation.
    param([Parameter(Mandatory)][string]$Uri)
    $results = [System.Collections.Generic.List[object]]::new()
    $next = $Uri
    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"
        }
        $response = 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'
    }
    return $results.ToArray()
}

function Get-LMUser {
    # Fetch licensed-relevant user fields. Probes sign-in capability: if the tenant
    # lacks Entra ID P1 / AuditLog.Read.All, the signInActivity select fails and we
    # retry without it, reporting HasSignInData = $false.
    [CmdletBinding()]
    param()
    $uriFull = "$script:GraphBase/users?`$select=$script:UserSelect&`$top=999"
    try {
        $users = Invoke-LMGraphList -Uri $uriFull
        return [pscustomobject]@{ Users = $users; HasSignInData = $true }
    } 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 ($reason -notmatch '(?i)(premium|authorization_requestdenied|forbidden|\b403\b|does not have|tenant.*licen|insufficient|aadsts)') {
            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
        return [pscustomobject]@{ Users = $users; HasSignInData = $false }
    }
}

function Get-LMSubscribedSku {
    return Invoke-LMGraphList -Uri "$script:GraphBase/subscribedSkus"
}

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

function Format-LMConsoleReport {
    param($Summary, [string]$TenantLabel = 'your tenant', [string]$ReportPath)
    $cur = $Summary.Currency
    $p1  = if ($Summary.HasSignInData) { 'yes' } else { 'no' }

    Write-Host ''
    Write-Host 'LicenseMeter Scan' -ForegroundColor White -NoNewline
    Write-Host " v$script:ModuleVersion" -ForegroundColor DarkGray
    Write-Host ("Tenant: {0} Users scanned: {1} Entra P1: {2}" -f $TenantLabel, $Summary.UsersScanned, $p1) -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 ''
        Write-Host 'Note: inactive and never-signed-in checks were skipped. This tenant lacks Entra ID P1 or AuditLog.Read.All (sign-in activity unavailable).' -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
            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
        }
    }
}

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. Prices are editable in the browser and totals
    # recalculate live; the page accepts a dropped prices file and can download one back.
    param($Summary, $Findings, [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'
    }

    $fData = foreach ($f in @($Findings)) {
        [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    = $(if ($f.LastActivity) { [string]$f.LastActivity } else { 'never' })
            sku     = [string]$f.SkuPartNumber
            skuName = [string]$f.SkuName
        }
    }

    $skuData = [ordered]@{}
    foreach ($f in @($Findings)) {
        $part = [string]$f.SkuPartNumber
        if ($part -and -not $skuData.Contains($part)) {
            $skuData[$part] = [pscustomobject]@{
                name   = [string]$f.SkuName
                amount = [math]::Round(([decimal]$f.MonthlyCents / 100), 2)
                known  = [bool]$f.PriceKnown
            }
        }
    }

    $data = [pscustomobject]@{
        currency     = $cur
        tenant       = [string]$TenantLabel
        usersScanned = [int]$Summary.UsersScanned
        p1           = [bool]$Summary.HasSignInData
        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'
        }
        findings = @($fData)
        skus     = $skuData
    }

    $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; }
  body.drag { outline:3px dashed var(--rust); outline-offset:-12px; }
  .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); }
  .wrap { max-width:820px; 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); }
  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; }
  .clear { font-size:1.1rem; margin-top:28px; }
  .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; }
  .toast { position:fixed; left:50%; bottom:24px; transform:translateX(-50%) translateY(20px); background:var(--ink); color:var(--paper);
           padding:9px 16px; border-radius:8px; font-size:.8rem; opacity:0; pointer-events:none; transition:all .2s ease; }
  .toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
</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 id="report">
      <div class="prices-hd">
        <h2>Prices <span class="hint">edit any price to update the report, or drop a prices file onto the page</span></h2>
        <button id="dl" type="button">Download prices.json</button>
      </div>
      <table><thead><tr><th>License</th><th>SKU part number</th><th class="num">Monthly price per seat</th></tr></thead><tbody id="priceRows"></tbody></table>

      <h2>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>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 sign-in</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 licensed waste found. Every paid license is assigned to an active, enabled account.</p>

    <p class="foot">Prices start from public list-price estimates you can edit above. Drop a prices file onto the page to load your own, or Download to reuse them on the next scan. 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>
  <div id="toast" class="toast"></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 }); }

  $('meta').textContent = 'Tenant: ' + data.tenant + ' \u00b7 Users scanned: ' + data.usersScanned +
    ' \u00b7 Entra P1: ' + (data.p1 ? 'yes' : 'no') + ' \u00b7 Generated: ' + data.generated;

  if (!data.findings || data.findings.length === 0) {
    $('report').style.display = 'none';
    $('empty').style.display = '';
    $('heroNum').innerHTML = fmt(0) + ' <span class="per">/ month</span>';
    $('cardAnnual').textContent = fmt(0);
    $('cardAccounts').textContent = '0';
    $('cardUsers').textContent = data.usersScanned;
    return;
  }

  var parts = Object.keys(data.skus).sort(function (a, b) {
    return (data.skus[a].name || a).localeCompare(data.skus[b].name || b);
  });
  $('priceRows').innerHTML = parts.map(function (p) {
    var info = data.skus[p];
    return '<tr><td>' + esc(info.name || p) + '</td><td class="mono">' + esc(p) + '</td>' +
      '<td class="num"><input type="number" min="0" step="0.01" data-sku="' + esc(p) + '" value="' + (info.amount || 0) + '">' +
      (info.known ? '' : '<span class="needs">set price</span>') + '</td></tr>';
  }).join('');

  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'];

  function recompute() {
    var prices = readPrices(), 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;

    $('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) {
      return '<tr><td>' + esc(x.f.name) + '</td><td>' + esc(x.f.upn) + '</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();
  });

  $('dl').addEventListener('click', function () {
    var prices = {}, inputs = document.querySelectorAll('input[data-sku]');
    for (var i = 0; i < inputs.length; i++) {
      var v = parseFloat(inputs[i].value);
      if (!isNaN(v) && v > 0) prices[inputs[i].getAttribute('data-sku')] = Math.round(v * 100) / 100;
    }
    var blob = new Blob([JSON.stringify({ currency: cur, period: 'monthly', prices: prices }, null, 2)], { type: 'application/json' });
    var a = document.createElement('a');
    a.href = URL.createObjectURL(blob); a.download = 'prices.json'; a.click();
    setTimeout(function () { URL.revokeObjectURL(a.href); }, 0);
  });

  function applyDropped(text) {
    var map = {}, obj = null;
    try { obj = JSON.parse(text); } catch (e) { obj = null; }
    if (obj) {
      var src = obj.prices || obj;
      Object.keys(src).forEach(function (k) { var v = parseFloat(src[k]); if (!isNaN(v)) map[k] = v; });
    } else {
      text.split(/\r?\n/).forEach(function (line) {
        var m = line.split(','); if (m.length >= 2) { var v = parseFloat(m[1]); if (!isNaN(v)) map[m[0].trim()] = v; }
      });
    }
    var n = 0, inputs = document.querySelectorAll('input[data-sku]');
    for (var i = 0; i < inputs.length; i++) {
      var k = inputs[i].getAttribute('data-sku');
      if (map[k] != null) { inputs[i].value = map[k]; n++; }
    }
    recompute();
    toast(n + ' price' + (n === 1 ? '' : 's') + ' loaded from file');
  }

  ['dragover', 'dragenter'].forEach(function (ev) {
    document.addEventListener(ev, function (e) { e.preventDefault(); document.body.classList.add('drag'); });
  });
  document.addEventListener('dragleave', function (e) { if (e.relatedTarget === null) document.body.classList.remove('drag'); });
  document.addEventListener('drop', function (e) {
    e.preventDefault(); document.body.classList.remove('drag');
    var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
    if (!f) return;
    var r = new FileReader(); r.onload = function () { applyDropped(String(r.result)); }; r.readAsText(f);
  });

  var toastT;
  function toast(msg) { var t = $('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove('show'); }, 2500); }

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


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

function Write-LMReport {
    param(
        $Summary,
        $Findings,
        [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
    }
    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)
        } | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputJson -Encoding utf8
    }
    if ($OutputHtml) {
        Get-LMHtmlReport -Summary $Summary -Findings $Findings -TenantLabel $TenantLabel |
            Set-Content -LiteralPath $OutputHtml -Encoding utf8
    }
}

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

function Get-LMPriceStorePath {
    # Per-user location for prices the user downloaded from a report, so they are reused on
    # the next scan regardless 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, totals recalculate live, you can
        drop a prices file onto the page, and download one back to reuse next scan. 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.
    .EXAMPLE
        LicenseMeterScan
    .EXAMPLE
        LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html
    .EXAMPLE
        LicenseMeterScan -PriceFile ./prices.json -OutputCsv ./waste.csv
    #>

    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$ClientId,
        [switch]$UseDeviceCode,
        [int]$InactiveDays = 90,
        [string]$PriceFile,
        [string]$Currency,
        [string]$OutputHtml,
        [string]$OutputCsv,
        [string]$OutputJson,
        [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
    $tenantLabel = Get-LMTenantLabel $context

    $skus     = Get-LMSubscribedSku
    $priceMap = Resolve-LMPriceMap -SubscribedSkus $skus -PriceBook $book

    $userResult = Get-LMUser
    $users      = $userResult.Users
    $hasSignIn  = $userResult.HasSignInData

    $asOf = [datetimeoffset]::UtcNow
    $findings = @(Invoke-LMRule -Users $users -PriceMap $priceMap -AsOf $asOf `
            -InactiveDays $InactiveDays -HasSignInData $hasSignIn -GraceDays $script:NeverSignedInGraceDays)

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

    Write-LMReport -Summary $summary -Findings $findings -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 ' Tip: edit prices in the report, then Download prices.json into this folder to reuse them next scan.' -ForegroundColor DarkGray
        Write-Host ''
    }

    if ($PassThru) { return $summary }
}

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