modules/shared/RunHistory.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Run history storage for the executive dashboard (Phase 11 / #97).

.DESCRIPTION
    Persists per-run snapshots of results.json (and a small companion run-meta.json)
    under output/history/{yyyy-MM-dd-HHmmss}/ so trend analytics (sparklines, MTTR,
    severity-mix-over-time) can be computed across runs.

    Snapshots are deliberately a shallow copy of the v1 results.json findings only,
    not raw tool output, to keep history disk usage bounded.

    Functions:
      - Save-RunSnapshot : Copies results.json into history and writes run-meta.json.
      - Get-RunHistory : Returns ordered (oldest first) snapshot metadata + paths.
      - Remove-OldRunSnapshots : Prunes oldest snapshots beyond the retention count.
#>


Set-StrictMode -Version Latest

function Get-RunHistoryRoot {
    [CmdletBinding()]
    param ([Parameter(Mandatory)] [string] $OutputPath)
    return (Join-Path $OutputPath 'history')
}

function Save-RunSnapshot {
    <#
    .SYNOPSIS
        Persist a snapshot of the current results.json + lightweight metadata into history.
    .PARAMETER OutputPath
        Output directory (typically the orchestrator's -OutputPath, e.g. .\output).
    .PARAMETER ResultsPath
        Path to the v1 results.json that should be snapshotted.
    .PARAMETER Timestamp
        Optional explicit timestamp (defaults to UTC now). Used for deterministic tests.
    .PARAMETER Tools
        Optional list of tool names that ran in this scan.
    .PARAMETER Subscriptions
        Optional list of subscription IDs scanned in this run.
    .OUTPUTS
        [pscustomobject] with Path, MetaPath, Timestamp, FindingCount.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $OutputPath,
        [Parameter(Mandatory)] [string] $ResultsPath,
        [datetime] $Timestamp = (Get-Date).ToUniversalTime(),
        [string[]] $Tools = @(),
        [string[]] $Subscriptions = @()
    )

    if (-not (Test-Path $ResultsPath)) {
        Write-Warning "Save-RunSnapshot: results file not found at '$ResultsPath' - skipping snapshot."
        return $null
    }

    if ($Timestamp.Kind -ne [System.DateTimeKind]::Utc) {
        $Timestamp = $Timestamp.ToUniversalTime()
    }

    $historyRoot = Get-RunHistoryRoot -OutputPath $OutputPath
    if (-not (Test-Path $historyRoot)) {
        $null = New-Item -ItemType Directory -Path $historyRoot -Force
    }

    $stamp   = $Timestamp.ToString('yyyy-MM-dd-HHmmss')
    $runDir  = Join-Path $historyRoot $stamp
    if (-not (Test-Path $runDir)) {
        $null = New-Item -ItemType Directory -Path $runDir -Force
    }

    $snapshotPath = Join-Path $runDir 'results.json'
    Copy-Item -Path $ResultsPath -Destination $snapshotPath -Force

    # Build lightweight run-meta.json
    $findings = @()
    try {
        $findings = @(Get-Content $snapshotPath -Raw | ConvertFrom-Json -ErrorAction Stop)
    } catch {
        Write-Warning "Save-RunSnapshot: unable to parse snapshot for severity counts: $_"
    }

    $sevCounts = [ordered]@{
        Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0
    }
    $nonCompliantSevCounts = [ordered]@{
        Critical = 0; High = 0; Medium = 0; Low = 0; Info = 0
    }
    $nonCompliant = 0
    foreach ($f in $findings) {
        if (-not $f) { continue }
        $sev = $null
        if ($f.PSObject.Properties['Severity'] -and $f.Severity) { $sev = [string]$f.Severity }
        $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 ($bucket) { $sevCounts[$bucket]++ }
        $isNonCompliant = $f.PSObject.Properties['Compliant'] -and -not $f.Compliant
        if ($isNonCompliant) {
            $nonCompliant++
            if ($bucket) { $nonCompliantSevCounts[$bucket]++ }
        }
    }

    $meta = [pscustomobject]@{
        Timestamp                     = $Timestamp.ToString('o')
        Stamp                         = $stamp
        Tools                         = @($Tools)
        Subscriptions                 = @($Subscriptions)
        FindingCount                  = @($findings).Count
        NonCompliantCount             = $nonCompliant
        SeverityCounts                = $sevCounts
        NonCompliantSeverityCounts    = $nonCompliantSevCounts
        SchemaVersion                 = '1.1'
    }
    $metaPath = Join-Path $runDir 'run-meta.json'
    $meta | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8

    return [pscustomobject]@{
        Path         = $snapshotPath
        MetaPath     = $metaPath
        Timestamp    = $Timestamp
        Stamp        = $stamp
        FindingCount = $meta.FindingCount
    }
}

function Get-RunHistory {
    <#
    .SYNOPSIS
        Returns ordered (oldest first) snapshot metadata for every run found under output/history/.
    .OUTPUTS
        Array of [pscustomobject] { Stamp; Timestamp; ResultsPath; MetaPath; Meta }.
        Empty array when no history exists.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $OutputPath,
        [string] $HistoryPath
    )

    if ($HistoryPath) {
        $historyRoot = $HistoryPath
    } else {
        $historyRoot = Get-RunHistoryRoot -OutputPath $OutputPath
    }
    if (-not (Test-Path $historyRoot)) { return @() }

    $dirs = @(Get-ChildItem -Path $historyRoot -Directory -ErrorAction SilentlyContinue) |
        Sort-Object -Property Name

    $out = [System.Collections.Generic.List[object]]::new()
    foreach ($d in $dirs) {
        $resultsPath = Join-Path $d.FullName 'results.json'
        $metaPath    = Join-Path $d.FullName 'run-meta.json'
        if (-not (Test-Path $resultsPath)) { continue }

        $meta = $null
        if (Test-Path $metaPath) {
            try { $meta = Get-Content $metaPath -Raw | ConvertFrom-Json -ErrorAction Stop } catch { $meta = $null }
        }

        $ts = $null
        if ($meta -and $meta.PSObject.Properties['Timestamp'] -and $meta.Timestamp) {
            try {
                $ts = [datetime]::Parse(
                    [string]$meta.Timestamp,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [System.Globalization.DateTimeStyles]::RoundtripKind)
            } catch { $ts = $null }
        }
        if (-not $ts) {
            try {
                $ts = [datetime]::ParseExact(
                    $d.Name, 'yyyy-MM-dd-HHmmss',
                    [System.Globalization.CultureInfo]::InvariantCulture)
            } catch { $ts = $d.CreationTimeUtc }
        }

        $out.Add([pscustomobject]@{
            Stamp       = $d.Name
            Timestamp   = $ts
            ResultsPath = $resultsPath
            MetaPath    = if (Test-Path $metaPath) { $metaPath } else { $null }
            Meta        = $meta
        }) | Out-Null
    }

    return @($out | Sort-Object Timestamp)
}

function Remove-OldRunSnapshots {
    <#
    .SYNOPSIS
        Prune oldest history snapshots beyond -Retention.
    .PARAMETER Retention
        Number of most recent snapshots to keep. Must be >= 1. Default 30.
    .OUTPUTS
        Array of stamps that were removed.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string] $OutputPath,
        [ValidateRange(1, 365)]
        [int] $Retention = 30
    )

    $history = @(Get-RunHistory -OutputPath $OutputPath)
    if ($history.Count -le $Retention) { return @() }

    $toRemove = $history | Select-Object -First ($history.Count - $Retention)
    $removed  = [System.Collections.Generic.List[string]]::new()
    foreach ($h in $toRemove) {
        $dir = Split-Path $h.ResultsPath -Parent
        try {
            Remove-Item -Path $dir -Recurse -Force -ErrorAction Stop
            $removed.Add($h.Stamp) | Out-Null
        } catch {
            Write-Warning "Remove-OldRunSnapshots: failed to prune '$dir': $_"
        }
    }
    return @($removed)
}