Src/Private/Get-AbrEntraIDCharts.ps1

#region --- Chart Generation Helpers ---
# All functions run OUTSIDE the PScribo Document{} scriptblock.
# They use [void] and $null= on every expression to ensure zero pipeline output.
# PScribo intercepts ALL pipeline output inside Document{}; even boolean results
# of if() statements cause "The term 'if' is not recognized" errors.
# These functions return only a Base64 string (or $null) -- never pipeline output.
#
# Usage:
# # Call BEFORE sections start (outside PScribo scope):
# $b64 = New-AbrMfaDonutChart -Capable 80 -NotCapable 20 -PhishResistant 10 -Pct 80 -TenantId 'x'
# # Then inside Section{}:
# if ($b64) { BlankLine; Image -Text 'MFA' -Base64 $b64 -Percent 65 -Align Center; BlankLine }

function New-AbrDonutChart {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [array]   $Segments,
        [Parameter(Mandatory)] [string]  $CentreText,
        [string]  $SubText  = '',
        [string]  $Title    = '',
        [int]     $Width    = 520,
        [int]     $Height   = 300
    )

    # Load System.Drawing (name differs between PS5 and PS7 Core)
    [bool]$DrawingLoaded = $false
    foreach ($asm in @('System.Drawing.Common','System.Drawing')) {
        try { [void][System.Reflection.Assembly]::LoadWithPartialName($asm); $DrawingLoaded = $true; break } catch {}
        try { Add-Type -AssemblyName $asm -ErrorAction Stop; $DrawingLoaded = $true; break } catch {}
    }
    if (-not $DrawingLoaded) { return $null }

    [int]$total = 0
    foreach ($s in $Segments) { $total += [int]$s.Value }
    if ($total -le 0) { return $null }

    try {
        $bmp = [System.Drawing.Bitmap]::new($Width, $Height)
        $gfx = [System.Drawing.Graphics]::FromImage($bmp)
        [void]$gfx.SmoothingMode
        $gfx.SmoothingMode    = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $gfx.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
        $gfx.Clear([System.Drawing.Color]::White)

        # Title
        if ($Title -ne '') {
            $tf = [System.Drawing.Font]::new('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
            $gfx.DrawString($Title, $tf, [System.Drawing.Brushes]::DimGray, [System.Drawing.PointF]::new(8, 6))
            $tf.Dispose()
        }

        # Donut geometry
        [int]$cx   = [int]($Height / 2)
        [int]$cy   = [int]($Height / 2)
        [int]$r    = [int]($Height / 2) - 18
        [int]$hole = [int]($r * 0.58)
        $rect = [System.Drawing.RectangleF]::new($cx - $r, $cy - $r, $r * 2, $r * 2)

        # Segments
        [float]$startAngle = -90.0
        foreach ($seg in $Segments) {
            [int]$val = [int]$seg.Value
            if ($val -le 0) { continue }
            [float]$sweep = [float](([float]$val / [float]$total) * 360.0)
            $c = [System.Drawing.ColorTranslator]::FromHtml($seg.Color)
            $b = [System.Drawing.SolidBrush]::new($c)
            $gfx.FillPie($b, $rect, $startAngle, $sweep)
            $b.Dispose()
            $startAngle += $sweep
        }

        # Separators
        $sepPen = [System.Drawing.Pen]::new([System.Drawing.Color]::White, 2)
        [float]$startAngle = -90.0
        foreach ($seg in $Segments) {
            [int]$val = [int]$seg.Value
            if ($val -le 0) { continue }
            [float]$sweep = [float](([float]$val / [float]$total) * 360.0)
            [double]$rad  = ($startAngle * [Math]::PI) / 180.0
            [int]$x2 = $cx + [int]($r * [Math]::Cos($rad))
            [int]$y2 = $cy + [int]($r * [Math]::Sin($rad))
            $gfx.DrawLine($sepPen, $cx, $cy, $x2, $y2)
            $startAngle += $sweep
        }
        $sepPen.Dispose()

        # White hole
        $hb = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::White)
        $gfx.FillEllipse($hb, $cx - $hole, $cy - $hole, $hole * 2, $hole * 2)
        $hb.Dispose()

        # Centre text
        $bigFont = [System.Drawing.Font]::new('Segoe UI', 22, [System.Drawing.FontStyle]::Bold)
        $subFont = [System.Drawing.Font]::new('Segoe UI', 8)
        $darkBrush = [System.Drawing.SolidBrush]::new([System.Drawing.Color]::FromArgb(50,50,50))
        $bigSize = $gfx.MeasureString($CentreText, $bigFont)
        [float]$bigX = $cx - ($bigSize.Width  / 2)
        [float]$bigY = $cy - ($bigSize.Height / 2) + $(if ($SubText -ne '') { -6 } else { 0 })
        $gfx.DrawString($CentreText, $bigFont, $darkBrush, [System.Drawing.PointF]::new($bigX, $bigY))
        if ($SubText -ne '') {
            $subSize = $gfx.MeasureString($SubText, $subFont)
            $gfx.DrawString($SubText, $subFont, [System.Drawing.Brushes]::Gray,
                [System.Drawing.PointF]::new($cx - ($subSize.Width/2), $bigY + $bigSize.Height - 2))
        }
        $bigFont.Dispose(); $subFont.Dispose(); $darkBrush.Dispose()

        # Legend
        [int]$legX = $Height + 16
        [int]$legY = 28
        $legFont    = [System.Drawing.Font]::new('Segoe UI', 8)
        $legValFont = [System.Drawing.Font]::new('Segoe UI', 8, [System.Drawing.FontStyle]::Bold)
        foreach ($seg in $Segments) {
            [int]$val = [int]$seg.Value
            if ($val -le 0) { continue }
            $c = [System.Drawing.ColorTranslator]::FromHtml($seg.Color)
            $sb = [System.Drawing.SolidBrush]::new($c)
            $gfx.FillRectangle($sb, $legX, $legY + 2, 12, 12)
            $sb.Dispose()
            [int]$pct = [int]([math]::Round(([float]$val / [float]$total) * 100, 0))
            $gfx.DrawString("$($seg.Label)", $legFont, [System.Drawing.Brushes]::DimGray,
                [System.Drawing.PointF]::new($legX + 18, $legY))
            $gfx.DrawString("$val ($pct%)", $legValFont, [System.Drawing.Brushes]::Black,
                [System.Drawing.PointF]::new($legX + 18, $legY + 13))
            $legY += 36
        }
        $legFont.Dispose(); $legValFont.Dispose()

        # Encode to Base64
        $ms = [System.IO.MemoryStream]::new()
        $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
        [string]$b64 = [System.Convert]::ToBase64String($ms.ToArray())
        $ms.Dispose(); $gfx.Dispose(); $bmp.Dispose()
        return $b64
    } catch {
        try { $gfx.Dispose() } catch {}
        try { $bmp.Dispose() } catch {}
        return $null
    }
}


function New-AbrRadarChart {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $Scores,
        [string] $Title     = 'Identity Security Posture',
        [string] $Framework = 'E8',
        [int]    $Width     = 520,
        [int]    $Height    = 420
    )

    [bool]$DrawingLoaded = $false
    foreach ($asm in @('System.Drawing.Common','System.Drawing')) {
        try { [void][System.Reflection.Assembly]::LoadWithPartialName($asm); $DrawingLoaded = $true; break } catch {}
        try { Add-Type -AssemblyName $asm -ErrorAction Stop; $DrawingLoaded = $true; break } catch {}
    }
    if (-not $DrawingLoaded) { return $null }

    [string[]]$labels = @($Scores.Keys)
    [int]$n = $labels.Count
    if ($n -lt 3) { return $null }

    try {
        $bmp = [System.Drawing.Bitmap]::new($Width, $Height)
        $gfx = [System.Drawing.Graphics]::FromImage($bmp)
        $gfx.SmoothingMode    = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
        $gfx.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
        $gfx.Clear([System.Drawing.Color]::White)

        # Title
        $titleFont = [System.Drawing.Font]::new('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
        $titleSize = $gfx.MeasureString($Title, $titleFont)
        $gfx.DrawString($Title, $titleFont, [System.Drawing.Brushes]::DimGray,
            [System.Drawing.PointF]::new(($Width - $titleSize.Width) / 2, 8))
        $titleFont.Dispose()

        [int]$cx   = [int]($Width * 0.46)
        [int]$cy   = [int]($Height * 0.52)
        [int]$maxR = [int]([Math]::Min($cx, $cy) * 0.72)

        # Grid rings
        $gridPen  = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(200,200,210), 1)
        $gridFont = [System.Drawing.Font]::new('Segoe UI', 6.5)
        foreach ($ring in @(20, 40, 60, 80, 100)) {
            [int]$rr = [int]($maxR * $ring / 100)
            $pts = [System.Drawing.PointF[]]$(0..($n-1) | ForEach-Object {
                [double]$angle = (2 * [Math]::PI * $_ / $n) - ([Math]::PI / 2)
                [System.Drawing.PointF]::new($cx + [float]($rr * [Math]::Cos($angle)),
                                             $cy + [float]($rr * [Math]::Sin($angle)))
            })
            for ([int]$i = 0; $i -lt $n; $i++) {
                $gfx.DrawLine($gridPen, $pts[$i], $pts[($i + 1) % $n])
            }
            [string]$rl = "$ring%"
            $ls = $gfx.MeasureString($rl, $gridFont)
            $gfx.DrawString($rl, $gridFont, [System.Drawing.Brushes]::LightGray,
                [System.Drawing.PointF]::new($cx - ($ls.Width/2), $cy - $rr - $ls.Height + 2))
        }
        $gridPen.Dispose()

        # Spokes
        $spokePen = [System.Drawing.Pen]::new([System.Drawing.Color]::FromArgb(180,180,195), 1)
        for ([int]$i = 0; $i -lt $n; $i++) {
            [double]$angle = (2 * [Math]::PI * $i / $n) - ([Math]::PI / 2)
            $gfx.DrawLine($spokePen, $cx, $cy,
                $cx + [float]($maxR * [Math]::Cos($angle)),
                $cy + [float]($maxR * [Math]::Sin($angle)))
        }
        $spokePen.Dispose()

        # Data polygon
        [string]$dataColorHex = $(if ($Framework -eq 'E8') { '#1a6eb5' } else { '#e87722' })
        $dataHtml  = [System.Drawing.ColorTranslator]::FromHtml($dataColorHex)
        $fillColor = [System.Drawing.Color]::FromArgb(60, $dataHtml.R, $dataHtml.G, $dataHtml.B)
        $fillBrush = [System.Drawing.SolidBrush]::new($fillColor)
        $outlinePen = [System.Drawing.Pen]::new($dataHtml, 2)
        $dotBrush  = [System.Drawing.SolidBrush]::new($dataHtml)

        $dataPts = [System.Drawing.PointF[]]$(0..($n-1) | ForEach-Object {
            [int]$score = [math]::Max(0, [math]::Min(100, [int]($Scores[$labels[$_]])))
            [float]$rr  = [float]($maxR * $score / 100)
            [double]$angle = (2 * [Math]::PI * $_ / $n) - ([Math]::PI / 2)
            [System.Drawing.PointF]::new($cx + [float]($rr * [Math]::Cos($angle)),
                                         $cy + [float]($rr * [Math]::Sin($angle)))
        })

        $gfx.FillPolygon($fillBrush, $dataPts)
        for ([int]$i = 0; $i -lt $n; $i++) {
            $gfx.DrawLine($outlinePen, $dataPts[$i], $dataPts[($i+1) % $n])
        }
        foreach ($pt in $dataPts) {
            $gfx.FillEllipse($dotBrush, $pt.X - 4, $pt.Y - 4, 8, 8)
        }
        $fillBrush.Dispose(); $outlinePen.Dispose(); $dotBrush.Dispose(); $gridFont.Dispose()

        # Labels
        $labelFont = [System.Drawing.Font]::new('Segoe UI', 7.5, [System.Drawing.FontStyle]::Bold)
        $badgeFont = [System.Drawing.Font]::new('Segoe UI', 7)
        [int]$padding = 20

        for ([int]$i = 0; $i -lt $n; $i++) {
            [double]$angle = (2 * [Math]::PI * $i / $n) - ([Math]::PI / 2)
            [float]$lx = $cx + [float](($maxR + $padding) * [Math]::Cos($angle))
            [float]$ly = $cy + [float](($maxR + $padding) * [Math]::Sin($angle))
            [string]$lbl = $labels[$i]
            [int]$score  = [int]($Scores[$lbl])
            $lSize = $gfx.MeasureString($lbl, $labelFont)

            [float]$drawX = $(if ($lx -lt $cx - 10) { $lx - $lSize.Width } elseif ($lx -gt $cx + 10) { $lx } else { $lx - $lSize.Width/2 })
            [float]$drawY = $(if ($ly -lt $cy - 10) { $ly - $lSize.Height - 2 } else { $ly + 2 })

            $scoreColor = $(
                if ($score -ge 80) { [System.Drawing.Color]::FromArgb(46,139,87) }
                elseif ($score -ge 50) { [System.Drawing.Color]::FromArgb(204,102,0) }
                else { [System.Drawing.Color]::FromArgb(180,30,30) }
            )
            $scoreBrush = [System.Drawing.SolidBrush]::new($scoreColor)
            $gfx.DrawString($lbl, $labelFont, [System.Drawing.Brushes]::DimGray,
                [System.Drawing.PointF]::new($drawX, $drawY))
            [string]$scoreText = "$score%"
            $sSize = $gfx.MeasureString($scoreText, $badgeFont)
            $gfx.DrawString($scoreText, $badgeFont, $scoreBrush,
                [System.Drawing.PointF]::new($drawX + ($lSize.Width - $sSize.Width)/2, $drawY + $lSize.Height - 1))
            $scoreBrush.Dispose()
        }
        $labelFont.Dispose(); $badgeFont.Dispose()

        # Legend
        $legFont  = [System.Drawing.Font]::new('Segoe UI', 7.5)
        [string]$legLabel = $(if ($Framework -eq 'E8') { 'ACSC E8 Score' } else { 'CIS M365 Score' })
        [int]$legX = $Width - 130; [int]$legY = $Height - 50
        $swatchBrush = [System.Drawing.SolidBrush]::new($dataHtml)
        $gfx.FillRectangle($swatchBrush, $legX, $legY + 2, 12, 12)
        $swatchBrush.Dispose()
        $gfx.DrawString($legLabel, $legFont, [System.Drawing.Brushes]::DimGray,
            [System.Drawing.PointF]::new($legX + 16, $legY))
        $legFont.Dispose()

        $ms  = [System.IO.MemoryStream]::new()
        $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
        [string]$b64 = [System.Convert]::ToBase64String($ms.ToArray())
        $ms.Dispose(); $gfx.Dispose(); $bmp.Dispose()
        return $b64
    } catch {
        try { $gfx.Dispose() } catch {}
        try { $bmp.Dispose() } catch {}
        return $null
    }
}


function New-AbrSecurityPostureRadar {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [System.Collections.ArrayList] $Checks,
        [string] $Framework = 'E8',
        [string] $Title     = 'Identity Security Posture'
    )
    if (-not $Checks -or $Checks.Count -eq 0) { return $null }

    $scores = [ordered]@{}
    [string[]]$sections = @($Checks | Select-Object -ExpandProperty Section -Unique | Sort-Object)
    foreach ($sec in $sections) {
        $secChecks = @($Checks | Where-Object { $_.Section -eq $sec })
        [int]$tot  = $secChecks.Count
        [int]$ok   = @($secChecks | Where-Object { $_.Status -like '*[OK]*' }).Count
        $scores[$sec] = $(if ($tot -gt 0) { [math]::Round(($ok / $tot) * 100, 0) } else { 0 })
    }
    if ($scores.Count -lt 3) { return $null }
    return New-AbrRadarChart -Scores $scores -Framework $Framework -Title $Title
}


function New-AbrCAStateDonut {
    [OutputType([string])]
    [CmdletBinding()]
    param([int]$Enabled, [int]$ReportOnly, [int]$Disabled, [string]$TenantId = '')
    [int]$total = $Enabled + $ReportOnly + $Disabled
    if ($total -le 0) { return $null }
    $segs = @(
        @{ Label = 'Enabled';     Value = $Enabled;    Color = '#2d8f4e' }
        @{ Label = 'Report-Only'; Value = $ReportOnly; Color = '#e87722' }
        @{ Label = 'Disabled';    Value = $Disabled;   Color = '#c0392b' }
    )
    [string]$t = $(if ($TenantId) { "CA Policy State -- $TenantId" } else { 'Conditional Access Policy State' })
    return New-AbrDonutChart -Segments $segs -CentreText "$total" -SubText 'Total Policies' -Title $t
}


function New-AbrUserBreakdownDonut {
    [OutputType([string])]
    [CmdletBinding()]
    param([int]$Members, [int]$Guests, [int]$Disabled, [string]$TenantId = '')
    [int]$total = $Members + $Guests + $Disabled
    if ($total -le 0) { return $null }
    $segs = @(
        @{ Label = 'Members';  Value = $Members;  Color = '#1a6eb5' }
        @{ Label = 'Guests';   Value = $Guests;   Color = '#e87722' }
        @{ Label = 'Disabled'; Value = $Disabled; Color = '#888888' }
    )
    [string]$t = $(if ($TenantId) { "User Breakdown -- $TenantId" } else { 'User Type Breakdown' })
    return New-AbrDonutChart -Segments $segs -CentreText "$total" -SubText 'Total Users' -Title $t
}


function New-AbrSecureScoreGauge {
    [OutputType([string])]
    [CmdletBinding()]
    param([int]$CurrentScore, [int]$MaxScore, [string]$TenantId = '')
    if ($MaxScore -le 0) { return $null }
    [int]$pct = [math]::Round(($CurrentScore / $MaxScore) * 100, 0)
    [string]$col = $(if ($pct -ge 80) { '#2d8f4e' } elseif ($pct -ge 50) { '#e87722' } else { '#c0392b' })
    $segs = @(
        @{ Label = "Score ($pct%)"; Value = $CurrentScore;             Color = $col      }
        @{ Label = 'Remaining';     Value = ($MaxScore - $CurrentScore); Color = '#e0e0e0' }
    )
    [string]$t = $(if ($TenantId) { "Identity Secure Score -- $TenantId" } else { 'Identity Secure Score' })
    return New-AbrDonutChart -Segments $segs -CentreText "$pct%" -SubText "of $MaxScore pts" -Title $t
}
#endregion