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.0.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 = @{
    ENTERPRISEPACK           = 'Office 365 E3'
    ENTERPRISEPREMIUM        = 'Office 365 E5'
    SPE_E3                   = 'Microsoft 365 E3'
    SPE_E5                   = 'Microsoft 365 E5'
    SPB                      = 'Microsoft 365 Business Premium'
    O365_BUSINESS_PREMIUM    = 'Microsoft 365 Business Standard'
    O365_BUSINESS_ESSENTIALS = 'Microsoft 365 Business Basic'
    EXCHANGESTANDARD         = 'Exchange Online (Plan 1)'
    EXCHANGEENTERPRISE       = 'Exchange Online (Plan 2)'
    POWER_BI_PRO             = 'Power BI Pro'
    PROJECTPROFESSIONAL      = 'Project Plan 3'
    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'
    DEVELOPERPACK_E5         = 'Microsoft 365 E5 (Developer)'
    DEFENDER_ENDPOINT_P1     = 'Defender for Endpoint P1'
    WIN_DEF_ATP              = 'Defender for Endpoint P2'
}

# ----------------------------------------------------------------------------
# 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)) { $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
        $priced = $false
        if ($part -and $PriceBook.Prices.ContainsKey($part)) {
            $cents  = [long]$PriceBook.Prices[$part]
            $priced = ($cents -gt 0)
        }
        $map[$skuId] = [pscustomobject]@{
            SkuId      = $skuId
            PartNumber = $part
            Cents      = $cents
            Priced     = $priced
        }
    }
    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 }

        $paid = [System.Collections.Generic.List[object]]::new()
        foreach ($lic in $licenses) {
            $skuId = Get-LMProp $lic 'skuId'
            if ($skuId -and $PriceMap.ContainsKey($skuId) -and $PriceMap[$skuId].Priced) {
                $paid.Add($PriceMap[$skuId])
            }
        }
        if ($paid.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 $paid) {
            $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
                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)
    $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 {
    param($Summary, $Findings, [string]$TenantLabel = 'your tenant')
    $cur = $Summary.Currency
    $p1  = if ($Summary.HasSignInData) { 'yes' } else { 'no' }
    $generated = ([datetimeoffset]::UtcNow).ToString('yyyy-MM-dd HH:mm') + ' UTC'

    $ruleRows = foreach ($r in $Summary.ByRule) {
        "<tr><td>$(ConvertTo-LMHtmlEncoded $r.Label)</td><td class='num'>$($r.Users)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $r.MonthlyCents $cur))</td></tr>"
    }
    $skuRows = foreach ($s in $Summary.BySku) {
        "<tr><td>$(ConvertTo-LMHtmlEncoded $s.SkuName)</td><td class='num'>$($s.Count)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $s.MonthlyCents $cur))</td></tr>"
    }
    $sorted = @($Findings) | Sort-Object -Property @{ Expression = 'MonthlyCents'; Descending = $true }, @{ Expression = 'DisplayName'; Descending = $false }
    $findingRows = foreach ($f in $sorted) {
        $finding = switch ($f.Rule) {
            'DisabledLicensed'      { 'Disabled' }
            'InactiveLicensed'      { 'Inactive' }
            'NeverSignedInLicensed' { 'Never signed in' }
            default                 { $f.Rule }
        }
        $lastSeen = if ($f.LastActivity) { $f.LastActivity } else { 'never' }
        "<tr><td>$(ConvertTo-LMHtmlEncoded $f.DisplayName)</td><td>$(ConvertTo-LMHtmlEncoded $f.UserPrincipalName)</td><td>$(ConvertTo-LMHtmlEncoded $finding)</td><td>$(ConvertTo-LMHtmlEncoded $lastSeen)</td><td>$(ConvertTo-LMHtmlEncoded $f.SkuName)</td><td class='num'>$(ConvertTo-LMHtmlEncoded (Format-LMMoney $f.MonthlyCents $cur))</td></tr>"
    }

    $flaggedAccounts = (@($Findings) | Select-Object -ExpandProperty UserId -Unique | Measure-Object).Count
    $monthly = ConvertTo-LMHtmlEncoded (Format-LMMoney $Summary.TotalMonthlyCents $cur)
    $annual  = ConvertTo-LMHtmlEncoded (Format-LMMoney $Summary.TotalAnnualCents $cur)
    $tenant  = ConvertTo-LMHtmlEncoded $TenantLabel

    if ($Summary.FindingCount -eq 0) {
        $bodyTables = ' <p class="clear">No licensed waste found. Every paid license is assigned to an active, enabled account.</p>'
    }
    else {
        $bodyTables = @"
  <h2>By category</h2>
  <table>
    <thead><tr><th>Category</th><th class="num">Users</th><th class="num">Monthly</th></tr></thead>
    <tbody>
      $($ruleRows -join "`n ")
    </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>
      $($skuRows -join "`n ")
    </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>
      $($findingRows -join "`n ")
    </tbody>
  </table>
"@

    }

    @"
<!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; }
  .wrap { max-width:800px; 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; }
  .brand svg { display:block; }
  .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; letter-spacing:0; }
  .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; }
  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:8px 10px; border-bottom:1px solid var(--line); vertical-align:top; }
  td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
  .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; }
</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">Tenant: $tenant &middot; Users scanned: $($Summary.UsersScanned) &middot; Entra P1: $p1 &middot; Generated: $generated</p>

    <div class="hero">
      <div class="hero-label">Identified Microsoft 365 license waste</div>
      <div class="hero-num serif">$monthly <span class="per">/ month</span></div>
    </div>

    <div class="cards">
      <div class="card"><div class="card-num serif">$annual</div><div class="card-lbl">identified per year</div></div>
      <div class="card"><div class="card-num serif">$flaggedAccounts</div><div class="card-lbl">accounts flagged</div></div>
      <div class="card"><div class="card-num serif">$($Summary.UsersScanned)</div><div class="card-lbl">users scanned</div></div>
    </div>

$bodyTables

    <p class="foot">Figures use list prices unless a custom price book was supplied. 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>
</body>
</html>
"@

}

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 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 $local }

    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 report the result.
    .DESCRIPTION
        Read-only. Signs in via MgGraphCommunity if not already connected, reads
        users and subscribed SKUs, applies the v0.1 waste rules, and prints a
        summary. Optionally writes CSV, JSON, and HTML reports locally.
    .EXAMPLE
        Invoke-LicenseMeterScan
    .EXAMPLE
        Invoke-LicenseMeterScan -InactiveDays 60 -OutputHtml ./waste.html
    #>

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

    $priceFilePath = Resolve-LMPriceFilePath -PriceFile $PriceFile
    $book = Get-LMPriceBook -Path $priceFilePath
    if ($Currency) { $book.Currency = $Currency }
    if ($priceFilePath -like '*prices.sample.json') {
        Write-Warning "Using sample list prices ($priceFilePath). Copy to prices.json and edit for your contract."
    }

    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

    if ($IncludeUnpriced) {
        $unpriced = foreach ($sku in @($skus)) {
            $skuId    = Get-LMProp $sku 'skuId'
            $part     = Get-LMProp $sku 'skuPartNumber'
            $consumed = Get-LMProp $sku 'consumedUnits'
            if ($skuId -and $priceMap.ContainsKey($skuId) -and -not $priceMap[$skuId].Priced -and ([int]$consumed -gt 0)) {
                [pscustomobject]@{ SkuPartNumber = $part; ConsumedUnits = [int]$consumed }
            }
        }
        if ($unpriced) {
            Write-Host 'Unpriced SKUs assigned in this tenant (add them to prices.json to include in the total):'
            foreach ($u in (@($unpriced) | Sort-Object SkuPartNumber)) {
                Write-Host (" {0,-34}{1,5} assigned" -f $u.SkuPartNumber, $u.ConsumedUnits) -ForegroundColor DarkGray
            }
            Write-Host ''
        }
    }

    return $summary
}

Export-ModuleMember -Function 'Invoke-LicenseMeterScan', 'Connect-LMGraph'