internal/Core/Get-GuerrillaScoreCalculation.ps1

# Guerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/Guerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Get-GuerrillaScoreCalculation {
    <#
    .SYNOPSIS
        Computes the composite Guerrilla Security Score (0-100).
    .DESCRIPTION
        Calculates a weighted composite score from three components:
          - Posture (70%): Audit posture scores from AD, Entra, and GWS findings
          - Coverage (15%): Percentage of the three assessment platforms with findings
          - Trend (15%): Score delta from previous run (improving = bonus)

        The retired Threats component scored monitoring detections; with the
        monitoring subsystem removed, keeping it would have silently awarded
        full credit for threats never assessed. Nothing in this score reflects
        data that was not collected.
    .PARAMETER AuditFindings
        Array of audit finding objects (from the AD, Entra, and GWS audits).
    .PARAMETER PreviousScore
        Previous Guerrilla Score for trend calculation. If not provided, trend is neutral.
    .PARAMETER Profile
        Baseline profile hashtable with component weights. Uses default weights if not provided.
    #>

    [CmdletBinding()]
    param(
        [PSCustomObject[]]$AuditFindings,
        [double]$PreviousScore = -1,
        [hashtable]$Profile
    )

    # Component weights from profile or defaults. Ignore any profile that still
    # carries the retired threats weight so old config cannot dilute the score.
    $weights = @{ posture = 0.70; coverage = 0.15; trend = 0.15 }
    $pw = $Profile.guerrillaScore.componentWeights
    if ($pw -and $pw.posture -and $pw.coverage -and $pw.trend -and -not $pw.threats) {
        $weights = $pw
    }

    # --- Component 1: Posture Score (0-100) ---
    $postureScore = 100
    if ($AuditFindings -and $AuditFindings.Count -gt 0) {
        $postureResult = Get-AuditPostureScore -Findings $AuditFindings
        $postureScore = [Math]::Max(0, [Math]::Min(100, $postureResult.OverallScore))
    }

    # --- Component 2: Coverage Score (0-100) over the three assessment platforms ---
    # Prefix note: GWS's ADMIN-* collides with a bare ^AD match, so the AD
    # pattern enumerates the real AD families instead.
    $activePlatforms = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    if ($AuditFindings -and $AuditFindings.Count -gt 0) {
        $platformPatterns = @{
            'Active Directory' = '^AD(ACL|CS|DOM|GPO|KERB|LOG|NET|PATH|PRIV|PWD|SCRIPT|STALE|TIER|TRADE|TRUST)-'
            'Entra ID / M365'  = '^(EID|EIDSCA|M365|AZIAM|INTUNE|AIAGENT)'
            'Google Workspace' = '^(AUTH|ADMIN|COLLAB|DEVICE|DRIVE|EMAIL|GROUP|GTRADE|GWS|LOG|OAUTH)-'
        }
        foreach ($name in $platformPatterns.Keys) {
            if (@($AuditFindings | Where-Object { $_.CheckId -match $platformPatterns[$name] }).Count -gt 0) {
                $activePlatforms.Add($name) | Out-Null
            }
        }
    }
    $coverageScore = [int][Math]::Round(100 * ($activePlatforms.Count / 3), 0)

    # --- Component 3: Trend Score (0-100) ---
    $trendScore = 50  # Neutral default
    if ($PreviousScore -ge 0) {
        $currentRaw = [Math]::Round(
            ($postureScore * $weights.posture + $coverageScore * $weights.coverage) /
            ($weights.posture + $weights.coverage), 0
        )
        $delta = $currentRaw - $PreviousScore
        # Map delta to 0-100: +20 or more = 100, -20 or less = 0, linear between
        $trendScore = [Math]::Max(0, [Math]::Min(100, [int][Math]::Round(50 + ($delta * 2.5), 0)))
    }

    # --- Composite Score ---
    $compositeScore = [int][Math]::Round(
        ($postureScore * $weights.posture) +
        ($coverageScore * $weights.coverage) +
        ($trendScore * $weights.trend),
        0
    )
    $compositeScore = [Math]::Max(0, [Math]::Min(100, $compositeScore))

    $label = Get-GuerrillaScoreLabel -Score $compositeScore

    return [PSCustomObject]@{
        PSTypeName    = 'Guerrilla.GuerrillaScore'
        Score         = $compositeScore
        Label         = $label.Label
        LabelColor    = $label.Color
        Components    = [PSCustomObject]@{
            Posture  = [PSCustomObject]@{ Score = $postureScore;  Weight = $weights.posture;  Weighted = [int][Math]::Round($postureScore * $weights.posture, 0) }
            Coverage = [PSCustomObject]@{ Score = $coverageScore; Weight = $weights.coverage; Weighted = [int][Math]::Round($coverageScore * $weights.coverage, 0) }
            Trend    = [PSCustomObject]@{ Score = $trendScore;    Weight = $weights.trend;    Weighted = [int][Math]::Round($trendScore * $weights.trend, 0) }
        }
        ActivePlatforms = @($activePlatforms)
        PreviousScore  = if ($PreviousScore -ge 0) { $PreviousScore } else { $null }
        Timestamp      = [datetime]::UtcNow
    }
}