Public/Export-AzLocalApplyUpdatesScheduleAudit.ps1

function Export-AzLocalApplyUpdatesScheduleAudit {
    <#
    .SYNOPSIS
        Runs the Step.3 Apply-Updates Schedule Coverage Audit workload:
        Test-AzLocalApplyUpdatesScheduleCoverage (Audit + Matrix +
        Recommend views) + JUnit XML + markdown step summary + cycle
        calendar + allow-list coverage + step outputs for the v0.8.5
        thin-YAML Step.3 pipeline.
 
    .DESCRIPTION
        Phase 1 (v0.8.5) of the thin-YAML refactor. Condenses the
        ~220-line inline `run: |` audit block and the ~210-line inline
        Create Summary block in v0.8.4 Step.3_apply-updates-schedule-
        audit.yml (GitHub Actions + Azure DevOps) into a single cmdlet
        call so the per-platform yml shrinks to a few lines and the
        workload becomes unit-testable against synthetic schedule and
        pipeline YAML inputs.
 
        The cmdlet:
 
          1. Resolves the output directory (defaults to './reports' on
             GitHub Actions / Local, or `$env:BUILD_ARTIFACTSTAGINGDIRECTORY`
             on Azure DevOps).
          2. Calls `Test-AzLocalApplyUpdatesScheduleCoverage` 3 times:
             -View Audit -PassThru (rows + CSV), -View Matrix
             (CSV only), -View Recommend (markdown only). Re-uses the
             v0.7.65+ advisor instead of re-implementing the
             ring/cron diff math inline.
          3. Bucketises rows by Status into Covered / Uncovered /
             PartiallyCovered / MalformedTag / UnparseableCron /
             NoWindowTag / RingMissingFromSchedule /
             RingOrphanedInSchedule / RingMixedWindows.
          4. Builds a JUnit XML report via the shared
             `New-AzLocalPipelineJUnitXml` Private helper. Schedule
             rows go in the 'Schedule (ring diff)' suite FIRST
             (higher blast radius), Cron rows in the 'Cron coverage'
             suite. Any non-Covered status becomes a <failure>.
          5. Emits 12 step outputs via `Set-AzLocalPipelineOutput`
             (lowercase snake_case per v0.8.5 convention):
             total_rows, covered, uncovered, partial, malformed,
             unparseable, no_window_tag, ring_missing, ring_orphan,
             ring_mixed, have_schedule, schedule_path.
          6. Builds the markdown step summary:
             - Summary counts table (10 rows)
             - Recommend snippet at the top when $hasIssues
             - Audit Detail - Schedule (ring-file gap) sub-table
               (rendered whenever -SchedulePath was supplied)
             - Allow-list coverage section (renders schema v1
               migration nudge or schema v2 per-row effective
               allow-list with optional pin-snippet)
             - Audit Detail - Cron coverage sub-table
             - Recommend snippet at the bottom when clean fleet
             - **Cycle calendar** - ALWAYS rendered when
               -SchedulePath is supplied (v0.8.5 fix for the v0.8.4
               $hasIssues-gate regression that silently dropped the
               calendar from clean-fleet runs).
             - Reports list with artifact filenames
          7. Pushes the markdown via `Add-AzLocalPipelineStepSummary`.
 
        Internal reuse (per the v0.8.5 thin-YAML consistency contract):
          * `Test-AzLocalApplyUpdatesScheduleCoverage` for advisor data.
          * `Get-AzLocalApplyUpdatesScheduleConfig` for allow-list / cycle.
          * `Get-AzLocalApplyUpdatesScheduleCycleCalendar -AsMarkdown
            -IncludePerRingSummary` for the calendar section.
          * `New-AzLocalPipelineJUnitXml` for JUnit XML.
          * `Add-AzLocalPipelineStepSummary` for the rendered markdown.
          * `Set-AzLocalPipelineOutput` for the step outputs.
          * `Get-AzLocalPipelineHost` is implicit (the above branch on it).
 
    .PARAMETER OutputDirectory
        Directory to write artifacts into. Created if it does not exist.
        Defaults to './reports' (GH / Local) or
        `$env:BUILD_ARTIFACTSTAGINGDIRECTORY` (Azure DevOps).
 
    .PARAMETER PipelineYamlPath
        Path (file or folder) to apply-updates.yml. REQUIRED so
        the Recommend view can diff its proposed crons against what is
        already in apply-updates and only emit a snippet for the truly missing
        entries.
 
    .PARAMETER SchedulePath
        Optional path to apply-updates-schedule.yml. When set, the audit
        also reports RingMissingFromSchedule / RingOrphanedInSchedule
        rows, the Allow-list coverage section, and the always-rendered
        Cycle calendar.
 
    .PARAMETER LeadTimeMinutes
        Minutes before UpdateStartWindow opens that the pipeline should
        fire (0-60). Default 5.
 
    .PARAMETER RecommendFiresPerWindow
        Cron entries the Recommend view should emit per UpdateStartWindow
        segment. 1 = opening edge only. 2 = belt-and-braces (default).
 
    .PARAMETER IncludeUntagged
        Surface clusters with no UpdateStartWindow tag as their own row.
 
    .PARAMETER ClusterCsvPath
        Optional path to the source-controlled cluster inventory CSV.
        When set (and the file exists) the Recommend view emits a
        NoWindowTag remediation section.
 
    .PARAMETER Platform
        Pipeline platform for the Recommend snippet:
        GitHubActions (default in pipeline contexts), AzureDevOps, Both.
 
    .PARAMETER AuditCsvFileName
        Filename for the per-(Ring, Window) audit CSV.
        Default 'schedule-coverage-audit.csv'.
 
    .PARAMETER MatrixCsvFileName
        Filename for the matrix-view CSV. Default 'schedule-coverage-matrix.csv'.
 
    .PARAMETER RecommendMdFileName
        Filename for the Recommend snippet markdown.
        Default 'schedule-coverage-recommend.md'.
 
    .PARAMETER JUnitXmlFileName
        Filename for the JUnit XML report.
        Default 'schedule-coverage-audit.xml'.
 
    .PARAMETER SummaryFileName
        Per-task markdown summary filename used by
        `Add-AzLocalPipelineStepSummary` on Azure DevOps and Local hosts.
        Default 'schedule-coverage-summary.md'.
 
    .PARAMETER SideloadEnabled
        Opt-in switch for the v0.8.7 "Recommended sideload schedule" section.
        When not explicitly bound, it is resolved from the SIDELOAD_UPDATES
        environment variable (true/1/yes/on => enabled). When disabled the
        section is omitted entirely and the audit is byte-identical to v0.8.6.
 
    .PARAMETER SideloadLeadDays
        How many days before a ring's apply window the media should already be
        sideloaded (drives the recommended sideload kickoff cron = apply firing
        shifted back this many days). When not explicitly bound it is resolved
        from the SIDELOAD_LEAD_DAYS environment variable, defaulting to 7.
 
    .PARAMETER MonitorPollIntervalMinutes
        Desired frequency the "Recommended in-flight monitor schedule" section
        suggests the Update: 4 monitor-updates pipeline should poll while an
        update run is in flight. One of 15, 20, 30, 60, 120, 180, 240 minutes
        (default 30). Values < 60 set the cron minute field to '*/N'; values
        >= 60 set the minute field to '0' and step the hour field every N/60
        hours. Lets operators dial cadence to run duration (single-node runs
        ~4-5h vs multi-node up to ~48h).
 
    .PARAMETER MonitorTrailingDays
        How many days after an apply window opens an update run may still be
        in flight (0-14, default 3, mirroring the Update: 4 monitor's CRITICAL
        elapsed tier). The recommended monitor cron covers the eligible
        weekday(s) plus this many trailing days so multi-day runs stay
        observed. Any value > 0 forces the hour field to '*' (a run can carry
        into the next day's hours).
 
    .PARAMETER MonitorInFlightHours
        How many hours past the latest UpdateStartWindow end the recommended
        monitor cron keeps polling, to catch runs still finishing after the
        maintenance window closes (0-48, default 6). The monitor hour field is
        bounded to [earliest window start .. latest window end + this buffer]
        when that span stays within a single UTC day; otherwise it falls back
        to '*' (all hours) so no in-flight time is left unpolled.
 
    .PARAMETER InstalledModuleVersion
        Optional [string] used in the markdown footer
        ('Generated by AzLocal.UpdateManagement v<x>').
 
    .PARAMETER PassThru
        When set, returns a single PSCustomObject summarising the run.
        Without -PassThru the cmdlet emits nothing to the pipeline; the
        artifacts and step outputs are still produced.
 
    .OUTPUTS
        Nothing by default. When -PassThru is set, a single PSCustomObject
        with: TotalRows, Covered, Uncovered, Partial, Malformed,
        Unparseable, NoWindowTag, RingMissing, RingOrphan, RingMixed,
        HaveSchedule, SchedulePath, AuditRows, AuditCsvPath,
        MatrixCsvPath, RecommendMdPath, JUnitXmlPath, SummaryPath.
 
    .EXAMPLE
        Export-AzLocalApplyUpdatesScheduleAudit `
            -PipelineYamlPath '.github/workflows' `
            -SchedulePath '.github/apply-updates-schedule.yml' `
            -Platform GitHubActions -PassThru
 
    .NOTES
        Author: Neil Bird, MSFT
        Added: v0.8.5
        Module: AzLocal.UpdateManagement
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $false)]
        [string]$OutputDirectory,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$PipelineYamlPath,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$SchedulePath,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60)]
        [int]$LeadTimeMinutes = 5,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 2)]
        [int]$RecommendFiresPerWindow = 2,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeUntagged,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$ClusterCsvPath,

        [Parameter(Mandatory = $false)]
        [ValidateSet('GitHubActions', 'AzureDevOps', 'Both')]
        [string]$Platform = 'GitHubActions',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$AuditCsvFileName = 'schedule-coverage-audit.csv',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$MatrixCsvFileName = 'schedule-coverage-matrix.csv',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$RecommendMdFileName = 'schedule-coverage-recommend.md',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$JUnitXmlFileName = 'schedule-coverage-audit.xml',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'schedule-coverage-summary.md',

        [Parameter(Mandatory = $false)]
        [bool]$SideloadEnabled,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 365)]
        [int]$SideloadLeadDays = 7,

        [Parameter(Mandatory = $false)]
        [ValidateSet(15, 20, 30, 60, 120, 180, 240)]
        [int]$MonitorPollIntervalMinutes = 30,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 14)]
        [int]$MonitorTrailingDays = 3,

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 48)]
        [int]$MonitorInFlightHours = 6,

        [Parameter(Mandatory = $false)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]$InstalledModuleVersion,

        [Parameter(Mandatory = $false)]
        [switch]$PassThru
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $pipelineHost = Get-AzLocalPipelineHost

    if (-not $OutputDirectory) {
        if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) {
            $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY
        }
        else {
            $OutputDirectory = './reports'
        }
    }
    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }

    if (-not (Test-Path -LiteralPath $PipelineYamlPath)) {
        throw "PipelineYamlPath '$PipelineYamlPath' does not exist. Set it to the folder containing apply-updates.yml (e.g. '.github/workflows' or '.azure-pipelines') so the Recommend view can diff its proposed crons against the existing file."
    }

    $haveSchedule = $false
    if ($SchedulePath -and (Test-Path -LiteralPath $SchedulePath)) {
        $haveSchedule = $true
    }
    $haveCsv = $false
    if ($ClusterCsvPath -and (Test-Path -LiteralPath $ClusterCsvPath)) {
        $haveCsv = $true
    }

    $auditCsv  = Join-Path -Path $OutputDirectory -ChildPath $AuditCsvFileName
    $matrixCsv = Join-Path -Path $OutputDirectory -ChildPath $MatrixCsvFileName
    $recoMd    = Join-Path -Path $OutputDirectory -ChildPath $RecommendMdFileName
    $xmlPath   = Join-Path -Path $OutputDirectory -ChildPath $JUnitXmlFileName

    Write-Host '========================================'
    Write-Host 'Apply-Updates Schedule Coverage Audit'
    Write-Host '========================================'
    Write-Host "PipelineYamlPath : $PipelineYamlPath"
    Write-Host "SchedulePath : $(if ($haveSchedule) { $SchedulePath } else { '(skipped - empty or missing)' })"
    Write-Host "LeadTimeMinutes : $LeadTimeMinutes"
    Write-Host "FiresPerWindow : $RecommendFiresPerWindow"
    Write-Host "IncludeUntagged : $IncludeUntagged"
    Write-Host "ClusterCsvPath : $(if ($haveCsv) { $ClusterCsvPath } else { '(skipped - empty or missing)' })"
    Write-Host "Platform : $Platform"
    Write-Host "MonitorPollMins : $MonitorPollIntervalMinutes"
    Write-Host "MonitorTrailDays : $MonitorTrailingDays"
    Write-Host "MonitorInFlightH : $MonitorInFlightHours"
    Write-Host ''

    # ---- Audit view (rows + CSV) ------------------------------------------
    $auditArgs = @{
        View                    = 'Audit'
        LeadTimeMinutes         = $LeadTimeMinutes
        RecommendFiresPerWindow = $RecommendFiresPerWindow
        ExportPath              = $auditCsv
        Platform                = $Platform
        PassThru                = $true
        PipelineYamlPath        = $PipelineYamlPath
    }
    if ($haveSchedule) { $auditArgs.SchedulePath   = $SchedulePath }
    if ($IncludeUntagged) { $auditArgs.IncludeUntagged = $true }
    if ($haveCsv)      { $auditArgs.ClusterCsvPath = $ClusterCsvPath }
    $audit = Test-AzLocalApplyUpdatesScheduleCoverage @auditArgs
    if (-not $audit) { $audit = @() }
    $audit = @($audit)

    # ---- Matrix view (CSV only) -------------------------------------------
    Test-AzLocalApplyUpdatesScheduleCoverage -View Matrix `
        -LeadTimeMinutes $LeadTimeMinutes -ExportPath $matrixCsv | Out-Null

    # ---- Recommend view (markdown only) -----------------------------------
    $recoArgs = @{
        View                    = 'Recommend'
        LeadTimeMinutes         = $LeadTimeMinutes
        RecommendFiresPerWindow = $RecommendFiresPerWindow
        Platform                = $Platform
        ExportPath              = $recoMd
        PipelineYamlPath        = $PipelineYamlPath
    }
    if ($haveSchedule) { $recoArgs.SchedulePath   = $SchedulePath }
    if ($haveCsv)      { $recoArgs.ClusterCsvPath = $ClusterCsvPath }
    # v0.8.75: suppress the inner plain (5-column) cycle calendar from
    # the Recommend output; this wrapper renders its own enriched
    # (7-column) calendar below, so without this switch the Step.3
    # step summary would render two cycle-calendar tables back-to-back.
    $recoArgs.OmitCycleCalendar = $true
    Test-AzLocalApplyUpdatesScheduleCoverage @recoArgs | Out-Null

    # ---- Bucketise --------------------------------------------------------
    $covered     = @($audit | Where-Object { $_.Status -eq 'Covered' })
    $uncovered   = @($audit | Where-Object { $_.Status -eq 'Uncovered' })
    $partial     = @($audit | Where-Object { $_.Status -eq 'PartiallyCovered' })
    $malformed   = @($audit | Where-Object { $_.Status -eq 'MalformedTag' })
    $unparseable = @($audit | Where-Object { $_.Status -eq 'UnparseableCron' })
    $noWindowTag = @($audit | Where-Object { $_.Status -eq 'NoWindowTag' })
    $ringMissing = @($audit | Where-Object { $_.Status -eq 'RingMissingFromSchedule' })
    $ringOrphan  = @($audit | Where-Object { $_.Status -eq 'RingOrphanedInSchedule' })
    $ringMixed   = @($audit | Where-Object { $_.Status -eq 'RingMixedWindows' })

    $hasIssues = ($uncovered.Count + $partial.Count + $malformed.Count + $unparseable.Count + $noWindowTag.Count + $ringMissing.Count + $ringOrphan.Count) -gt 0

    Write-Host ''
    Write-Host 'Audit complete:'
    Write-Host " Covered : $($covered.Count)"
    Write-Host " Uncovered : $($uncovered.Count)"
    Write-Host " PartiallyCovered : $($partial.Count)"
    Write-Host " MalformedTag : $($malformed.Count)"
    Write-Host " UnparseableCron : $($unparseable.Count)"
    Write-Host " NoWindowTag : $($noWindowTag.Count)"
    Write-Host " RingMissingFromSchedule : $($ringMissing.Count)"
    Write-Host " RingOrphanedInSchedule : $($ringOrphan.Count)"
    Write-Host " RingMixedWindows : $($ringMixed.Count)"

    # ---- JUnit XML --------------------------------------------------------
    $scheduleRows = @($audit | Where-Object { $_.Section -eq 'Schedule' })
    $cronRows     = @($audit | Where-Object { $_.Section -ne 'Schedule' })

    $buildCase = {
        param($Row)
        $tcName = '{0} / {1}' -f $Row.UpdateRing, ($(if ($Row.UpdateStartWindow) { $Row.UpdateStartWindow } else { '(no window)' }))
        $tc = @{ Name = $tcName; ClassName = 'ScheduleCoverage' }
        if ($Row.Status -ne 'Covered') {
            $issue = if ($Row.PSObject.Properties['Issue'] -and $Row.Issue) { [string]$Row.Issue } else { '' }
            $reco  = if ($Row.PSObject.Properties['Recommendation'] -and $Row.Recommendation) { [string]$Row.Recommendation } else { '' }
            $clCnt = if ($Row.PSObject.Properties['ClusterCount']) { [string]$Row.ClusterCount } else { '0' }
            $body  = "Recommendation: $reco`nCluster count: $clCnt"
            $tc.Failure = @{ Message = $issue; Type = [string]$Row.Status; Body = $body }
        }
        $tc
    }

    $suites = @()
    if ($haveSchedule -or $scheduleRows.Count -gt 0) {
        $cases = @()
        foreach ($r in $scheduleRows) { $cases += ,(& $buildCase $r) }
        if ($cases.Count -eq 0) {
            $cases = @(@{ Name = 'Schedule and fleet ring sets match - no gaps.'; ClassName = 'ScheduleCoverage' })
        }
        $suites += ,@{
            Name      = 'Apply-Updates Schedule Coverage - Schedule (ring diff)'
            ClassName = 'ScheduleCoverage'
            TestCases = $cases
        }
    }
    if ($cronRows.Count -gt 0 -or -not $haveSchedule) {
        $cases = @()
        foreach ($r in $cronRows) { $cases += ,(& $buildCase $r) }
        if ($cases.Count -eq 0) {
            $cases = @(@{ Name = 'No tagged clusters found - nothing to audit'; ClassName = 'ScheduleCoverage' })
        }
        $suites += ,@{
            Name      = 'Apply-Updates Schedule Coverage - Cron coverage'
            ClassName = 'ScheduleCoverage'
            TestCases = $cases
        }
    }
    New-AzLocalPipelineJUnitXml -TestSuitesName 'Apply-Updates Schedule Coverage' -Suites $suites -OutputPath $xmlPath | Out-Null

    # ---- Step outputs -----------------------------------------------------
    Set-AzLocalPipelineOutput -Name 'total_rows'    -Value ([string]$audit.Count)
    Set-AzLocalPipelineOutput -Name 'covered'       -Value ([string]$covered.Count)
    Set-AzLocalPipelineOutput -Name 'uncovered'     -Value ([string]$uncovered.Count)
    Set-AzLocalPipelineOutput -Name 'partial'       -Value ([string]$partial.Count)
    Set-AzLocalPipelineOutput -Name 'malformed'     -Value ([string]$malformed.Count)
    Set-AzLocalPipelineOutput -Name 'unparseable'   -Value ([string]$unparseable.Count)
    Set-AzLocalPipelineOutput -Name 'no_window_tag' -Value ([string]$noWindowTag.Count)
    Set-AzLocalPipelineOutput -Name 'ring_missing'  -Value ([string]$ringMissing.Count)
    Set-AzLocalPipelineOutput -Name 'ring_orphan'   -Value ([string]$ringOrphan.Count)
    Set-AzLocalPipelineOutput -Name 'ring_mixed'    -Value ([string]$ringMixed.Count)
    Set-AzLocalPipelineOutput -Name 'have_schedule' -Value ([string]$haveSchedule)
    Set-AzLocalPipelineOutput -Name 'schedule_path' -Value $(if ($haveSchedule) { $SchedulePath } else { '' })

    # ---- Recommend snippet content (for top-of-summary inlining) ----------
    $recoContent = ''
    if (Test-Path -LiteralPath $recoMd) {
        $recoContent = Get-Content -LiteralPath $recoMd -Raw
        if ($null -eq $recoContent) { $recoContent = '' }
    }

    # ---- Markdown step summary --------------------------------------------
    $md = New-Object 'System.Collections.Generic.List[string]'
    [void]$md.Add('## Apply-Updates Schedule Coverage Audit')
    [void]$md.Add('')
    [void]$md.Add('| Metric | Count |')
    [void]$md.Add('|--------|-------|')
    [void]$md.Add("| **(Ring, Window) pairs audited** | $($audit.Count) |")
    [void]$md.Add("| **Covered** | $($covered.Count) |")
    [void]$md.Add("| **Uncovered** | $($uncovered.Count) |")
    [void]$md.Add("| **PartiallyCovered** | $($partial.Count) |")
    [void]$md.Add("| **MalformedTag** | $($malformed.Count) |")
    [void]$md.Add("| **UnparseableCron** | $($unparseable.Count) |")
    [void]$md.Add("| **NoWindowTag** | $($noWindowTag.Count) |")
    [void]$md.Add("| **RingMissingFromSchedule** | $($ringMissing.Count) |")
    [void]$md.Add("| **RingOrphanedInSchedule** | $($ringOrphan.Count) |")
    [void]$md.Add("| **RingMixedWindows** | $($ringMixed.Count) |")
    [void]$md.Add('')

    if ($hasIssues -and $recoContent) {
        [void]$md.Add($recoContent)
        [void]$md.Add('')
    }

    # Helper for the per-section detail table (Schedule + Cron).
    $addDetailTable = {
        param([string]$Heading, [object[]]$DetailRows, [string]$EmptyText)
        if ($Heading) { [void]$md.Add($Heading) }
        [void]$md.Add('')
        if ($DetailRows.Count -eq 0) {
            [void]$md.Add("*$EmptyText*")
            [void]$md.Add('')
            return
        }
        [void]$md.Add('| Status | UpdateRing | UpdateStartWindow | Clusters | Required Cron (UTC) | Recommendation |')
        [void]$md.Add('|--------|------------|--------------|---------:|----------------------|----------------|')
        $shown = 0
        foreach ($r in $DetailRows) {
            if ($shown -ge 100) { break }
            $status   = if ($r.PSObject.Properties['Status']) { [string]$r.Status } else { '' }
            $ring     = if ($r.PSObject.Properties['UpdateRing']) { [string]$r.UpdateRing } else { '' }
            $win      = if ($r.PSObject.Properties['UpdateStartWindow']) { [string]$r.UpdateStartWindow } else { '' }
            $clCnt    = if ($r.PSObject.Properties['ClusterCount']) { [string]$r.ClusterCount } else { '' }
            $reqCron  = if ($r.PSObject.Properties['RequiredCronUTC']) { [string]$r.RequiredCronUTC } else { '' }
            $reco     = if ($r.PSObject.Properties['Recommendation']) { [string]$r.Recommendation } else { '' }
            [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f $status, $ring, $win, $clCnt, $reqCron, $reco))
            $shown++
        }
        if ($DetailRows.Count -gt 100) {
            [void]$md.Add('')
            [void]$md.Add("*Showing first 100 of $($DetailRows.Count); see ``$AuditCsvFileName`` artifact for the full list.*")
        }
        [void]$md.Add('')
    }

    # Schedule detail (always rendered when -SchedulePath supplied OR when
    # any Schedule-section rows exist - defensive fallback).
    if ($haveSchedule -or $scheduleRows.Count -gt 0) {
        [void]$md.Add('### Audit Detail - Schedule (ring-file gap)')
        [void]$md.Add('')
        [void]$md.Add('> **Higher blast radius.** A `RingMissingFromSchedule` row means apply-updates will NEVER fire on those clusters until you either add the ring to `apply-updates-schedule.yml` or retag them onto an existing scheduled ring.')
        & $addDetailTable '' $scheduleRows 'Schedule and fleet ring sets match - no gaps.'
    }

    # Allow-list coverage (schema v1 nudge or schema v2 per-row table).
    if ($haveSchedule) {
        $sched = $null
        try {
            $sched = Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath -ErrorAction Stop
        }
        catch {
            [void]$md.Add('### Allow-list coverage')
            [void]$md.Add('')
            [void]$md.Add("*Could not load schedule file ``$SchedulePath`` for allow-list analysis: $($_.Exception.Message)*")
            [void]$md.Add('')
        }
        if ($sched) {
            [void]$md.Add("### Allow-list coverage (schema v$($sched.SchemaVersion))")
            [void]$md.Add('')
            if ($sched.SchemaVersion -lt 2) {
                [void]$md.Add('*This schedule is on schema v1. Schema v2 adds `allowedUpdateVersions` for fleet-wide + per-ring update allow-lists (e.g. enforce a ''minimum updates'' policy on Prod). Migrate the file with:*')
                [void]$md.Add('')
                [void]$md.Add('```powershell')
                [void]$md.Add("Update-AzLocalApplyUpdatesScheduleConfig -Path '$SchedulePath' -SchemaMigrate")
                [void]$md.Add('```')
                [void]$md.Add('')
            }
            else {
                $topAllow = @()
                if ($sched.PSObject.Properties['AllowedUpdateVersions'] -and $sched.AllowedUpdateVersions) {
                    $topAllow = @($sched.AllowedUpdateVersions)
                }
                $topIsLatest = ($topAllow.Count -eq 1 -and $topAllow[0] -eq 'Latest')
                if ($topIsLatest) {
                    $topLabel = '`Latest` _(no constraint - install the latest Ready update on each cluster)_'
                }
                else {
                    $topLabel = "``$($topAllow -join '; ')`` _(explicit allow-list - clusters only install matching updates)_"
                }
                [void]$md.Add("**Top-level fleet default:** $topLabel")
                [void]$md.Add('')
                [void]$md.Add('| Row | weeksInCycle | daysOfWeek | rings | Effective allowedUpdateVersions |')
                [void]$md.Add('|----:|--------------|------------|-------|---------------------------------|')
                $rowsMissingOverride = New-Object System.Collections.Generic.List[object]
                $rowIdx = 0
                foreach ($r in @($sched.Schedule)) {
                    $rowIdx++
                    $rowAllow = @()
                    if ($r.PSObject.Properties['AllowedUpdateVersionsParsed'] -and $r.AllowedUpdateVersionsParsed) {
                        $rowAllow = @($r.AllowedUpdateVersionsParsed)
                    }
                    if ($rowAllow.Count -gt 0) {
                        if ($rowAllow.Count -eq 1 -and $rowAllow[0] -eq 'Latest') {
                            $effLabel = '`Latest` _(row override: no constraint)_'
                        }
                        else {
                            $effLabel = "``$($rowAllow -join '; ')`` _(row override)_"
                        }
                    }
                    else {
                        if ($topIsLatest) {
                            $effLabel = '`Latest` _(inherits top-level - no constraint)_'
                        }
                        else {
                            $effLabel = "``$($topAllow -join '; ')`` _(inherits top-level)_"
                        }
                        $rowsMissingOverride.Add([pscustomobject]@{
                            Row          = $rowIdx
                            WeeksInCycle = $r.weeksInCycle
                            DaysOfWeek   = $r.daysOfWeek
                            Rings        = $r.rings
                        })
                    }
                    [void]$md.Add(('| {0} | {1} | {2} | {3} | {4} |' -f $rowIdx, $r.weeksInCycle, $r.daysOfWeek, $r.rings, $effLabel))
                }
                [void]$md.Add('')
                if ($rowsMissingOverride.Count -gt 0) {
                    [void]$md.Add("*$($rowsMissingOverride.Count) row(s) inherit the top-level allow-list. This is fine when you want each ring to install the latest Ready update as soon as it is available.*")
                    [void]$md.Add('')
                    [void]$md.Add("### Optional - pin a ring to a specific update in ``$SchedulePath``")
                    [void]$md.Add('')
                    [void]$md.Add('*Only needed if you want to PIN a ring to a specific update (e.g. keep Prod on the latest feature drop only). This is NOT a fix for the cron-coverage or ring-diff sections above.* Add `allowedUpdateVersions:` to the row that covers the ring you want to pin. Use `Get-AzLocalAvailableUpdates` (or the Azure portal -> Azure Local -> Updates) to find valid update names / version strings.')
                    [void]$md.Add('')
                    [void]$md.Add('```yaml')
                    [void]$md.Add("- weeksInCycle: '*'")
                    [void]$md.Add(" daysOfWeek: 'Tue,Wed,Thu'")
                    [void]$md.Add(" rings: 'Prod'")
                    [void]$md.Add(" allowedUpdateVersions: 'Solution12.2604.1003.1005;Solution12.2610.1003.XX'")
                    [void]$md.Add('```')
                    [void]$md.Add('')
                }
                else {
                    [void]$md.Add('*Every schedule row has an explicit `allowedUpdateVersions:` override - no inheritance from the top-level default.*')
                    [void]$md.Add('')
                }
            }
        }
    }

    & $addDetailTable '### Audit Detail - Cron coverage (Uncovered / Partial / Malformed first)' $cronRows 'No tagged clusters found - nothing to audit.'

    if (-not $hasIssues -and $recoContent) {
        [void]$md.Add('### Reference - Recommended schedule (copy into apply-updates.yml)')
        [void]$md.Add('')
        [void]$md.Add($recoContent)
        [void]$md.Add('')
    }

    # ---- Cycle calendar - ALWAYS when -SchedulePath supplied --------------
    # v0.8.5 fix for the v0.8.4 $hasIssues-gate regression where the
    # calendar silently disappeared from clean-fleet runs.
    # v0.8.6 enrichment: when PipelineYamlPath (always) and ClusterCsvPath
    # (optional) are available, build two extra columns:
    # * "Ring CRON Start Time (apply-updates pipeline)" - per-day UTC firing
    # times projected from Step.6 cron triggers.
    # * "Tag Start Window Match (>=95%)" - per (ring, date) pair: do
    # >=95% of clusters in the ring have an UpdateStartWindow tag
    # that covers AT LEAST ONE Step.6 cron firing on that date?
    # Both columns are pure render-time data computed here (Azure I/O
    # / file I/O lives in this caller, not the cycle-calendar cmdlet).
    if ($haveSchedule) {
        $calendarMd = $null
        $cronFiringsByDate        = $null
        $windowMatchByRingAndDate = $null
        $clusterRingCountMap      = $null
        try {
            $schedForCalendar = if ($sched) { $sched } else { Get-AzLocalApplyUpdatesScheduleConfig -Path $SchedulePath -ErrorAction Stop }

            # Build cron firings per UTC date across the cycle-calendar's
            # default horizon (CycleWeeks * 7 days, mirroring the cmdlet's
            # default $Days). Best-effort: parse failures degrade gracefully
            # to "no firings" rather than aborting the whole calendar.
            try {
                $cronFireRows = New-Object System.Collections.Generic.List[psobject]
                # NOTE: Read-AzLocalApplyUpdatesYamlCrons uses unary-comma return; direct assignment only (no @() wrap).
                $cronTriggers = Read-AzLocalApplyUpdatesYamlCrons -Path $PipelineYamlPath -ErrorAction Stop
                foreach ($ct in $cronTriggers) {
                    try {
                        $parsed = ConvertFrom-AzLocalCronExpression -Expression $ct.CronExpression -ErrorAction Stop
                        if ($parsed.IsValid -and -not $parsed.IsComplex) {
                            foreach ($ft in @($parsed.FireTimes)) {
                                $cronFireRows.Add([pscustomobject]@{
                                    DayOfWeek = $ft.DayOfWeek
                                    Time      = $ft.TimeOfDay
                                }) | Out-Null
                            }
                        }
                    }
                    catch {
                        Write-Verbose "Cron parse failed for '$($ct.CronExpression)': $($_.Exception.Message)"
                    }
                }

                $horizonDays = [int]$schedForCalendar.CycleWeeks * 7
                if ($horizonDays -lt 7) { $horizonDays = 7 }
                $startDay = [datetime]::SpecifyKind([datetime]::UtcNow.Date, [DateTimeKind]::Utc)

                $cronFiringsByDate = @{}
                for ($i = 0; $i -lt $horizonDays; $i++) {
                    $d   = $startDay.AddDays($i)
                    $dow = $d.DayOfWeek
                    $fires = @(
                        $cronFireRows |
                            Where-Object { $_.DayOfWeek -eq $dow } |
                            ForEach-Object { ($_.Time).ToString('hh\:mm') }
                    ) | Sort-Object -Unique
                    $cronFiringsByDate[$d.ToString('yyyy-MM-dd')] = @($fires)
                }

                # Window-match column needs cluster CSV input.
                if ($haveCsv) {
                    $clusters = @(Import-Csv -LiteralPath $ClusterCsvPath -ErrorAction Stop)

                    # Build a ring -> tagged-cluster-count map so the
                    # cycle calendar can fold the count inline into the
                    # "Eligible rings (cluster count)" column.
                    $clusterRingCountMap = @{}
                    foreach ($c in $clusters) {
                        $cr = ''
                        if ($c.PSObject.Properties.Name -contains 'UpdateRing' -and $null -ne $c.UpdateRing) {
                            $cr = ([string]$c.UpdateRing).Trim()
                        }
                        if ([string]::IsNullOrWhiteSpace($cr)) { continue }
                        if ($clusterRingCountMap.ContainsKey($cr)) {
                            $clusterRingCountMap[$cr] = [int]$clusterRingCountMap[$cr] + 1
                        } else {
                            $clusterRingCountMap[$cr] = 1
                        }
                    }
                    if ($clusterRingCountMap.Count -eq 0) { $clusterRingCountMap = $null }

                    # Pre-parse each cluster's UpdateStartWindow once.
                    $ringsToClusters = New-Object 'System.Collections.Generic.Dictionary[string,System.Collections.Generic.List[psobject]]' ([System.StringComparer]::OrdinalIgnoreCase)
                    foreach ($c in $clusters) {
                        $ring = ''
                        if ($c.PSObject.Properties.Name -contains 'UpdateRing' -and $null -ne $c.UpdateRing) {
                            $ring = ([string]$c.UpdateRing).Trim()
                        }
                        if ([string]::IsNullOrWhiteSpace($ring)) { continue }
                        $winStr = ''
                        if ($c.PSObject.Properties.Name -contains 'UpdateStartWindow' -and $null -ne $c.UpdateStartWindow) {
                            $winStr = ([string]$c.UpdateStartWindow).Trim()
                        }
                        $segments = @()
                        if (-not [string]::IsNullOrWhiteSpace($winStr)) {
                            try { $segments = @(ConvertFrom-AzLocalUpdateWindow -WindowString $winStr -ErrorAction Stop) }
                            catch { $segments = @() }
                        }
                        if (-not $ringsToClusters.ContainsKey($ring)) {
                            $ringsToClusters[$ring] = New-Object System.Collections.Generic.List[psobject]
                        }
                        $ringsToClusters[$ring].Add([pscustomobject]@{ Segments = $segments }) | Out-Null
                    }

                    $windowMatchByRingAndDate = @{}
                    for ($i = 0; $i -lt $horizonDays; $i++) {
                        $d       = $startDay.AddDays($i)
                        $dow     = $d.DayOfWeek
                        $prevDow = $d.AddDays(-1).DayOfWeek
                        $dKey    = $d.ToString('yyyy-MM-dd')
                        $firesToday = @(
                            $cronFireRows |
                                Where-Object { $_.DayOfWeek -eq $dow } |
                                ForEach-Object { $_.Time }
                        )
                        if ($firesToday.Count -eq 0) { continue }
                        foreach ($ring in $ringsToClusters.Keys) {
                            $clist = @($ringsToClusters[$ring])
                            $tot   = $clist.Count
                            $mat   = 0
                            foreach ($cw in $clist) {
                                $hit = $false
                                foreach ($seg in @($cw.Segments)) {
                                    $segDays = @($seg.Days)
                                    if ($seg.Overnight) {
                                        # Two cases for overnight windows:
                                        # (a) opens today before midnight -> fires today >= StartTime,
                                        # (b) opened yesterday and still active -> fires today < EndTime.
                                        foreach ($ft in $firesToday) {
                                            if ($segDays -contains $dow     -and $ft -ge $seg.StartTime) { $hit = $true; break }
                                            if ($segDays -contains $prevDow -and $ft -lt $seg.EndTime)   { $hit = $true; break }
                                        }
                                    }
                                    else {
                                        if (-not ($segDays -contains $dow)) { continue }
                                        foreach ($ft in $firesToday) {
                                            if ($ft -ge $seg.StartTime -and $ft -lt $seg.EndTime) { $hit = $true; break }
                                        }
                                    }
                                    if ($hit) { break }
                                }
                                if ($hit) { $mat++ }
                            }
                            if ($tot -gt 0) {
                                if (-not $windowMatchByRingAndDate.ContainsKey($ring)) { $windowMatchByRingAndDate[$ring] = @{} }
                                $windowMatchByRingAndDate[$ring][$dKey] = @{ Matching = $mat; Total = $tot }
                            }
                        }
                    }
                }
            }
            catch {
                Write-Warning "Failed to build cycle-calendar enrichment columns: $($_.Exception.Message)"
                $cronFiringsByDate        = $null
                $windowMatchByRingAndDate = $null
                $clusterRingCountMap      = $null
            }

            $calArgs = @{
                Schedule              = $schedForCalendar
                AsMarkdown            = $true
                IncludePerRingSummary = $true
                ErrorAction           = 'Stop'
            }
            if ($cronFiringsByDate)        { $calArgs.CronFiringsByDate        = $cronFiringsByDate }
            if ($windowMatchByRingAndDate) { $calArgs.WindowMatchByRingAndDate = $windowMatchByRingAndDate }
            if ($clusterRingCountMap)      { $calArgs.ClusterRingCounts        = $clusterRingCountMap }
            $calendarMd = Get-AzLocalApplyUpdatesScheduleCycleCalendar @calArgs
        }
        catch {
            $calendarMd = $null
            Write-Warning "Failed to build cycle calendar: $($_.Exception.Message)"
        }
        if ($calendarMd) {
            [void]$md.Add($calendarMd)
            [void]$md.Add('')
        }
    }

    # ---- Recommended sideload schedule (Step.6, opt-in v0.8.7) ------------
    # Gated on SIDELOAD_UPDATES (parameter override -> env var). When disabled
    # the section is omitted and the audit output is byte-identical to v0.8.6.
    # The on-prem Step.6 sideload pipeline is re-entrant and driven by a
    # frequent poll cron; its planner uses SIDELOAD_LEAD_DAYS to decide when a
    # cluster is "due". This section reuses the apply-window crons already read
    # from PipelineYamlPath to recommend, per apply firing, the EARLIEST weekly
    # cron at which staging should begin (apply firing shifted back LeadDays).
    #
    # NOTE: the local accumulator is deliberately NOT named $sideloadEnabled -
    # PowerShell variable names are CASE-INSENSITIVE, so $sideloadEnabled would
    # alias the [bool]$SideloadEnabled parameter and clobber the bound value to
    # $false on the first assignment (silent: -SideloadEnabled $true then
    # rendered nothing while only the env-var path worked).
    $emitSideloadSection = $false
    if ($PSBoundParameters.ContainsKey('SideloadEnabled')) {
        $emitSideloadSection = $SideloadEnabled
    }
    elseif ($env:SIDELOAD_UPDATES) {
        $emitSideloadSection = (([string]$env:SIDELOAD_UPDATES).Trim().ToLowerInvariant() -in @('true', '1', 'yes', 'on'))
    }
    $sideloadScheduleIncluded = $false
    if ($emitSideloadSection) {
        $leadDays = $SideloadLeadDays
        if (-not $PSBoundParameters.ContainsKey('SideloadLeadDays') -and $env:SIDELOAD_LEAD_DAYS) {
            $parsedLead = 0
            if ([int]::TryParse((([string]$env:SIDELOAD_LEAD_DAYS).Trim()), [ref]$parsedLead) -and $parsedLead -ge 0 -and $parsedLead -le 365) {
                $leadDays = $parsedLead
            }
        }

        # Distinct apply-window weekly firings (DayOfWeek + TimeOfDay) from the
        # apply pipeline crons. Read-AzLocalApplyUpdatesYamlCrons uses a
        # unary-comma return - direct assignment only (NEVER @() wrap).
        $applyFirings = New-Object 'System.Collections.Generic.List[psobject]'
        try {
            $sideloadCronTriggers = Read-AzLocalApplyUpdatesYamlCrons -Path $PipelineYamlPath -ErrorAction Stop
            foreach ($sct in $sideloadCronTriggers) {
                try {
                    $sparsed = ConvertFrom-AzLocalCronExpression -Expression $sct.CronExpression -ErrorAction Stop
                    if ($sparsed.IsValid -and -not $sparsed.IsComplex) {
                        foreach ($sft in @($sparsed.FireTimes)) {
                            $applyFirings.Add([pscustomobject]@{
                                DayOfWeek = [int]$sft.DayOfWeek
                                TimeOfDay = $sft.TimeOfDay
                            }) | Out-Null
                        }
                    }
                }
                catch {
                    Write-Verbose "Sideload section: cron parse failed for '$($sct.CronExpression)': $($_.Exception.Message)"
                }
            }
        }
        catch {
            Write-Warning "Failed to read apply-updates crons for the sideload schedule recommendation: $($_.Exception.Message)"
        }

        [void]$md.Add('### Recommended sideload schedule (sideload-updates - opt-in)')
        [void]$md.Add('')
        [void]$md.Add("`SIDELOAD_UPDATES` is enabled, so media must be pre-staged on each cluster **$leadDays day(s)** before its apply window opens. The on-prem **Sideload Updates** pipeline is re-entrant: drive it on a frequent poll cron and its planner uses `SIDELOAD_LEAD_DAYS=$leadDays` to decide when each cluster is due.")
        [void]$md.Add('')
        [void]$md.Add('Recommended sideload-updates poll cron (every 30 minutes) - paste into the `schedule:`/`schedules:` block of `sideload-updates.yml`:')
        [void]$md.Add('')
        [void]$md.Add('```')
        [void]$md.Add('*/30 * * * *')
        [void]$md.Add('```')
        [void]$md.Add('')

        # Build a distinct, ordered list of apply firings.
        $distinctFirings = @(
            $applyFirings |
                Sort-Object DayOfWeek, TimeOfDay -Unique
        )
        if ($distinctFirings.Count -gt 0) {
            $dayNames = @('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat')
            [void]$md.Add("Per apply-window firing, the EARLIEST weekly cron at which staging should begin (apply firing shifted back $leadDays day(s)):")
            [void]$md.Add('')
            [void]$md.Add('| Apply firing (UTC) | Lead (days) | Begin-staging (UTC) | Sideload kickoff cron |')
            [void]$md.Add('|--------------------|------------:|---------------------|-----------------------|')
            foreach ($f in $distinctFirings) {
                $applyDow = [int]$f.DayOfWeek
                $hhmm = ([timespan]$f.TimeOfDay).ToString('hh\:mm')
                $minute = ([timespan]$f.TimeOfDay).Minutes
                $hour = ([timespan]$f.TimeOfDay).Hours
                $shiftedDow = ((($applyDow - ($leadDays % 7)) % 7) + 7) % 7
                $kickoffCron = ('{0} {1} * * {2}' -f $minute, $hour, $shiftedDow)
                [void]$md.Add(('| {0} {1} | {2} | {3} {1} | `{4}` |' -f $dayNames[$applyDow], $hhmm, $leadDays, $dayNames[$shiftedDow], $kickoffCron))
            }
            [void]$md.Add('')
            [void]$md.Add('> The kickoff cron is the EARLIEST point staging could start for that window. The re-entrant sideload-updates pipeline (frequent poll) advances the multi-hour copy from that point; you do NOT need a separate cron per ring.')
            [void]$md.Add('')
        }
        else {
            [void]$md.Add('> No simple weekly apply-window firings were found in the pipeline YAML, so a per-window kickoff cron could not be derived. Drive sideload-updates on the recommended poll cron above; its planner will stage each cluster `SIDELOAD_LEAD_DAYS` days before its apply window.')
            [void]$md.Add('')
        }
        $sideloadScheduleIncluded = $true
    }

    # ---- Recommended in-flight monitor schedule (Update: 4) ----------------
    # v0.8.89: recommend a poll cron for the 'Update: 4 - Monitor In-Flight
    # Updates' (monitor-updates.yml) pipeline, scaled to how often updates are
    # applied AND bounded to when updates can actually be running. Once an apply
    # window fires, an update run can stay in-flight for hours to days, so the
    # recommended cron polls across:
    # * DAYS - the weekdays an update can be eligible. When -SchedulePath is
    # supplied these come from apply-updates-schedule.yml ring
    # eligibility (layer 1, via the cycle calendar); otherwise from
    # the apply-updates.yml cron weekday(s) (layer 2). The base set
    # is expanded by -MonitorTrailingDays for multi-day runs.
    # * HOURS - bounded by the UpdateStartWindow tag span (layer 3) earliest
    # start -> latest end + an -MonitorInFlightHours buffer for runs
    # still finishing. Falls back to all-hours ('*') when a run can
    # cross midnight (an overnight window, -MonitorTrailingDays > 0,
    # or the buffer pushes coverage past 24h) so no in-flight time
    # is left unpolled.
    # * CADENCE - -MonitorPollIntervalMinutes (15/20/30/60/120/180/240 min)
    # sets the minute step (< 60 min) or the hour step (>= 60 min).
    # Always emitted (independent of the sideload opt-in).

    # Cadence -> minute field + (for >= 60 min) hour step.
    $monitorIntervalMinutes = $MonitorPollIntervalMinutes
    $monitorHourStep = 1
    if ($monitorIntervalMinutes -lt 60) {
        $monitorMinuteField = ('*/{0}' -f $monitorIntervalMinutes)
    }
    else {
        $monitorMinuteField = '0'
        $monitorHourStep = [int]($monitorIntervalMinutes / 60)
    }

    # HOURS: derive the [earliest start, latest end] span across every distinct
    # UpdateStartWindow tag value in the audit rows. An overnight window
    # (end <= start) means a run can cross midnight, so hours cannot be bounded.
    $monitorWindowStrings = @(
        $audit |
            Where-Object { $_.PSObject.Properties['UpdateStartWindow'] -and -not [string]::IsNullOrWhiteSpace([string]$_.UpdateStartWindow) } |
            ForEach-Object { ([string]$_.UpdateStartWindow).Trim() } |
            Sort-Object -Unique
    )
    $monitorWinMinStartHour = $null
    $monitorWinMaxEndHour   = $null
    $monitorWinOvernight    = $false
    foreach ($ws in $monitorWindowStrings) {
        $segs = @()
        try { $segs = @(ConvertFrom-AzLocalUpdateWindow -WindowString $ws -ErrorAction Stop) }
        catch { continue }
        foreach ($seg in $segs) {
            if ($seg.Overnight) { $monitorWinOvernight = $true; continue }
            $sh = [int][math]::Floor($seg.StartTime.TotalHours)
            $eh = [int][math]::Ceiling($seg.EndTime.TotalHours)
            if ($null -eq $monitorWinMinStartHour -or $sh -lt $monitorWinMinStartHour) { $monitorWinMinStartHour = $sh }
            if ($null -eq $monitorWinMaxEndHour   -or $eh -gt $monitorWinMaxEndHour)   { $monitorWinMaxEndHour   = $eh }
        }
    }

    # Decide whether the hour field can be bounded to a single UTC day.
    $monitorHoursBounded = $false
    $monitorHourLow      = 0
    $monitorHourHigh     = 23
    $monitorHoursReason  = ''
    if ($monitorWinOvernight) {
        $monitorHoursReason = 'an UpdateStartWindow crosses midnight'
    }
    elseif ($MonitorTrailingDays -gt 0) {
        $monitorHoursReason = ('runs may continue for {0} trailing day(s)' -f $MonitorTrailingDays)
    }
    elseif ($null -eq $monitorWinMinStartHour -or $null -eq $monitorWinMaxEndHour) {
        $monitorHoursReason = 'no UpdateStartWindow tags were found to bound the hours'
    }
    else {
        $candidateHigh = $monitorWinMaxEndHour + $MonitorInFlightHours
        if ($candidateHigh -gt 23) {
            $monitorHoursReason = ('the in-flight buffer ({0}h past window end) pushes coverage past midnight' -f $MonitorInFlightHours)
        }
        else {
            $monitorHoursBounded = $true
            $monitorHourLow      = $monitorWinMinStartHour
            $monitorHourHigh     = $candidateHigh
        }
    }

    # Build the hour field from the bound + cadence step.
    if ($monitorHoursBounded) {
        if ($monitorHourStep -gt 1) {
            $monitorHourField = ('{0}-{1}/{2}' -f $monitorHourLow, $monitorHourHigh, $monitorHourStep)
        }
        elseif ($monitorHourLow -eq $monitorHourHigh) {
            $monitorHourField = ('{0}' -f $monitorHourLow)
        }
        else {
            $monitorHourField = ('{0}-{1}' -f $monitorHourLow, $monitorHourHigh)
        }
    }
    else {
        $monitorHourField = if ($monitorHourStep -gt 1) { ('*/{0}' -f $monitorHourStep) } else { '*' }
    }

    # DAYS: prefer apply-updates-schedule.yml ring eligibility (layer 1) - the
    # distinct UTC weekdays that carry at least one apply-cron firing across the
    # cycle horizon. This reuses the $cronFiringsByDate map already built for the
    # cycle-calendar render above, so the schedule calendar cmdlet is NOT invoked
    # a second time (a second call would clobber the calendar render's args and
    # break the cycle-calendar enrichment). Falls back to the apply-updates.yml
    # cron weekday(s) when no schedule firings are available.
    $monitorScheduleDows = @()
    $monitorDaySource    = 'apply-updates.yml cron weekday(s)'
    if ($haveSchedule -and $cronFiringsByDate -and $cronFiringsByDate.Count -gt 0) {
        $monitorEligibleDows = New-Object 'System.Collections.Generic.HashSet[int]'
        foreach ($dateKey in $cronFiringsByDate.Keys) {
            $fires = @($cronFiringsByDate[$dateKey])
            if ($fires.Count -eq 0) { continue }
            $parsedDate = [datetime]::MinValue
            if ([datetime]::TryParseExact([string]$dateKey, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$parsedDate)) {
                [void]$monitorEligibleDows.Add([int]$parsedDate.DayOfWeek)
            }
        }
        if ($monitorEligibleDows.Count -gt 0) {
            # NB: wrap the WHOLE pipeline in @(); '@($x) | Sort-Object' undoes the
            # inner @() for a single element, leaving a scalar with no .Count
            # (PropertyNotFoundException under Set-StrictMode -Version Latest at the
            # $monitorScheduleDows.Count check below).
            $monitorScheduleDows = @(@($monitorEligibleDows) | Sort-Object)
            $monitorDaySource    = 'apply-updates-schedule.yml ring eligibility'
        }
    }

    # Distinct apply-window weekly firings (DayOfWeek) from the apply pipeline
    # crons - the day source when schedule eligibility is unavailable.
    # Read-AzLocalApplyUpdatesYamlCrons uses a unary-comma return - direct
    # assignment only (NEVER @() wrap).
    $monitorApplyFirings = New-Object 'System.Collections.Generic.List[psobject]'
    try {
        $monitorCronTriggers = Read-AzLocalApplyUpdatesYamlCrons -Path $PipelineYamlPath -ErrorAction Stop
        foreach ($mct in $monitorCronTriggers) {
            try {
                $mparsed = ConvertFrom-AzLocalCronExpression -Expression $mct.CronExpression -ErrorAction Stop
                if ($mparsed.IsValid -and -not $mparsed.IsComplex) {
                    foreach ($mft in @($mparsed.FireTimes)) {
                        $monitorApplyFirings.Add([pscustomobject]@{
                            DayOfWeek = [int]$mft.DayOfWeek
                        }) | Out-Null
                    }
                }
            }
            catch {
                Write-Verbose "Monitor section: cron parse failed for '$($mct.CronExpression)': $($_.Exception.Message)"
            }
        }
    }
    catch {
        Write-Warning "Failed to read apply-updates crons for the monitor schedule recommendation: $($_.Exception.Message)"
    }

    $monitorDayNames = @('Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat')
    $monitorApplyDows = @($monitorApplyFirings | ForEach-Object { [int]$_.DayOfWeek } | Sort-Object -Unique)

    # Base weekdays: schedule eligibility wins, else apply-cron weekdays.
    if ($monitorScheduleDows.Count -gt 0) {
        $monitorBaseDows = $monitorScheduleDows
    }
    else {
        $monitorBaseDows = $monitorApplyDows
    }

    $monitorCadenceLabel =
        if ($monitorIntervalMinutes -lt 60) { ('every {0} min' -f $monitorIntervalMinutes) }
        elseif ($monitorIntervalMinutes -eq 60) { 'hourly' }
        else { ('every {0}h' -f $monitorHourStep) }

    [void]$md.Add('### Recommended in-flight monitor schedule (Update: 4)')
    [void]$md.Add('')

    if ($monitorBaseDows.Count -gt 0) {
        # Expand each base weekday across the trailing coverage days.
        $monitorCoverageSet = New-Object 'System.Collections.Generic.HashSet[int]'
        foreach ($baseDow in $monitorBaseDows) {
            for ($k = 0; $k -le $MonitorTrailingDays; $k++) {
                [void]$monitorCoverageSet.Add((($baseDow + $k) % 7))
            }
        }
        $monitorCoverageDows  = @($monitorCoverageSet) | Sort-Object
        $monitorDayField      = if ($monitorCoverageDows.Count -ge 7) { '*' } else { ($monitorCoverageDows -join ',') }
        $monitorCron          = ('{0} {1} * * {2}' -f $monitorMinuteField, $monitorHourField, $monitorDayField)
        $monitorCoverageLabel = if ($monitorCoverageDows.Count -ge 7) { 'every day' } else { (($monitorCoverageDows | ForEach-Object { $monitorDayNames[$_] }) -join ', ') }
        $monitorBaseLabel     = ($monitorBaseDows | ForEach-Object { $monitorDayNames[$_] }) -join ', '
        $monitorHoursLabel    = if ($monitorHoursBounded) { ('{0:00}:00-{1:00}:00 UTC' -f $monitorHourLow, $monitorHourHigh) } else { 'all hours (24h)' }

        [void]$md.Add(('The **Update: 4 - Monitor In-Flight Updates** (`monitor-updates.yml`) pipeline should poll while an update run is in flight. Cadence: **{0}**. Eligible weekday(s) (from {1}): **{2}**; with {3} trailing day(s) the monitor runs on **{4}**. Hour coverage: **{5}**.' -f $monitorCadenceLabel, $monitorDaySource, $monitorBaseLabel, $MonitorTrailingDays, $monitorCoverageLabel, $monitorHoursLabel))
        [void]$md.Add('')
        if (-not $monitorHoursBounded -and $monitorHoursReason) {
            [void]$md.Add(('> Hours are left at `*` (24h) because {0}. Set `-MonitorTrailingDays 0`, tighten the `UpdateStartWindow` tags, or lower `-MonitorInFlightHours` to bound the hour field to a single UTC day.' -f $monitorHoursReason))
            [void]$md.Add('')
        }
        [void]$md.Add('Recommended Update: 4 monitor cron - paste into the `schedule:` block of `monitor-updates.yml`:')
        [void]$md.Add('')
        [void]$md.Add('```')
        [void]$md.Add($monitorCron)
        [void]$md.Add('```')
        [void]$md.Add('')
        [void]$md.Add('> GitHub Actions supports sub-hour cron (e.g. `*/30`). Azure DevOps YAML `schedules:` is hourly-only - for sub-hour cadence on ADO, trigger `monitor-updates.yml` from an external scheduler via REST. Widen the day list above if your runs routinely exceed the trailing-day coverage.')
        [void]$md.Add('')
    }
    else {
        $monitorFallbackCron = ('{0} {1} * * *' -f $monitorMinuteField, $monitorHourField)
        [void]$md.Add(('No simple weekly apply-window firings were found in the pipeline YAML, so a focused monitor window could not be derived. Run the monitor at the configured cadence (**{0}**) across all days:' -f $monitorCadenceLabel))
        [void]$md.Add('')
        [void]$md.Add('```')
        [void]$md.Add($monitorFallbackCron)
        [void]$md.Add('```')
        [void]$md.Add('')
    }

    [void]$md.Add('### Reports Available')
    [void]$md.Add("- ``$AuditCsvFileName`` - one row per (UpdateRing, UpdateStartWindow) pair")
    [void]$md.Add("- ``$MatrixCsvFileName`` - full inventory + required cron per row")
    [void]$md.Add("- ``$RecommendMdFileName`` - ready-to-paste cron block")
    [void]$md.Add("- ``$JUnitXmlFileName`` - JUnit XML for CI/CD visualisation")
    [void]$md.Add('')
    [void]$md.Add("*Generated at $((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')) UTC*")

    if ($InstalledModuleVersion) {
        [void]$md.Add('')
        [void]$md.Add(('_Generated by AzLocal.UpdateManagement v{0}._' -f $InstalledModuleVersion))
    }

    Add-AzLocalPipelineStepSummary -Markdown ($md -join [Environment]::NewLine) -SummaryFileName $SummaryFileName | Out-Null

    if ($PassThru) {
        return [pscustomobject]@{
            TotalRows       = [int]$audit.Count
            Covered         = [int]$covered.Count
            Uncovered       = [int]$uncovered.Count
            Partial         = [int]$partial.Count
            Malformed       = [int]$malformed.Count
            Unparseable     = [int]$unparseable.Count
            NoWindowTag     = [int]$noWindowTag.Count
            RingMissing     = [int]$ringMissing.Count
            RingOrphan      = [int]$ringOrphan.Count
            RingMixed       = [int]$ringMixed.Count
            HaveSchedule    = [bool]$haveSchedule
            SchedulePath    = $(if ($haveSchedule) { $SchedulePath } else { '' })
            AuditRows       = $audit
            AuditCsvPath    = $auditCsv
            MatrixCsvPath   = $matrixCsv
            RecommendMdPath = $recoMd
            JUnitXmlPath    = $xmlPath
            SummaryPath     = (Join-Path -Path $OutputDirectory -ChildPath $SummaryFileName)
        }
    }
}