modules/shared/ReportDelta.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Report v2 delta helper — compare current findings against a previous run.

.DESCRIPTION
    Given two arrays of v2 FindingRows (or v1 legacy rows), returns a hashtable keyed
    by a stable composite key (Source|ResourceId|Category|Title) with a classification
    of each current finding as 'New', 'Resolved', or 'Unchanged'.

    - 'New' — present in current, absent in previous.
    - 'Resolved' — present in previous, absent in current (emitted as synthetic rows).
    - 'Unchanged' — present in both.

    The returned shape is:
      @{
        Status = @{ '<key>' = 'New' | 'Resolved' | 'Unchanged' ; ... }
        Resolved = @( <synthetic rows from previous run that vanished> )
        Summary = [pscustomobject]@{ New = <int>; Resolved = <int>; Unchanged = <int>; NetNonCompliantDelta = <int> }
      }

    Also provides:
      Resolve-BaselineRun — discovers the most-recent prior results.json under an OutputRoot.
      Get-RunTrend — aggregates the last N runs into a trend array (oldest first) for sparklines.

    Safe under StrictMode — all property reads are PSObject-probed.
#>

Set-StrictMode -Version Latest

function Get-ReportDeltaKey {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [PSCustomObject] $Row
    )
    $src = if ($Row.PSObject.Properties['Source']     -and $Row.Source)     { [string]$Row.Source }     else { '' }
    $rid = if ($Row.PSObject.Properties['ResourceId'] -and $Row.ResourceId) { [string]$Row.ResourceId } else { '' }
    if (-not $rid -and $Row.PSObject.Properties['EntityId'] -and $Row.EntityId) { $rid = [string]$Row.EntityId }
    $cat = if ($Row.PSObject.Properties['Category']   -and $Row.Category)   { [string]$Row.Category }   else { '' }
    $title = if ($Row.PSObject.Properties['Title']    -and $Row.Title)      { [string]$Row.Title }      else { '' }
    return "{0}|{1}|{2}|{3}" -f $src, $rid.ToLowerInvariant(), $cat, $title
}

function Get-ReportDelta {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] $Current,
        [Parameter(Mandatory)] $Previous
    )

    $currentArr  = @($Current)
    $previousArr = @($Previous)

    $currentIndex  = @{}
    $previousIndex = @{}

    foreach ($r in $currentArr) {
        if (-not $r) { continue }
        $k = Get-ReportDeltaKey -Row $r
        if (-not $currentIndex.ContainsKey($k)) { $currentIndex[$k] = $r }
    }
    foreach ($r in $previousArr) {
        if (-not $r) { continue }
        $k = Get-ReportDeltaKey -Row $r
        if (-not $previousIndex.ContainsKey($k)) { $previousIndex[$k] = $r }
    }

    $status   = @{}
    $newCount = 0
    $unchangedCount = 0

    foreach ($k in $currentIndex.Keys) {
        if ($previousIndex.ContainsKey($k)) {
            $status[$k] = 'Unchanged'
            $unchangedCount++
        } else {
            $status[$k] = 'New'
            $newCount++
        }
    }

    $resolved = [System.Collections.Generic.List[object]]::new()
    foreach ($k in $previousIndex.Keys) {
        if (-not $currentIndex.ContainsKey($k)) {
            $status[$k] = 'Resolved'
            $resolved.Add($previousIndex[$k]) | Out-Null
        }
    }

    $curNonCompliant = @($currentArr  | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant }).Count
    $prevNonCompliant = @($previousArr | Where-Object { $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant }).Count

    return [pscustomobject]@{
        Status   = $status
        Resolved = @($resolved)
        Summary  = [pscustomobject]@{
            New       = $newCount
            Resolved  = $resolved.Count
            Unchanged = $unchangedCount
            NetNonCompliantDelta = ($curNonCompliant - $prevNonCompliant)
        }
    }
}

function Resolve-BaselineRun {
    <#
    .SYNOPSIS
        Returns the path to the most recent snapshot in a snapshot index, for use as a delta baseline.
    .DESCRIPTION
        Reads SnapshotDir/index.json (SchemaVersion 1.0, written by Add-RunSnapshot) and returns
        the full path to the most recent entry's snapshot file, or $null when no prior snapshot exists.

        Call this BEFORE Add-RunSnapshot so the current run is not yet in the index.
        The snapshot-index design avoids scanning parent directories for sibling results.json files,
        which would produce false positives when runs share a flat output root (e.g. the default .\output).

        Note: only snapshots registered via Add-RunSnapshot appear in the index. Partial or
        incremental output directories (e.g. #94 layer dirs) are never indexed unless an explicit
        Add-RunSnapshot call targets them.
    .PARAMETER SnapshotDir
        Path to the snapshot directory (e.g. $OutputPath\snapshots).
    .OUTPUTS
        [string] absolute path to the baseline snapshot file, or $null.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $SnapshotDir
    )
    if (-not (Test-Path $SnapshotDir)) { return $null }
    $indexPath = Join-Path $SnapshotDir 'index.json'
    if (-not (Test-Path $indexPath)) { return $null }
    try {
        $parsed = Get-Content $indexPath -Raw | ConvertFrom-Json -ErrorAction Stop
        if (-not ($parsed.PSObject.Properties['SchemaVersion'])) {
            Write-Warning "Resolve-BaselineRun: index.json has no SchemaVersion; skipping baseline."
            return $null
        }
        if ($parsed.SchemaVersion -ne '1.0') {
            Write-Warning "Resolve-BaselineRun: unknown index SchemaVersion '$($parsed.SchemaVersion)'; skipping baseline."
            return $null
        }
        if (-not ($parsed.PSObject.Properties['Entries']) -or $null -eq $parsed.Entries) {
            Write-Warning "Resolve-BaselineRun: index.json Entries is absent or null; skipping baseline."
            return $null
        }
        $entries = @($parsed.Entries)
        if ($entries.Count -eq 0) { return $null }
        # Last entry is the most recently added snapshot.
        $latest = $entries[-1]
        $snapshotPath = Join-Path $SnapshotDir ([string]$latest.SnapshotFile)
        if (Test-Path $snapshotPath) { return $snapshotPath }
    } catch {
        Write-Warning "Resolve-BaselineRun: could not read snapshot index at ${SnapshotDir}: $_"
    }
    return $null
}

function Get-RunTrend {
    <#
    .SYNOPSIS
        Aggregates the last N snapshots from a snapshot index into a trend array ordered oldest to newest.
    .DESCRIPTION
        Reads SnapshotDir/index.json (SchemaVersion 1.0, written by Add-RunSnapshot) and returns an
        array of run-summary objects ordered oldest to newest so a sparkline reads left to right.

        Call this AFTER Add-RunSnapshot so the current run is included in the trend.

        Only entries registered via Add-RunSnapshot appear; partial or incremental layer directories
        (e.g. #94 per-tool dirs) are excluded by design since they are never indexed here.
    .PARAMETER SnapshotDir
        Path to the snapshot directory (e.g. $OutputPath\snapshots).
    .PARAMETER MaxRuns
        Maximum number of runs to include (default 10). The most recent N index entries are selected.
    .OUTPUTS
        Array of [pscustomobject]@{ RunId; Timestamp; Total; NonCompliant; BySeverity = @{ Critical; High; Medium; Low; Info } }
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $SnapshotDir,
        [int] $MaxRuns = 10
    )
    $result = [System.Collections.Generic.List[object]]::new()
    if (-not (Test-Path $SnapshotDir)) { return @($result) }
    $indexPath = Join-Path $SnapshotDir 'index.json'
    if (-not (Test-Path $indexPath)) { return @($result) }
    try {
        $parsed = Get-Content $indexPath -Raw | ConvertFrom-Json -ErrorAction Stop
        if (-not ($parsed.PSObject.Properties['SchemaVersion'])) {
            Write-Warning "Get-RunTrend: index.json has no SchemaVersion; skipping trend."
            return @($result)
        }
        if ($parsed.SchemaVersion -ne '1.0') {
            Write-Warning "Get-RunTrend: unknown index SchemaVersion '$($parsed.SchemaVersion)'; skipping trend."
            return @($result)
        }
        if (-not ($parsed.PSObject.Properties['Entries']) -or $null -eq $parsed.Entries) {
            Write-Warning "Get-RunTrend: index.json Entries is absent or null; skipping trend."
            return @($result)
        }
        $entries = @($parsed.Entries)
    } catch {
        Write-Warning "Get-RunTrend: could not read index at ${SnapshotDir}: $_"
        return @($result)
    }
    # Take the most recent MaxRuns entries; they are already in insertion (oldest-first) order.
    $selected = @($entries | Select-Object -Last $MaxRuns)
    foreach ($entry in $selected) {
        $snapshotPath = Join-Path $SnapshotDir ([string]$entry.SnapshotFile)
        if (-not (Test-Path $snapshotPath)) {
            Write-Warning "Get-RunTrend: snapshot file missing, skipping: $snapshotPath"
            continue
        }
        try {
            $findings     = @(Get-Content $snapshotPath -Raw | ConvertFrom-Json -ErrorAction Stop)
            $total        = $findings.Count
            $nonCompliant = @($findings | Where-Object {
                $_ -and $_.PSObject.Properties['Compliant'] -and -not $_.Compliant
            }).Count
            $sev = @{ Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0 }
            foreach ($level in @('Critical','High','Medium','Low','Info')) {
                $sev[$level] = @($findings | Where-Object {
                    $_ -and
                    $_.PSObject.Properties['Severity'] -and $_.Severity -eq $level -and
                    $_.PSObject.Properties['Compliant'] -and -not $_.Compliant
                }).Count
            }
            $ts = if ($entry.PSObject.Properties['Timestamp'] -and $entry.Timestamp) {
                try { [datetime]$entry.Timestamp } catch { [datetime]::MinValue }
            } else { [datetime]::MinValue }
            $result.Add([pscustomobject]@{
                RunId        = [string]$entry.RunId
                Timestamp    = $ts
                Total        = $total
                NonCompliant = $nonCompliant
                BySeverity   = [pscustomobject]$sev
            })
        } catch {
            Write-Warning "Get-RunTrend: could not parse ${snapshotPath}: $_"
        }
    }
    return @($result)
}

function Add-RunSnapshot {
    <#
    .SYNOPSIS
        Archives a results.json into the snapshot directory and updates the snapshot index atomically.
    .DESCRIPTION
        Copies SourceFile into SnapshotDir as <RunId>.json and appends an entry to
        SnapshotDir/index.json (SchemaVersion 1.0). The index is written atomically via a
        .tmp file + Move-Item -Force to guard against corruption from concurrent runs.
        Entries older than MaxHistory are pruned and their snapshot files deleted.

        Call this AFTER writing results.json and AFTER calling Resolve-BaselineRun (so the
        current run does not appear in the baseline lookup) but BEFORE calling Get-RunTrend
        (so the current run IS included in the sparkline).
    .PARAMETER SnapshotDir
        Destination snapshot directory.
    .PARAMETER RunId
        Unique identifier for this run. Use millisecond-precision timestamps plus a random
        suffix (e.g. 'yyyyMMdd-HHmmssfff-NNNN') to avoid second-resolution collisions on
        concurrent or rapid successive runs.
    .PARAMETER SourceFile
        Path to the results.json file to archive.
    .PARAMETER MaxHistory
        Maximum number of snapshots to retain (default 10). Oldest are pruned first.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $SnapshotDir,
        [Parameter(Mandatory)] [string] $RunId,
        [Parameter(Mandatory)] [string] $SourceFile,
        [int] $MaxHistory = 10
    )
    if (-not (Test-Path $SourceFile)) {
        Write-Warning "Add-RunSnapshot: source file not found, skipping: $SourceFile"
        return
    }
    $null = New-Item -ItemType Directory -Path $SnapshotDir -Force -ErrorAction SilentlyContinue
    $indexPath = Join-Path $SnapshotDir 'index.json'
    $tmpPath   = Join-Path $SnapshotDir 'index.json.tmp'
    $entries   = [System.Collections.Generic.List[object]]::new()

    # Read existing index; tolerates absent file (first run) or malformed JSON (fresh start).
    if (Test-Path $indexPath) {
        try {
            $parsed = Get-Content $indexPath -Raw | ConvertFrom-Json -ErrorAction Stop
            if ($parsed.PSObject.Properties['SchemaVersion'] -and $parsed.SchemaVersion -eq '1.0') {
                if ($parsed.PSObject.Properties['Entries'] -and $null -ne $parsed.Entries) {
                    foreach ($e in @($parsed.Entries)) { if ($null -ne $e) { $entries.Add($e) | Out-Null } }
                }
            } else {
                Write-Warning "Add-RunSnapshot: existing index has unknown schema; starting fresh."
            }
        } catch {
            Write-Warning "Add-RunSnapshot: could not read existing index, starting fresh: $_"
        }
    }

    # Archive the snapshot before updating the index so a crash between the two
    # leaves the index consistent (entry absent) rather than pointing at a missing file.
    $snapshotFile = "$RunId.json"
    $snapshotDest = Join-Path $SnapshotDir $snapshotFile
    Copy-Item -Path $SourceFile -Destination $snapshotDest -Force

    $entries.Add([pscustomobject]@{
        RunId        = $RunId
        Timestamp    = (Get-Date -Format 'o')
        SnapshotFile = $snapshotFile
    }) | Out-Null

    # Prune oldest entries when over the limit.
    while ($entries.Count -gt $MaxHistory) {
        $oldest = $entries[0]
        $fname  = if ($oldest -and $oldest.PSObject.Properties['SnapshotFile']) {
            [string]$oldest.SnapshotFile
        } else { $null }
        if (-not [string]::IsNullOrWhiteSpace($fname)) {
            $oldFile = Join-Path $SnapshotDir $fname
            if (Test-Path $oldFile) { Remove-Item $oldFile -Force -ErrorAction SilentlyContinue }
        }
        $entries.RemoveAt(0)
    }

    # Atomic write: write to .tmp then rename so a reader never sees a partial file.
    $indexObj = [pscustomobject]@{
        SchemaVersion = '1.0'
        Entries       = @($entries)
    }
    $indexObj | ConvertTo-Json -Depth 4 | Set-Content -Path $tmpPath -Encoding UTF8
    Move-Item -Path $tmpPath -Destination $indexPath -Force
}

function Get-MttrBySeverity {
    <#
    .SYNOPSIS
        Compute median Mean-Time-To-Remediate (in days) per severity from a run history.
    .DESCRIPTION
        Walks an ordered (oldest-first) array of run history entries (as produced by
        Get-RunHistory). For each consecutive pair of runs, any finding present in the
        earlier run but absent from the later run is treated as resolved at the later
        run's timestamp. Days-to-resolve = laterTimestamp - firstSeenTimestamp, where
        firstSeenTimestamp is the earliest run in which that finding appeared.

        Returns one row per severity (Critical/High/Medium/Low/Info) with:
          - Severity
          - ResolvedCount
          - MedianDays ([double] or $null when ResolvedCount -eq 0)
          - MeanDays ([double] or $null when ResolvedCount -eq 0)

        MTTR is only meaningful with 3+ runs containing resolved findings; for shorter
        histories the per-severity rows still return with ResolvedCount = 0 and null
        timing values so the caller can render "N/A".

    .PARAMETER History
        Array of objects each with { Timestamp; ResultsPath } (the shape returned by
        modules/shared/RunHistory.ps1::Get-RunHistory). May be empty.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $History
    )

    $severities = @('Critical','High','Medium','Low','Info')
    $stats = @{}
    foreach ($sev in $severities) { $stats[$sev] = [System.Collections.Generic.List[double]]::new() }

    if (-not $History -or $History.Count -lt 2) {
        return @($severities | ForEach-Object {
            [pscustomobject]@{ Severity = $_; ResolvedCount = 0; MedianDays = $null; MeanDays = $null }
        })
    }

    # First-seen index: key -> earliest timestamp + canonical row.
    $firstSeen = @{}
    $previousKeys = $null
    $previousRows = $null
    $previousTs   = $null

    foreach ($run in $History) {
        if (-not $run -or -not (Test-Path $run.ResultsPath)) { continue }
        $rows = @()
        try {
            $rows = @(Get-Content $run.ResultsPath -Raw | ConvertFrom-Json -ErrorAction Stop)
        } catch {
            Write-Warning "Get-MttrBySeverity: could not parse '$($run.ResultsPath)': $_"
            continue
        }

        $thisKeys = @{}
        foreach ($r in $rows) {
            if (-not $r) { continue }
            $k = Get-ReportDeltaKey -Row $r
            if (-not $thisKeys.ContainsKey($k)) { $thisKeys[$k] = $r }
            if (-not $firstSeen.ContainsKey($k)) {
                $firstSeen[$k] = [pscustomobject]@{ Timestamp = $run.Timestamp; Row = $r }
            }
        }

        if ($previousKeys -and $previousTs -and $run.Timestamp) {
            foreach ($k in $previousKeys.Keys) {
                if ($thisKeys.ContainsKey($k)) { continue }
                # Resolved in this run: earlier had it, current does not.
                $row = $previousKeys[$k]
                $sev = if ($row.PSObject.Properties['Severity'] -and $row.Severity) {
                    [string]$row.Severity
                } else { '' }
                $bucket = $null
                switch -Regex ($sev) {
                    '^(?i)critical$' { $bucket = 'Critical'; break }
                    '^(?i)high$'     { $bucket = 'High'; break }
                    '^(?i)medium$'   { $bucket = 'Medium'; break }
                    '^(?i)low$'      { $bucket = 'Low'; break }
                    '^(?i)info$'     { $bucket = 'Info'; break }
                }
                if (-not $bucket) { continue }
                $first = if ($firstSeen.ContainsKey($k)) { $firstSeen[$k].Timestamp } else { $previousTs }
                $days = ($run.Timestamp - $first).TotalDays
                if ($days -lt 0) { $days = 0 }
                $stats[$bucket].Add([double]$days) | Out-Null
            }
        }

        $previousKeys = $thisKeys
        $previousRows = $rows
        $previousTs   = $run.Timestamp
    }

    return @($severities | ForEach-Object {
        $sev = $_
        $vals = @($stats[$sev])
        if ($vals.Count -eq 0) {
            [pscustomobject]@{ Severity = $sev; ResolvedCount = 0; MedianDays = $null; MeanDays = $null }
        } else {
            $sorted = @($vals | Sort-Object)
            $count  = $sorted.Count
            $median = if ($count % 2 -eq 1) {
                $sorted[[math]::Floor($count / 2)]
            } else {
                ($sorted[$count / 2 - 1] + $sorted[$count / 2]) / 2.0
            }
            $mean = ($sorted | Measure-Object -Average).Average
            [pscustomobject]@{
                Severity      = $sev
                ResolvedCount = $count
                MedianDays    = [math]::Round([double]$median, 2)
                MeanDays      = [math]::Round([double]$mean, 2)
            }
        }
    })
}