Modules/Diff/Diff.psm1

# Diff.psm1
#
# Pure comparison logic for Invoke-SCuBADiff. This module compares two
# ScubaResults.json files and produces a machine-readable DiffResults object and
# an HTML report. It has NO dependency on Connection, Providers, Permissions, or
# anything touching a live tenant session: everything here runs fully offline.
#
# See docs/execution/diff.md for usage.

# Product abbreviation -> display title used for the HTML report headings and
# summary table. The DiffResults.json keys remain the canonical abbreviations;
# only the report display uses these friendlier names. Products not listed here
# are shown using their raw abbreviation.
$script:ProductDisplayNameMap = @{
    'AAD'            = 'Microsoft Entra ID / Azure Active Directory'
    'Defender'       = 'Microsoft 365 Defender'
    'EXO'            = 'Exchange Online'
    'PowerPlatform'  = 'Microsoft Power Platform'
    'PowerBI'        = 'Microsoft Power BI'
    'SharePoint'     = 'SharePoint Online'
    'Teams'          = 'Microsoft Teams'
    'SecuritySuite'  = 'Security Suite'
}

# Fixed display order for products in the HTML report (summary table and section
# tables). Products not listed here are appended, ordered alphabetically.
$script:ProductOrder = @(
    'AAD'
    'Defender'
    'EXO'
    'PowerBI'
    'PowerPlatform'
    'SharePoint'
    'SecuritySuite'
    'Teams'
)

# Classification -> row color class used by the HTML report. Keep in sync with the
# Diff Key terminology in the usage doc. Key order here is incidental:
# $script:ClassificationOrder below is what drives display order.
$script:ClassificationColorMap = [ordered]@{
    'Errored'             = 'red'
    'NewFail'             = 'red'
    'NewPass'             = 'green'
    'NewWarning'          = 'yellow'
    'NewOmission'         = 'yellow'
    'NewIncorrectResult'  = 'grey'
    'PolicyVersionUpdate' = 'yellow'
    'Other'               = 'yellow'
    'NewAutomatedCheck'   = 'neutral'
    'NewManualCheck'      = 'neutral'
    'NewPolicy'           = 'neutral'
    'RemovedPolicy'       = 'neutral'
    'Migrated'            = 'neutral'
    'Unchanged'           = 'unchanged'
}

# Display order for the summary table's classification columns and their filter
# checkboxes, shared with ScubaGoggles so the two tools' reports read the same
# way. The order is by severity, in tiers:
#
# 1. Broken now: Errored, NewFail
# 2. Degraded: NewWarning
# 3. Needs manual review: NewIncorrectResult, PolicyVersionUpdate,
# NewOmission, Other
# 4. Coverage shape: NewAutomatedCheck, NewManualCheck
# 5. Good news / admin: NewPass, NewPolicy, RemovedPolicy, Migrated
# 6. Hidden by default: Unchanged
#
# The tiers track the row colors a record renders with, since row color keys off
# Result (After), so column order and row color tell one severity story rather
# than two unrelated ones. 'Migrated' is the one entry ScubaGoggles has no
# counterpart for; it sits with the administrative classifications because a
# Migrated stub reports a relocation, not a result change.
$script:ClassificationOrder = @(
    'Errored'
    'NewFail'
    'NewWarning'
    'NewIncorrectResult'
    'PolicyVersionUpdate'
    'NewOmission'
    'Other'
    'NewAutomatedCheck'
    'NewManualCheck'
    'NewPass'
    'NewPolicy'
    'RemovedPolicy'
    'Migrated'
    'Unchanged'
)

# Classification -> human-friendly label for HTML display. Classifications not listed here are
# displayed using their raw (camelCase) token.
$script:ClassificationLabelMap = @{
    'NewFail'             = 'New Fail'
    'NewPass'             = 'New Pass'
    'NewWarning'          = 'New Warning'
    'NewOmission'         = 'New Omission'
    'NewAutomatedCheck'   = 'New Automated Check'
    'NewManualCheck'      = 'New Manual Check'
    'NewIncorrectResult'  = 'New Incorrect Result (false positive)'
    'NewPolicy'           = 'New Policy'
    'RemovedPolicy'       = 'Removed Policy'
    'PolicyVersionUpdate' = 'Policy Version Update'
}

# Legacy -> Security Suite policy migration, keyed by source product and then by
# *base* control ID (no version suffix) on both sides. Derived from
# mappings\scuba-baseline-policy-migrations.csv, restricted to the rows whose New
# ID is a single Security Suite policy. Across the whole table the mapping is
# one-to-one: no two source policies claim the same Security Suite target.
#
# Deliberately excluded (they fall through to the normal presence rules, i.e.
# RemovedPolicy):
# - MS.DEFENDER.1.1v1 - MS.DEFENDER.1.5v1, each split across several Security
# Suite policies (MS.SECURITYSUITE.1.1v1 - MS.SECURITYSUITE.1.4v1), so no
# single target exists to align to.
# - MS.DEFENDER.4.5v1, retired outright (New ID "None").
# - The whole MS.EXO.14 (anti-spam) and MS.EXO.15 (Safe Links) groups. The spam
# rows are ambiguous at the source -- MS.EXO.14.1v2 and MS.EXO.14.2v1 both
# claim MS.SECURITYSUITE.6.1v1 -- and the Safe Links rows are ambiguous at the
# target, since MS.TEAMS.8.1v1/8.2v1 claim MS.SECURITYSUITE.7.1v1/7.3v1
# alongside MS.EXO.15.1v1/15.3v1. Rather than pick a winner per row, the two
# groups are excluded together so the reworked anti-spam and Safe Links
# policies are read on their own terms.
# - Every remaining EXO row and every Teams row. Most collapse many-to-one onto
# a target a Defender row already claims (MS.EXO.16.1v1 and MS.DEFENDER.5.1v1
# both map to MS.SECURITYSUITE.4.1v1, and MS.EXO.11.1v1 maps to
# MS.SECURITYSUITE.2.1v1, which MS.DEFENDER.2.1v1 owns); the rest are either
# retired outright (New ID "None", e.g. MS.EXO.11.3v1) or manual checks that
# only ever produced N/A, so an aligned before/after comparison would carry no
# signal.
$script:PolicyMigrationMap = [ordered]@{
    'Defender' = [ordered]@{
        'MS.DEFENDER.2.1' = 'MS.SECURITYSUITE.2.1'
        'MS.DEFENDER.2.2' = 'MS.SECURITYSUITE.2.2'
        'MS.DEFENDER.2.3' = 'MS.SECURITYSUITE.2.3'
        'MS.DEFENDER.3.1' = 'MS.SECURITYSUITE.1.4'
        'MS.DEFENDER.4.1' = 'MS.SECURITYSUITE.3.1'
        'MS.DEFENDER.4.2' = 'MS.SECURITYSUITE.3.2'
        'MS.DEFENDER.4.3' = 'MS.SECURITYSUITE.3.3'
        'MS.DEFENDER.4.4' = 'MS.SECURITYSUITE.3.4'
        'MS.DEFENDER.4.6' = 'MS.SECURITYSUITE.3.5'
        'MS.DEFENDER.5.1' = 'MS.SECURITYSUITE.4.1'
        'MS.DEFENDER.5.2' = 'MS.SECURITYSUITE.4.2'
        'MS.DEFENDER.6.1' = 'MS.SECURITYSUITE.5.1'
        'MS.DEFENDER.6.3' = 'MS.SECURITYSUITE.5.2'
    }
    'EXO'      = [ordered]@{
        'MS.EXO.11.2' = 'MS.SECURITYSUITE.2.4'
        'MS.EXO.12.1' = 'MS.SECURITYSUITE.8.1'
        'MS.EXO.12.2' = 'MS.SECURITYSUITE.8.2'
    }
}

# The product every entry in $script:PolicyMigrationMap targets. A migrated record
# is filed under this product, following the after side like the rest of the
# report.
$script:PolicyMigrationTargetProduct = 'SecuritySuite'

function Get-ScubaBaseControlId {
    <#
    .Description
    Strips the trailing per-policy version suffix (v<N> or v<N>.<M>...) from a
    control ID, returning the base ID used for matching between files.
    E.g. "MS.AAD.1.1v1" -> "MS.AAD.1.1"; "MS.AAD.1.1v1.2" -> "MS.AAD.1.1".
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $ControlId
    )
    return ($ControlId -replace '[vV]\d+(?:\.\d+)*$', '')
}

function Get-ScubaControlVersion {
    <#
    .Description
    Returns the trailing version suffix (e.g. "v1", "v12", "v1.2") of a control
    ID, or $null when the ID carries no version suffix.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]
        $ControlId
    )
    if ($ControlId -match '([vV]\d+(?:\.\d+)*)$') {
        return $Matches[1]
    }
    return $null
}

function Get-ScubaControlSortKey {
    <#
    .Description
    Builds a sort key that orders control IDs numerically rather than
    lexicographically, so that e.g. MS.EXO.9.5 precedes MS.EXO.10.1 instead of
    following it. Every run of digits is zero-padded to a fixed width, leaving a
    string whose ordinal comparison matches the intended numeric order.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $ControlId
    )
    if ([string]::IsNullOrEmpty($ControlId)) {
        return ''
    }
    return [regex]::Replace($ControlId, '\d+', { param($m) $m.Value.PadLeft(10, '0') })
}

function Get-ScubaOrderedControlIds {
    <#
    .Description
    Orders control IDs by their numeric segments (see Get-ScubaControlSortKey),
    keeping each product's policy groups in baseline order (1, 2, ... 9, 10, 11)
    in both the JSON output and the HTML report.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $ControlIds
    )
    return @($ControlIds | Sort-Object -Property @{ Expression = { Get-ScubaControlSortKey $_ } })
}

function ConvertTo-ScubaPlainText {
    <#
    .Description
    Strips HTML from a ScubaResults string so it is safe to store in the diff
    record and to compare for equality. In particular the Requirement field
    embeds a <div class='policy-indicators'>...</div> block plus anchor tags;
    those are removed and remaining markup / entities are decoded.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Text
    )
    if ([string]::IsNullOrEmpty($Text)) {
        return ''
    }
    $clean = $Text
    # Drop the policy-indicators block (and anything after it, which is only ever
    # the closing markup for that block in observed output).
    $clean = $clean -replace "(?s)<div class=['`"]policy-indicators['`"].*$", ''
    # Normalize common structural tags to spaces before stripping the rest.
    $clean = $clean -replace '<br\s*/?>', ' '
    # Strip any remaining HTML tags.
    $clean = $clean -replace '<[^>]+>', ''
    # Decode HTML entities (e.g. &amp; -> &).
    $clean = [System.Net.WebUtility]::HtmlDecode($clean)
    # Collapse runs of whitespace introduced by tag removal.
    $clean = $clean -replace '\s+', ' '
    return $clean.Trim()
}

function ConvertTo-ScubaHtmlEncoded {
    <#
    .Description
    HTML-encodes a string for safe inclusion in the diff report. All
    user-controllable strings must pass through this before being written to HTML.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Text
    )
    if ([string]::IsNullOrEmpty($Text)) {
        return ''
    }
    return [System.Net.WebUtility]::HtmlEncode($Text)
}

function ConvertTo-ScubaCsvSafeText {
    <#
    .Description
    Neutralizes spreadsheet formula injection for a value written to the diff CSV.
    Excel and other spreadsheet apps evaluate a cell whose text begins with =, +,
    - or @ (a leading tab or carriage return is skipped before that test), so a
    free-text field such as the analyst-supplied annotation Comment can run as a
    formula for whoever opens the file -- typically a reviewer, not the person who
    ran the diff. Prefixing a single quote forces the value to be read as text.
    All strings written to CSV must pass through this, the way HTML strings pass
    through ConvertTo-ScubaHtmlEncoded. Non-strings are returned untouched so the
    boolean columns keep their type.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [object]
        $Value
    )
    if ($Value -isnot [string] -or [string]::IsNullOrEmpty($Value)) {
        return $Value
    }
    if ($Value -match '^[=+\-@\t\r]') {
        return "'" + $Value
    }
    return $Value
}

function Get-ScubaResultCategory {
    <#
    .Description
    Normalizes an open-set Result string into a comparison category. Anything
    unrecognized becomes 'Other' (the literal is preserved elsewhere), so unknown
    Result values never crash the diff.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Result
    )
    if ([string]::IsNullOrEmpty($Result)) {
        return 'Other'
    }
    switch ($Result.Trim().ToLowerInvariant()) {
        'pass'              { return 'Pass' }
        'fail'              { return 'Fail' }
        'warning'           { return 'Warning' }
        'n/a'               { return 'NA' }
        'omitted'           { return 'Omitted' }
        'error'             { return 'Error' }
        'incorrect result'  { return 'Incorrect' }
        default             { return 'Other' }
    }
}

function Get-ScubaDiffClassification {
    <#
    .Description
    Classifies a single base control ID into exactly one diff classification,
    honoring the precedence: Errored > PolicyVersionUpdate > Unchanged >
    NewIncorrectResult > specific diffs > NewOmission > Other.
    NewPolicy/RemovedPolicy are determined by presence and take precedence over
    everything else. RemovedPolicy (base ID present in the before file but absent
    from the after file) aligns with the baselines' removedpolicies.md tracking.
 
    Errored keys off the *after* state only: a control that is errored in the
    latest run is what an operator needs to see. A control that recovered *from* an
    error (Error -> Pass/Fail/Warning) is classified by the state it lands in, like
    any other prior non-evaluated state, so a resolved error is not reported as red.
 
    Classifications are named for the state the control lands in, so a diff into
    Pass/Fail/Warning from any prior state -- including Omitted, a prior Error, or a
    cleared "Incorrect result" marking -- reports as NewPass/NewFail/NewWarning.
    Only the Omitted diffs that have no such landing state (e.g. Pass ->
    Omitted, N/A <-> Omitted) report as NewOmission.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string] $BeforeResult,
        [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string] $AfterResult,
        [Parameter(Mandatory = $true)] [bool] $BeforePresent,
        [Parameter(Mandatory = $true)] [bool] $AfterPresent,
        [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string] $BeforeVersion,
        [Parameter(Mandatory = $true)] [AllowNull()] [AllowEmptyString()] [string] $AfterVersion,
        [Parameter(Mandatory = $false)] [switch] $IsMigrated
    )

    if (-not $BeforePresent) { return 'NewPolicy' }
    if (-not $AfterPresent) { return 'RemovedPolicy' }

    $bCat = Get-ScubaResultCategory $BeforeResult
    $aCat = Get-ScubaResultCategory $AfterResult

    # Errored has highest precedence among matched controls, but keys off the
    # after state only: a control that is errored *now* must surface regardless of
    # a version bump. A control that recovered from a prior error falls through to
    # the landing-state diffs below (Error -> Pass = NewPass, etc.).
    if ($aCat -eq 'Error') { return 'Errored' }

    # A changed version suffix means the policy's meaning changed; the Result
    # comparison is informational, so this outranks the specific diffs. Skipped for
    # a migrated pair: the two IDs come from different policy families (e.g.
    # MS.DEFENDER.4.1v2 -> MS.SECURITYSUITE.3.1v1), so their version suffixes are
    # not comparable and the pair is classified by its result like any other.
    if (-not $IsMigrated -and $BeforeVersion -ne $AfterVersion) { return 'PolicyVersionUpdate' }

    # Identical Result and version. Checked before the diffs below so a
    # stable state -- including a stable "Incorrect result" marking or a control
    # omitted in both runs -- is never mistaken for a diff.
    if ($bCat -eq $aCat -and $BeforeResult -eq $AfterResult) { return 'Unchanged' }

    # A control the user marked incorrect (a false positive) carries the literal
    # Result "Incorrect result" (category 'Incorrect'). Newly acquiring that
    # marking is its own classification; losing it is classified by the result it reveals
    # via the diffs below.
    if ($aCat -eq 'Incorrect') { return 'NewIncorrectResult' }

    switch ("$bCat->$aCat") {
        'Pass->Fail'         { return 'NewFail' }
        'Warning->Fail'      { return 'NewFail' }
        'Omitted->Fail'      { return 'NewFail' }
        'Incorrect->Fail'    { return 'NewFail' }
        'Error->Fail'        { return 'NewFail' }
        'Fail->Pass'         { return 'NewPass' }
        'Warning->Pass'      { return 'NewPass' }
        'Omitted->Pass'      { return 'NewPass' }
        'Incorrect->Pass'    { return 'NewPass' }
        'Error->Pass'        { return 'NewPass' }
        'Pass->Warning'      { return 'NewWarning' }
        'Fail->Warning'      { return 'NewWarning' }
        'Omitted->Warning'   { return 'NewWarning' }
        'Incorrect->Warning' { return 'NewWarning' }
        'Error->Warning'     { return 'NewWarning' }
        'NA->Pass'           { return 'NewAutomatedCheck' }
        'NA->Fail'           { return 'NewAutomatedCheck' }
        'NA->Warning'        { return 'NewAutomatedCheck' }
        'Pass->NA'           { return 'NewManualCheck' }
        'Fail->NA'           { return 'NewManualCheck' }
        'Warning->NA'        { return 'NewManualCheck' }
        'Incorrect->NA'      { return 'NewManualCheck' }
        'Error->NA'          { return 'NewManualCheck' }
    }

    # Any remaining non-identical diff involving Omitted (e.g. Pass ->
    # Omitted, N/A <-> Omitted, Incorrect result -> Omitted).
    if ($bCat -eq 'Omitted' -or $aCat -eq 'Omitted') { return 'NewOmission' }

    return 'Other'
}

function Get-ScubaClassificationColor {
    <#
    .Description
    Maps a diff classification to its HTML row color class (red/green/yellow/
    neutral/unchanged). Unknown classifications fall back to yellow.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $Classification
    )
    if ($script:ClassificationColorMap.Contains($Classification)) {
        return $script:ClassificationColorMap[$Classification]
    }
    return 'yellow'
}

function Get-ScubaClassificationLabel {
    <#
    .Description
    Maps a diff classification to its human-friendly display label for the HTML
    report. Classifications without an explicit label are shown using their raw token.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $Classification
    )
    if ($script:ClassificationLabelMap.ContainsKey($Classification)) {
        return $script:ClassificationLabelMap[$Classification]
    }
    return $Classification
}

function Get-ScubaProductDisplayName {
    <#
    .Description
    Maps a product abbreviation to its display title for the HTML report. Products
    without an explicit mapping are shown using their raw abbreviation.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]
        $ProductKey
    )
    if ($script:ProductDisplayNameMap.ContainsKey($ProductKey)) {
        return $script:ProductDisplayNameMap[$ProductKey]
    }
    return $ProductKey
}

function Get-ScubaOrderedProducts {
    <#
    .Description
    Orders a set of product abbreviations by the fixed report display order
    ($script:ProductOrder). Products not in that list are appended alphabetically.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $Products
    )
    return @($Products | Sort-Object -Property `
        @{ Expression = {
                $i = [Array]::IndexOf($script:ProductOrder, $_)
                if ($i -ge 0) { $i } else { [int]::MaxValue }
            } }, `
        @{ Expression = { $_ } })
}

function Get-ScubaRowColorClass {
    <#
    .Description
    Determines the HTML row color for a diff record. Removed and migrated-away
    policies are greyed out to match the manual-check styling (neither has an
    after side to color by); every other row is colored by its Result (After)
    value: Fail/Error -> red, Warning -> yellow, Pass -> green, and manual (N/A) /
    Omitted / anything else -> grey.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]
        $Record
    )
    if ($Record.Classification -eq 'RemovedPolicy' -or $Record.Classification -eq 'Migrated') { return 'grey' }
    switch (Get-ScubaResultCategory $Record.ResultAfter) {
        'Fail'    { return 'red' }
        'Error'   { return 'red' }
        'Warning' { return 'yellow' }
        'Pass'    { return 'green' }
        default   { return 'grey' }
    }
}

function Get-ScubaResultTextClass {
    <#
    .Description
    Maps a Result value to the CSS class that colors the text of a
    Result (Before) / Result (After) cell: Pass green, Fail red, Warning amber.
    The row background only ever shows the after state, so without this a reader
    scanning the table cannot see which way a policy moved. Everything else
    (N/A, Omitted, Error, Incorrect result, unknown) keeps the default text color
    so the three states being compared are the ones that stand out.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Result
    )
    switch (Get-ScubaResultCategory $Result) {
        'Pass'    { return 'result-pass' }
        'Fail'    { return 'result-fail' }
        'Warning' { return 'result-warning' }
        default   { return '' }
    }
}

function Get-ScubaControlMap {
    <#
    .Description
    Flattens a product's Results (a list of groups, each with Controls) into an
    ordered map keyed by base control ID. Each value carries the fields the diff
    needs. When multiple versions of the same base ID appear in one file, the
    last one wins (an unusual, diff-only edge case).
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [object]
        $ProductResults
    )
    $map = [ordered]@{}
    if ($null -eq $ProductResults) {
        return $map
    }
    foreach ($group in $ProductResults) {
        foreach ($control in $group.Controls) {
            $fullId = $control.'Control ID'
            if ([string]::IsNullOrEmpty($fullId)) { continue }
            $base = Get-ScubaBaseControlId $fullId
            $map[$base] = [pscustomobject]@{
                FullId         = $fullId
                BaseId         = $base
                Version        = Get-ScubaControlVersion $fullId
                Result         = $control.Result
                # OriginalResult is the tool-computed result before annotation /
                # omission processing; for a control marked incorrect (Result =
                # "Incorrect result") it holds the underlying evaluated result.
                OriginalResult = $control.OriginalResult
                Criticality    = $control.Criticality
                Requirement    = $control.Requirement
                Details        = $control.Details
                GroupName      = $group.GroupName
                GroupNumber    = $group.GroupNumber
            }
        }
    }
    return $map
}

function Invoke-ScubaPolicyMigrationAlignment {
    <#
    .Description
    Aligns the legacy (Defender, EXO) -> Security Suite policy migration across the
    two runs so a retired policy is compared against the Security Suite policy that
    replaced it, instead of being reported as RemovedPolicy alongside a matching
    NewPolicy.
 
    Operates on the per-product control maps in place: a matched before-side entry
    is moved out of its source product's map and re-keyed under its Security Suite
    base ID in the SecuritySuite map, so the record is produced -- and counted --
    under the Security Suite product. The moved entry is tagged with MigratedFromId
    / MigratedFromProduct, which become the record's migration fields.
 
    A stub tagged MigratedToId / MigratedToProduct is left in the source product's
    map so the retired policy still shows up in its own section as a before-only
    'Migrated' record. The comparison itself happens once, under the target product.
 
    A direct base-ID match always wins over the migration alias: a pair is only
    aligned when the before run has the retired policy, the after run has its
    replacement, and neither run carries both. That leaves a transitional run
    containing the old and new policy side by side compared as-is.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [System.Collections.Specialized.OrderedDictionary]
        $BeforeMaps,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [System.Collections.Specialized.OrderedDictionary]
        $AfterMaps
    )

    $target = $script:PolicyMigrationTargetProduct

    # Nothing to align unless the after run assessed the target product.
    if (-not $AfterMaps.Contains($target)) { return }

    $targetAfter = $AfterMaps[$target]
    if ($BeforeMaps.Contains($target)) { $targetBefore = $BeforeMaps[$target] } else { $targetBefore = $null }

    foreach ($source in @($script:PolicyMigrationMap.Keys)) {
        # Nothing to align from a source product the before run did not assess.
        if (-not $BeforeMaps.Contains($source)) { continue }

        $sourceBefore = $BeforeMaps[$source]
        if ($AfterMaps.Contains($source)) { $sourceAfter = $AfterMaps[$source] } else { $sourceAfter = $null }

        $entries = $script:PolicyMigrationMap[$source]
        foreach ($oldBase in @($entries.Keys)) {
            $newBase = $entries[$oldBase]

            # The before run must carry the retired policy and the after run its
            # replacement; otherwise there is nothing to pair.
            if (-not $sourceBefore.Contains($oldBase)) { continue }
            if (-not $targetAfter.Contains($newBase)) { continue }

            # Either side carrying both forms means this is not a migration
            # boundary. This also keeps a target claimed by an earlier alignment
            # from being overwritten, should the table ever gain two source
            # policies pointing at the same Security Suite ID.
            if ($null -ne $sourceAfter -and $sourceAfter.Contains($oldBase)) { continue }
            if ($null -ne $targetBefore -and $targetBefore.Contains($newBase)) { continue }

            $entry = $sourceBefore[$oldBase]
            Add-Member -InputObject $entry -NotePropertyName 'MigratedFromId' -NotePropertyValue $entry.FullId -Force
            Add-Member -InputObject $entry -NotePropertyName 'MigratedFromProduct' -NotePropertyValue $source -Force

            if ($null -eq $targetBefore) {
                $targetBefore = [ordered]@{}
                $BeforeMaps[$target] = $targetBefore
            }
            $targetBefore[$newBase] = $entry

            # Replace the source-side entry with a stub rather than dropping it, so
            # the retired policy still appears in its own product's section instead
            # of vanishing from the report. MigratedToId/MigratedToProduct make the
            # stub produce a before-only 'Migrated' record pointing at where the
            # comparison was actually made. A fresh object (not $entry) keeps the two
            # records' migration markers from aliasing each other.
            $sourceBefore[$oldBase] = [pscustomobject]@{
                FullId            = $entry.FullId
                BaseId            = $entry.BaseId
                Version           = $entry.Version
                Result            = $entry.Result
                OriginalResult    = $entry.OriginalResult
                Criticality       = $entry.Criticality
                Requirement       = $entry.Requirement
                Details           = $entry.Details
                GroupName         = $entry.GroupName
                GroupNumber       = $entry.GroupNumber
                MigratedToId      = $targetAfter[$newBase].FullId
                MigratedToProduct = $target
            }
        }
    }
}

function Get-ScubaAnnotationEntry {
    <#
    .Description
    Safely reads an AnnotatedFailedPolicies entry (keyed by full control ID) from
    a ScubaResults object, returning $null when absent.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [AllowNull()] [object] $Annotations,
        [Parameter(Mandatory = $true)] [string] $ControlId
    )
    if ($null -eq $Annotations) { return $null }
    $prop = $Annotations.PSObject.Properties[$ControlId]
    if ($null -eq $prop) { return $null }
    return $prop.Value
}

function Import-ScubaResultsFile {
    <#
    .Description
    Reads a ScubaResults.json file (BOM-tolerant via Get-Content), parses it, and
    validates the required top-level keys. Throws a user-actionable error on
    malformed input.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $LiteralPath
    )
    if (-not (Test-Path -LiteralPath $LiteralPath)) {
        throw "ScubaResults file not found: '$LiteralPath'."
    }
    try {
        $raw = Get-Content -LiteralPath $LiteralPath -Raw -ErrorAction Stop
    }
    catch {
        throw "Unable to read ScubaResults file '$LiteralPath': $($_.Exception.Message)"
    }
    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw "ScubaResults file '$LiteralPath' is empty."
    }
    try {
        $obj = $raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "ScubaResults file '$LiteralPath' is not valid JSON: $($_.Exception.Message)"
    }
    foreach ($key in @('MetaData', 'Summary', 'Results')) {
        if ($null -eq $obj.PSObject.Properties[$key]) {
            throw "ScubaResults file '$LiteralPath' is missing the required top-level key '$key'. Is this a ScubaResults.json file produced by ScubaGear?"
        }
    }
    return $obj
}

function Get-ScubaDiffRunTimestamp {
    <#
    .Description
    Parses a ScubaResults MetaData.TimestampZulu value (e.g.
    "2026-01-01T00:00:00.000Z") into a UTC DateTime. Returns $null when the value
    is absent or is not an ISO-8601 date and time, so callers skip the comparison
    rather than guess at an ordering. The leading pattern check keeps loose
    fragments that .NET would happily coerce into a date (a bare "3", say) from
    being read as a run timestamp.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]
        $Timestamp
    )
    if ([string]::IsNullOrWhiteSpace($Timestamp)) {
        return $null
    }
    if ($Timestamp -notmatch '^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}') {
        return $null
    }
    $parsed = [DateTime]::MinValue
    $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor
              [System.Globalization.DateTimeStyles]::AssumeUniversal
    if ([DateTime]::TryParse($Timestamp, [cultureinfo]::InvariantCulture, $styles, [ref]$parsed)) {
        return $parsed
    }
    return $null
}

function Compare-ScubaResults {
    <#
    .Description
    Pure comparison of two parsed ScubaResults objects. Returns the full
    DiffResults object (SchemaVersion / MetaData / Summary / Diff). Performs
    base-ID matching (per product, matched by name only), diff
    classification, and Fail->Fail annotation comparison.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]
        $Before,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]
        $After,

        [Parameter(Mandatory = $false)]
        [string]
        $ToolVersion = 'unknown'
    )

    $beforeProducts = @($Before.Results.PSObject.Properties.Name)
    $afterProducts = @($After.Results.PSObject.Properties.Name)

    # Flatten every product to a base-ID keyed control map up front, so the
    # legacy -> Security Suite migration can move before-side entries across
    # product boundaries before any product is compared.
    $beforeMaps = [ordered]@{}
    foreach ($product in $beforeProducts) { $beforeMaps[$product] = Get-ScubaControlMap $Before.Results.$product }
    $afterMaps = [ordered]@{}
    foreach ($product in $afterProducts) { $afterMaps[$product] = Get-ScubaControlMap $After.Results.$product }

    Invoke-ScubaPolicyMigrationAlignment -BeforeMaps $beforeMaps -AfterMaps $afterMaps

    # Each product is compared independently and matched by name only. A product
    # present in only one file has all of its controls reported as NewPolicy (only
    # in after) or RemovedPolicy (only in before). The union is taken over the
    # control maps rather than the raw product lists, since migration can give the
    # target product a before side it did not have in the input file.
    $allProducts = @(@($beforeMaps.Keys) + @($afterMaps.Keys) | Select-Object -Unique | Sort-Object)

    $beforeAnnot = $Before.AnnotatedFailedPolicies
    $afterAnnot = $After.AnnotatedFailedPolicies

    $diff = [ordered]@{}
    $summary = [ordered]@{}
    $productsOnlyBefore = @()
    $productsOnlyAfter = @()

    foreach ($product in $allProducts) {
        # ProductsOnlyIn* describes the input files, so it is derived from the raw
        # product lists rather than the post-migration maps.
        if (($beforeProducts -contains $product) -and -not ($afterProducts -contains $product)) { $productsOnlyBefore += $product }
        if (($afterProducts -contains $product) -and -not ($beforeProducts -contains $product)) { $productsOnlyAfter += $product }

        if ($beforeMaps.Contains($product)) { $beforeMap = $beforeMaps[$product] } else { $beforeMap = [ordered]@{} }
        if ($afterMaps.Contains($product)) { $afterMap = $afterMaps[$product] } else { $afterMap = [ordered]@{} }

        $allBase = Get-ScubaOrderedControlIds @(@($beforeMap.Keys) + @($afterMap.Keys) | Select-Object -Unique)

        $records = @()
        $classificationCounts = [ordered]@{}

        foreach ($base in $allBase) {
            $b = $beforeMap[$base]
            $a = $afterMap[$base]
            $bPresent = $null -ne $b
            $aPresent = $null -ne $a

            if ($bPresent) { $bResult = $b.Result; $bVersion = $b.Version } else { $bResult = $null; $bVersion = $null }
            if ($aPresent) { $aResult = $a.Result; $aVersion = $a.Version } else { $aResult = $null; $aVersion = $null }

            # Set by Invoke-ScubaPolicyMigrationAlignment on a before-side entry it
            # relocated into this product.
            $isMigrated = ($bPresent -and $null -ne $b.PSObject.Properties['MigratedFromId'])
            # Set on the stub it left behind in the source product. The pair is
            # compared once, under the target product, so this record is before-only
            # and is classified 'Migrated' rather than by a result diff it does not have.
            $isMigratedOut = ($bPresent -and $null -ne $b.PSObject.Properties['MigratedToId'])

            if ($isMigratedOut) {
                $classification = 'Migrated'
            }
            else {
                $classification = Get-ScubaDiffClassification -BeforeResult $bResult -AfterResult $aResult `
                    -BeforePresent $bPresent -AfterPresent $aPresent `
                    -BeforeVersion $bVersion -AfterVersion $aVersion `
                    -IsMigrated:$isMigrated
            }

            if ($aPresent) { $reqSource = $a.Requirement } else { $reqSource = $b.Requirement }
            if ($aPresent) { $groupName = $a.GroupName; $groupNumber = $a.GroupNumber } else { $groupName = $b.GroupName; $groupNumber = $b.GroupNumber }
            if ($aPresent) { $detailsAfter = ConvertTo-ScubaPlainText $a.Details } else { $detailsAfter = $null }

            $record = [ordered]@{
                'Control ID (Before)' = if ($bPresent) { $b.FullId } else { $null }
                'Control ID (After)'  = if ($aPresent) { $a.FullId } else { $null }
                'Requirement'         = ConvertTo-ScubaPlainText $reqSource
                'GroupName'           = $groupName
                'GroupNumber'         = $groupNumber
                'ResultBefore'        = $bResult
                'ResultAfter'         = $aResult
                'Classification'              = $classification
                'CriticalityBefore'   = if ($bPresent) { $b.Criticality } else { $null }
                'CriticalityAfter'    = if ($aPresent) { $a.Criticality } else { $null }
                'DetailsAfter'        = $detailsAfter
            }

            # Migration metadata. The record is classified by result like any other
            # pair; these fields are what tell a consumer the comparison spans the
            # legacy -> Security Suite migration rather than a single policy's
            # history. Control ID (Before) already carries the retired ID.
            if ($isMigrated) {
                $record['Migrated'] = $true
                $record['MigratedFromId'] = $b.MigratedFromId
                $record['MigratedFromProduct'] = $b.MigratedFromProduct
            }

            # The mirror of the above, on the source product's side: a before-only
            # record naming the policy that replaced this one. The after columns are
            # deliberately left empty -- the result comparison is reported once,
            # under the target product.
            if ($isMigratedOut) {
                $record['MigratedToId'] = $b.MigratedToId
                $record['MigratedToProduct'] = $b.MigratedToProduct
            }

            # False-positive (marked incorrect) metadata. When either side carries
            # the "Incorrect result" marking, record the marking state and the
            # underlying (tool-computed) result on each side so consumers compare
            # the real result rather than the placeholder.
            $bIncorrect = ($bPresent -and (Get-ScubaResultCategory $bResult) -eq 'Incorrect')
            $aIncorrect = ($aPresent -and (Get-ScubaResultCategory $aResult) -eq 'Incorrect')
            if ($bIncorrect -or $aIncorrect) {
                $record['MarkedIncorrectBefore'] = [bool]$bIncorrect
                $record['MarkedIncorrectAfter'] = [bool]$aIncorrect
                $record['UnderlyingResultBefore'] = if ($bPresent) { $b.OriginalResult } else { $null }
                $record['UnderlyingResultAfter'] = if ($aPresent) { $a.OriginalResult } else { $null }
            }

            # Fail -> Fail annotation comparison (narrow v1 scope).
            if ($bPresent -and $aPresent -and
                (Get-ScubaResultCategory $bResult) -eq 'Fail' -and
                (Get-ScubaResultCategory $aResult) -eq 'Fail') {
                $bEntry = Get-ScubaAnnotationEntry $beforeAnnot $b.FullId
                $aEntry = Get-ScubaAnnotationEntry $afterAnnot $a.FullId
                if ($bEntry) { $bComment = $bEntry.Comment; $bDate = $bEntry.RemediationDate } else { $bComment = $null; $bDate = $null }
                if ($aEntry) { $aComment = $aEntry.Comment; $aDate = $aEntry.RemediationDate } else { $aComment = $null; $aDate = $null }
                $record['AnnotationChanged'] = [bool](($bComment -ne $aComment) -or ($bDate -ne $aDate))
                $record['Comment'] = $aComment
                $record['RemediationDate'] = $aDate
            }

            $records += [pscustomobject]$record

            if (-not $classificationCounts.Contains($classification)) { $classificationCounts[$classification] = 0 }
            $classificationCounts[$classification] += 1
        }

        # Guard against an empty section, e.g. a product key present in a file with
        # no controls under it. Migration no longer empties a product -- it leaves a
        # 'Migrated' stub behind for every entry it relocates.
        if ($records.Count -eq 0) { continue }

        # Emit the per-product counts in the same severity order the HTML summary
        # columns use, so a consumer reading DiffResults.json sees the taxonomy in
        # one order rather than in whatever order the records happened to arrive.
        # Any classification outside the taxonomy is appended rather than dropped.
        $orderedCounts = [ordered]@{}
        foreach ($name in $script:ClassificationOrder) {
            if ($classificationCounts.Contains($name)) { $orderedCounts[$name] = $classificationCounts[$name] }
        }
        foreach ($name in @($classificationCounts.Keys)) {
            if (-not $orderedCounts.Contains($name)) { $orderedCounts[$name] = $classificationCounts[$name] }
        }

        $diff[$product] = $records
        $summary[$product] = $orderedCounts
    }

    $timestampZulu = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')

    $metaData = [ordered]@{
        'Tool'          = 'ScubaGear'
        'ToolVersion'   = $ToolVersion
        'TimestampZulu' = $timestampZulu
        'Before'        = [ordered]@{
            'ReportUUID'    = $Before.MetaData.ReportUUID
            'TimestampZulu' = $Before.MetaData.TimestampZulu
            'ToolVersion'   = $Before.MetaData.ToolVersion
        }
        'After'         = [ordered]@{
            'ReportUUID'    = $After.MetaData.ReportUUID
            'TimestampZulu' = $After.MetaData.TimestampZulu
            'ToolVersion'   = $After.MetaData.ToolVersion
        }
        'ProductsOnlyInBefore' = @($productsOnlyBefore)
        'ProductsOnlyInAfter'  = @($productsOnlyAfter)
    }

    return [ordered]@{
        'SchemaVersion' = '1.0'
        'MetaData'      = $metaData
        'Summary'       = $summary
        'Diff'          = $diff
    }
}

function Get-ScubaDiffFileEncoding {
    <#
    .Description
    Returns the encoding used to write diff output files, matching the repo's
    Get-FileEncoding convention: UTF-8 with BOM on Windows PowerShell 5.1
    (Desktop), UTF-8 without BOM on PowerShell 6+.
    .Functionality
    Internal
    #>

    if ($PSVersionTable.PSVersion -ge '6.0') {
        return 'utf8NoBom'
    }
    return 'utf8'
}

function ConvertTo-ScubaDiffCsvRecord {
    <#
    .Description
    Flattens a DiffResults object into one row per control, ready for Export-Csv /
    ConvertTo-Csv. Products are emitted in the report display order and carried in
    a leading Product column, since the CSV has no per-product sections.
 
    Every row is built from the same fixed column list, including the fields that
    only some records carry (the false-positive and annotation fields), which are
    left empty when absent. CSV headers come from the first object in the
    pipeline, so a uniform shape is what keeps those columns from being dropped
    for every record when the first one happens to lack them.
 
    Field names mirror DiffResults.json rather than the HTML report's column
    titles, so the CSV reads as a flattened view of the JSON.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]
        $DiffResults
    )

    $rows = @()
    foreach ($product in (Get-ScubaOrderedProducts @($DiffResults.Diff.Keys))) {
        foreach ($r in @($DiffResults.Diff.$product)) {
            $get = {
                param($Name)
                $prop = $r.PSObject.Properties[$Name]
                if ($null -eq $prop) { return $null }
                return $prop.Value
            }
            $row = [pscustomobject][ordered]@{
                'Product'                = $product
                'Control ID (Before)'    = $r.'Control ID (Before)'
                'Control ID (After)'     = $r.'Control ID (After)'
                'GroupNumber'            = $r.GroupNumber
                'GroupName'              = $r.GroupName
                'Classification'                 = $r.Classification
                'ResultBefore'           = $r.ResultBefore
                'ResultAfter'            = $r.ResultAfter
                'CriticalityBefore'      = $r.CriticalityBefore
                'CriticalityAfter'       = $r.CriticalityAfter
                'Requirement'            = $r.Requirement
                'DetailsAfter'           = $r.DetailsAfter
                'MarkedIncorrectBefore'  = & $get 'MarkedIncorrectBefore'
                'MarkedIncorrectAfter'   = & $get 'MarkedIncorrectAfter'
                'UnderlyingResultBefore' = & $get 'UnderlyingResultBefore'
                'UnderlyingResultAfter'  = & $get 'UnderlyingResultAfter'
                'AnnotationChanged'      = & $get 'AnnotationChanged'
                'Comment'                = & $get 'Comment'
                'RemediationDate'        = & $get 'RemediationDate'
                'Migrated'               = & $get 'Migrated'
                'MigratedFromId'         = & $get 'MigratedFromId'
                'MigratedFromProduct'    = & $get 'MigratedFromProduct'
                'MigratedToId'           = & $get 'MigratedToId'
                'MigratedToProduct'      = & $get 'MigratedToProduct'
            }

            # The CSV is meant to be opened in a spreadsheet, where a cell whose
            # text begins with =, +, - or @ is evaluated as a formula. Sanitize
            # every column rather than only the free-text annotation Comment,
            # since the requirement and detail strings are tenant-derived too.
            foreach ($name in @($row.PSObject.Properties.Name)) {
                $row.$name = ConvertTo-ScubaCsvSafeText -Value $row.$name
            }
            $rows += $row
        }
    }
    return $rows
}

function New-ScubaDiffReport {
    <#
    .Description
    Builds the standalone HTML diff report string from a DiffResults object. The
    report is fully self-contained (inline CSS + JS) and does NOT reuse New-Report
    (which requires live run artifacts). Unchanged rows are emitted but hidden by
    default via a client-side toggle. All user-controllable strings are
    HTML-encoded.
    .Functionality
    Internal
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object]
        $DiffResults,

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

    $CssPath = Join-Path -Path $PSScriptRoot -ChildPath 'styles\DiffReport.css'
    $JsPath = Join-Path -Path $PSScriptRoot -ChildPath 'scripts\DiffReport.js'
    $Css = Get-Content -LiteralPath $CssPath -Raw
    $Js = Get-Content -LiteralPath $JsPath -Raw

    $meta = $DiffResults.MetaData
    $enc = { param($s) ConvertTo-ScubaHtmlEncoded ([string]$s) }

    # Severity-ordered list of classifications for the summary columns and their
    # filter checkboxes (see $script:ClassificationOrder).
    $classificationOrder = @($script:ClassificationOrder)

    $sb = New-Object System.Text.StringBuilder

    [void]$sb.AppendLine('<div class="report-header">')
    [void]$sb.AppendLine(' <div class="report-title">')
    [void]$sb.AppendLine(' <h1>ScubaGear Diff Report</h1>')
    [void]$sb.AppendLine(' <p class="report-subtitle">Comparison between two ScubaResults.json files</p>')
    [void]$sb.AppendLine(' </div>')
    [void]$sb.AppendLine(' <div class="controls-bar">')
    [void]$sb.AppendLine(' <label><input type="checkbox" id="toggle-unchanged"> Show unchanged rows</label>')
    [void]$sb.AppendLine(' <label><input type="checkbox" id="toggle-dark"> Dark Mode</label>')
    [void]$sb.AppendLine(' </div>')
    [void]$sb.AppendLine('</div>')

    # Source metadata cards.
    [void]$sb.AppendLine('<div class="source-summary">')
    foreach ($side in @('Before', 'After')) {
        $s = $meta.$side
        [void]$sb.AppendLine(' <div class="source-card">')
        [void]$sb.AppendLine(" <h3>$side</h3>")
        [void]$sb.AppendLine(" <div>Tool version: $(& $enc $s.ToolVersion)</div>")
        [void]$sb.AppendLine(" <div>Timestamp: $(& $enc $s.TimestampZulu)</div>")
        [void]$sb.AppendLine(" <div class=""uuid"">Report UUID: $(& $enc $s.ReportUUID)</div>")
        [void]$sb.AppendLine(' </div>')
    }
    [void]$sb.AppendLine('</div>')
    [void]$sb.AppendLine("<p class=""diff-generated"">Diff generated $(& $enc $meta.TimestampZulu) by ScubaGear $(& $enc $meta.ToolVersion).</p>")

    if (@($meta.ProductsOnlyInBefore).Count -gt 0) {
        [void]$sb.AppendLine("<p><strong>Products only in Before (controls reported as Removed Policy unless migrated):</strong> $(& $enc ((@($meta.ProductsOnlyInBefore)) -join ', '))</p>")
    }
    if (@($meta.ProductsOnlyInAfter).Count -gt 0) {
        [void]$sb.AppendLine("<p><strong>Products only in After (all controls New Policy):</strong> $(& $enc ((@($meta.ProductsOnlyInAfter)) -join ', '))</p>")
    }

    # Legend. Rows are colored by their Result (After) value; removed policies are
    # greyed out like manual checks.
    [void]$sb.AppendLine('<div class="legend">')
    [void]$sb.AppendLine(' <span><span class="swatch diff-red"></span>Fail (Result After)</span>')
    [void]$sb.AppendLine(' <span><span class="swatch diff-yellow"></span>Warning (Result After)</span>')
    [void]$sb.AppendLine(' <span><span class="swatch diff-green"></span>Pass (Result After)</span>')
    [void]$sb.AppendLine(' <span><span class="swatch diff-grey"></span>Manual (N/A) / Omitted / Removed Policy / Migrated</span>')
    [void]$sb.AppendLine(' <span><span class="migrated-badge">migrated</span>Before result comes from the retired policy shown in the Control ID column.</span>')
    [void]$sb.AppendLine(' <span><strong>Migrated</strong> rows are the retired policy in its own product section; its result is compared under Security Suite.</span>')
    [void]$sb.AppendLine(' <span>Unchanged rows are hidden by default (use the toggle above).</span>')
    [void]$sb.AppendLine('</div>')

    # Per-product summary table. Every classification in the taxonomy gets a column --
    # including classifications absent from this diff -- so each one has a filter control.
    # Each non-Unchanged column header carries a checkbox that filters both this
    # table (dimming the column and recomputing totals) and the per-product
    # diff tables below. Unchanged has no checkbox here: it stays governed by
    # the "Show unchanged rows" toggle in the report header.
    $summaryColumns = @($classificationOrder)

    [void]$sb.AppendLine('<h2>Summary</h2>')
    [void]$sb.AppendLine('<div class="filter-controls">')
    [void]$sb.AppendLine(' <span class="filter-hint">Use the checkboxes in the column headers to filter classifications. Filters apply to this table and the product tables below.</span>')
    [void]$sb.AppendLine(' <button type="button" id="toggle-all-filters" class="filter-btn">Uncheck all filters</button>')
    [void]$sb.AppendLine('</div>')
    [void]$sb.AppendLine('<table class="summary-table">')
    [void]$sb.Append('<tr><th>Product</th>')
    foreach ($col in $summaryColumns) {
        $colEnc = & $enc $col
        if ($col -eq 'Unchanged') {
            [void]$sb.Append("<th class=""classification-col"" data-classification=""$colEnc"">$colEnc</th>")
        }
        else {
            [void]$sb.Append("<th class=""classification-col classification-filter"" data-classification=""$colEnc""><label><input type=""checkbox"" class=""classification-toggle"" data-classification=""$colEnc"" checked> $colEnc</label></th>")
        }
    }
    [void]$sb.AppendLine('<th>Total</th></tr>')
    foreach ($product in (Get-ScubaOrderedProducts @($DiffResults.Summary.Keys))) {
        $counts = $DiffResults.Summary.$product
        [void]$sb.Append("<tr><td>$(& $enc (Get-ScubaProductDisplayName $product))</td>")
        $total = 0
        foreach ($col in $summaryColumns) {
            if ($counts.Contains($col)) { $val = $counts[$col] } else { $val = 0 }
            $total += $val
            $colEnc = & $enc $col
            if ($val -eq 0) { $cls = 'count count-zero' } else { $cls = 'count' }
            [void]$sb.Append("<td class=""$cls"" data-classification=""$colEnc"" data-count=""$val"">$val</td>")
        }
        [void]$sb.AppendLine("<td class=""summary-total"">$total</td></tr>")
    }
    [void]$sb.AppendLine('</table>')

    # Per-product diff tables.
    foreach ($product in (Get-ScubaOrderedProducts @($DiffResults.Diff.Keys))) {
        $records = @($DiffResults.Diff.$product)
        [void]$sb.AppendLine("<h2>$(& $enc (Get-ScubaProductDisplayName $product))</h2>")
        [void]$sb.AppendLine('<table class="policy-diff">')
        [void]$sb.AppendLine('<tr><th>Control ID</th><th>Group</th><th>Diff</th><th>Result (Before)</th><th>Result (After)</th><th>Requirement</th><th>Details (After)</th></tr>')
        foreach ($r in $records) {
            $classification = $r.Classification
            # Row color follows the Result (After) value (Fail/Error=red,
            # Warning=yellow, Pass=green, manual/removed/other=grey).
            $color = Get-ScubaRowColorClass $r
            $rowClass = "diff-row diff-$color"
            if ($classification -eq 'Unchanged') { $rowClass += ' diff-unchanged-row' }

            # Control ID display: show a "before -> after" arrow when the IDs differ.
            $beforeId = $r.'Control ID (Before)'
            $afterId = $r.'Control ID (After)'
            if ($beforeId -and $afterId -and ($beforeId -ne $afterId)) {
                $idDisplay = "$(& $enc $beforeId) &rarr; $(& $enc $afterId)"
            }
            elseif ($afterId) { $idDisplay = & $enc $afterId }
            else { $idDisplay = & $enc $beforeId }

            # Badge a pair aligned across the legacy -> Security Suite migration,
            # so a reader can tell the before result came from a different policy.
            $isMigratedRow = ($r.PSObject.Properties['Migrated'] -and $r.Migrated)
            if ($isMigratedRow) {
                $idDisplay += " <span class=""migrated-badge"" title=""Aligned across the legacy to Security Suite policy migration"">migrated</span>"
            }

            $groupDisplay = & $enc (("$($r.GroupNumber) $($r.GroupName)").Trim())

            # Result cells. For a side marked incorrect (false positive), surface
            # the underlying tool-computed result inline.
            $resultBeforeCell = & $enc $r.ResultBefore
            if ($r.PSObject.Properties['MarkedIncorrectBefore'] -and $r.MarkedIncorrectBefore -and $r.UnderlyingResultBefore) {
                $resultBeforeCell += " <span class=""underlying"">(underlying: $(& $enc $r.UnderlyingResultBefore))</span>"
            }
            $resultAfterCell = & $enc $r.ResultAfter
            if ($r.PSObject.Properties['MarkedIncorrectAfter'] -and $r.MarkedIncorrectAfter -and $r.UnderlyingResultAfter) {
                $resultAfterCell += " <span class=""underlying"">(underlying: $(& $enc $r.UnderlyingResultAfter))</span>"
            }

            # Color the result text itself, not just the row: the row background
            # carries the after state only, so a Pass -> Fail flip is otherwise
            # easy to read past.
            $resultBeforeClass = ('result-cell ' + (Get-ScubaResultTextClass $r.ResultBefore)).Trim()
            $resultAfterClass = ('result-cell ' + (Get-ScubaResultTextClass $r.ResultAfter)).Trim()

            # The source-side stub of a migrated policy. Name its replacement in a
            # tooltip so a reader can find where the comparison was made without
            # filling in the after columns, which belong to the target product's row.
            $classificationLabel = & $enc (Get-ScubaClassificationLabel $classification)
            if ($r.PSObject.Properties['MigratedToId'] -and $r.MigratedToId) {
                $classificationTitle = " title=""Replaced by $(& $enc $r.MigratedToId); compared in the $(& $enc (Get-ScubaProductDisplayName $r.MigratedToProduct)) section"""
            }
            else { $classificationTitle = '' }

            if ($isMigratedRow) { $migratedAttr = ' data-migrated="true"' } else { $migratedAttr = '' }
            [void]$sb.AppendLine("<tr class=""$rowClass"" data-classification=""$(& $enc $classification)""$migratedAttr>")
            [void]$sb.AppendLine(" <td>$idDisplay</td>")
            [void]$sb.AppendLine(" <td>$groupDisplay</td>")
            [void]$sb.AppendLine(" <td class=""classification-label""$classificationTitle>$classificationLabel</td>")
            [void]$sb.AppendLine(" <td class=""$resultBeforeClass"">$resultBeforeCell</td>")
            [void]$sb.AppendLine(" <td class=""$resultAfterClass"">$resultAfterCell</td>")
            [void]$sb.AppendLine(" <td>$(& $enc $r.Requirement)</td>")
            [void]$sb.AppendLine(" <td>$(& $enc $r.DetailsAfter)</td>")
            [void]$sb.AppendLine('</tr>')
        }
        [void]$sb.AppendLine('</table>')
    }

    $body = $sb.ToString()
    $darkFlag = $DarkMode.IsPresent.ToString().ToLowerInvariant()

    $html = @"
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>ScubaGear Diff Report</title>
<style>
$Css
</style>
</head>
<body>
$body
<script id="dark-mode-flag" type="application/json">$darkFlag</script>
<script>
$Js
</script>
</body>
</html>
"@

    return $html
}

function Invoke-SCuBADiff {
    <#
    .SYNOPSIS
    Compares two ScubaResults.json files and produces a machine-readable diff
    (DiffResults.json), a flat CSV (DiffResults.csv), and an HTML report
    (DiffReport.html) highlighting per-policy status diffs.
 
    .Description
    Invoke-SCuBADiff is an offline, post-hoc analysis command: it takes two
    ScubaResults.json files produced by earlier ScubaGear runs (a "before" and an
    "after") and reports how each policy's result changed between them. It performs
    no authentication and makes no Connect-* calls.
 
    Controls are matched on their base ID (the Control ID with the trailing version
    suffix removed, e.g. MS.AAD.1.1v1 -> MS.AAD.1.1). When the same base ID appears
    with different version suffixes, the change is classified as PolicyVersionUpdate
    and the result comparison is treated as informational. Products are matched by
    name only; a product present in only one file has all of its controls reported
    as NewPolicy or RemovedPolicy.
 
    The HTML report hides Unchanged rows by default; a client-side toggle reveals
    them. Use -DarkMode to default the report to dark theme.
 
    Paths are treated literally throughout, so a folder whose name contains
    wildcard characters (for example "PR2416 [Test]") is used exactly as given.
 
    .Parameter BeforePath
    Path to the earlier ("before") ScubaResults.json file.
 
    .Parameter AfterPath
    Path to the later ("after") ScubaResults.json file.
 
    .Parameter OutPath
    The folder where DiffResults.json, DiffResults.csv, and DiffReport.html are
    written. Created if it does not exist. Defaults to the current directory.
 
    .Parameter OutJsonFileName
    Base name (without extension) of the diff JSON file. Defaults to "DiffResults".
 
    .Parameter OutCsvFileName
    Base name (without extension) of the diff CSV file. Defaults to "DiffResults".
 
    .Parameter OutReportFileName
    Base name (without extension) of the diff HTML report. Defaults to "DiffReport".
 
    .Parameter DarkMode
    When present, the HTML report defaults to dark theme.
 
    .Example
    Invoke-SCuBADiff -BeforePath .\before\ScubaResults.json -AfterPath .\after\ScubaResults.json
 
    .Example
    Invoke-SCuBADiff -BeforePath q1.json -AfterPath q2.json -OutPath .\diff -DarkMode
 
    .Functionality
    Public
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]
        $BeforePath,

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

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutPath = (Get-Location).ProviderPath,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutJsonFileName = 'DiffResults',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutCsvFileName = 'DiffResults',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $OutReportFileName = 'DiffReport',

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

    $Before = Import-ScubaResultsFile -LiteralPath $BeforePath
    $After = Import-ScubaResultsFile -LiteralPath $AfterPath

    # The after file is trusted to be the later run: nothing here enforces it,
    # because the two files may equally be two tenants captured at the same point
    # in time, where chronology carries no meaning. A swapped pair still produces
    # a self-consistent report, but every change reads in reverse (a policy that
    # was fixed is classified NewFail) and the directional legacy -> Security
    # Suite migration alias stops aligning, so warn and carry on rather than
    # block. Runs whose timestamps are missing or unparseable are left alone.
    $BeforeRunTime = Get-ScubaDiffRunTimestamp -Timestamp $Before.MetaData.TimestampZulu
    $AfterRunTime = Get-ScubaDiffRunTimestamp -Timestamp $After.MetaData.TimestampZulu
    if ($null -ne $BeforeRunTime -and $null -ne $AfterRunTime -and $AfterRunTime -le $BeforeRunTime) {
        $Relation = if ($AfterRunTime -eq $BeforeRunTime) { 'has the same timestamp as' } else { 'is older than' }
        Write-Warning ("The -AfterPath run ($($After.MetaData.TimestampZulu)) $Relation the -BeforePath run " +
            "($($Before.MetaData.TimestampZulu)). If the two files were passed in the wrong order, every change is " +
            "reported in reverse: a policy that was fixed shows as NewFail. Comparing them as given.")
    }

    # Resolve the running module version for the diff MetaData block.
    $ToolVersion = 'unknown'
    try {
        $ManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\..\ScubaGear.psd1'
        if (Test-Path -LiteralPath $ManifestPath) {
            $ToolVersion = (Import-PowerShellDataFile -LiteralPath $ManifestPath).ModuleVersion
        }
    }
    catch {
        Write-Warning "Could not resolve ScubaGear module version: $($_.Exception.Message)"
    }

    $DiffResults = Compare-ScubaResults -Before $Before -After $After -ToolVersion $ToolVersion

    if (-not (Test-Path -LiteralPath $OutPath)) {
        New-Item -ItemType Directory -Path $OutPath -Force | Out-Null
    }

    $Encoding = Get-ScubaDiffFileEncoding

    # Read and write every path literally. The input paths, OutPath, and the three
    # output file names all come from the caller, and a real directory such as
    # "PR2416 [Test]" is read as a wildcard by the -Path parameters: Set-Content
    # -Path fails outright on it, and a pattern that happens to match something
    # else would write to the wrong target. New-Item has no -LiteralPath, but its
    # -Path does not glob when creating an item, so it is left as is.

    # Write DiffResults.json.
    $JsonPath = Join-Path -Path $OutPath -ChildPath "$OutJsonFileName.json"
    $Json = $DiffResults | ConvertTo-Json -Depth 10
    # ConvertTo-Json escapes <, >, and ' as unicode escape sequences; convert them
    # back for readability and parity with the ScubaResults.json convention.
    $Bs = [char]0x5C  # backslash, built from a char code to keep the source literal
    $Json = $Json.Replace("${Bs}u003c", '<').Replace("${Bs}u003e", '>').Replace("${Bs}u0027", "'")
    $Json | Set-Content -LiteralPath $JsonPath -Encoding $Encoding -ErrorAction Stop

    # Write DiffResults.csv: one row per control, flattened across products.
    # ConvertTo-Csv (rather than Export-Csv) keeps the write going through
    # Set-Content with the same encoding as the other two artifacts.
    $CsvPath = Join-Path -Path $OutPath -ChildPath "$OutCsvFileName.csv"
    $CsvRows = @(ConvertTo-ScubaDiffCsvRecord -DiffResults $DiffResults)
    if ($CsvRows.Count -gt 0) {
        $Csv = $CsvRows | ConvertTo-Csv -NoTypeInformation
    }
    else {
        $Csv = @()
    }
    $Csv | Set-Content -LiteralPath $CsvPath -Encoding $Encoding -ErrorAction Stop

    # Write DiffReport.html.
    $ReportPath = Join-Path -Path $OutPath -ChildPath "$OutReportFileName.html"
    $Html = New-ScubaDiffReport -DiffResults $DiffResults -DarkMode:$DarkMode
    $Html | Set-Content -LiteralPath $ReportPath -Encoding $Encoding -ErrorAction Stop

    Write-Information -MessageData "ScubaGear diff written to:`n $JsonPath`n $CsvPath`n $ReportPath" -InformationAction Continue

    return [PSCustomObject]@{
        JsonPath   = $JsonPath
        CsvPath    = $CsvPath
        ReportPath = $ReportPath
    }
}

Export-ModuleMember -Function @(
    'Invoke-SCuBADiff',
    'Import-ScubaResultsFile',
    'Get-ScubaDiffRunTimestamp',
    'Compare-ScubaResults',
    'ConvertTo-ScubaDiffCsvRecord',
    'New-ScubaDiffReport',
    'Get-ScubaBaseControlId',
    'Get-ScubaControlVersion',
    'Get-ScubaControlSortKey',
    'Get-ScubaOrderedControlIds',
    'ConvertTo-ScubaPlainText',
    'ConvertTo-ScubaHtmlEncoded',
    'ConvertTo-ScubaCsvSafeText',
    'Get-ScubaResultCategory',
    'Get-ScubaDiffClassification',
    'Get-ScubaClassificationColor',
    'Get-ScubaClassificationLabel',
    'Get-ScubaRowColorClass',
    'Get-ScubaResultTextClass',
    'Get-ScubaProductDisplayName',
    'Get-ScubaOrderedProducts',
    'Get-ScubaControlMap',
    'Invoke-ScubaPolicyMigrationAlignment',
    'Get-ScubaAnnotationEntry',
    'Get-ScubaDiffFileEncoding'
)

# SIG # Begin signature block
# MIIu9wYJKoZIhvcNAQcCoIIu6DCCLuQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBJ7meFjNrJiK6X
# I2bfHaD33ju8yrMU/cuaAHxppOPPeaCCE6MwggWQMIIDeKADAgECAhAFmxtXno4h
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
# eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw
# CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV
# BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ
# 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8
# qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp
# uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X
# +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R
# SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs
# vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ
# TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj
# wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD
# DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml
# t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL
# Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R
# hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud
# IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl
# cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw
# gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp
# Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j
# cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0
# ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF
# BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t
# MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl
# cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ
# BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb
# ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby
# jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e
# Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO
# XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO
# DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs
# YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU
# MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd
# rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM
# YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi
# +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK
# KSXGBxlExEvjQ6dYwjGCGqowghqmAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV
# BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0
# IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP
# VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh
# AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM
# BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAp9gu5Av5u3nqWL4YObL8lCp+x
# vF6jKlJP/Ah4ZenAkzANBgkqhkiG9w0BAQEFAASCAgBKXCnBm+5l6mJGFsQW+8ka
# U3f735dVQYHfrKUaFoUrjj2SGHPJPzYWeKElM2a1gqoEk4Jt8ZzPz/AbDcQx7Nnw
# ILL/sfC7bCtZCuCAZcv3xDqILXFm8+xRgMOwgM2OZIw6TDkK+6UmawQkxBMjX6jZ
# M34b3AupF5an1hLLT2BGUsU8s+YYpoMd9MiRqDeFBolVwZMikWokuwwMCE0fyIEt
# xj7CL4tjqSjFP/hFIsiYQyuOBirrzGpS2AzODJIoNw3aqBg4/70joGqO+rzQhrmB
# REhH+Yc7AKi8rZCq8UQpLDVTB4v9W79GusJGrcXrHji+947yY2VQMNowilnY86me
# /f8MHT9n2BrHZc6ZnL6NqLb0vnfef5JyQ9vXG7M/W4049bg+e4omcx+TQLTueg7c
# Ow+JVCPDZNjeNt6cF84Ko10yTkgLmtCfNcy+bYcHpX1C3hdlFv8zu5YhLS64xKcd
# kpcD4cHni1xjVQkrII9hU0bHHXqKGlPvDkkCew5WUj9Pnt944mY0bwe7z23PD23R
# VLMYvRqqaoTfYvVg4Kgia4OnSUxDAK7eNX/BLxkN+o/sWzOQMyfzkS/ZRSA6Z+36
# pu5fnePe8kzU4PQ7aa4mDIKcJ0y+7HqeGlm6oca1q9d7DKp813Z9/RCvkdQ1sW0a
# PSt7Kk7AE2YT3z+hkEIk3KGCF3cwghdzBgorBgEEAYI3AwMBMYIXYzCCF18GCSqG
# SIb3DQEHAqCCF1AwghdMAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ
# AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCBJ8k+Otu+y
# ZB7yIOXT2p2pHdbVRc6Kq8yauf5uYyfmJgIRALyOc1P7Y3+cnBIgXyUMfcEYDzIw
# MjYxMDIyMjEzMDQxWqCCEzowggbtMIIE1aADAgECAhAIT9wzT35FTtvDD4/5khg1
# MA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy
# dCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBp
# bmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjYwODA1MDAwMDAwWhcNMzcx
# MTA0MjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFtcCBS
# ZXNwb25kZXIgMjAyNiAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tnum8sn+zUr41JtMZbP9OMYw+HwJDpG5xkIu/lqcfNYmMX81YmsUiHLbh9ykpeWB
# GKTLhYBrAN9Tdg/QEzG32XcObmgIblnr0CoQ3WSAeDZ6nH6X6VkFyYkJw3QBJREw
# vm4UhLzSxmwPA7cFKRTEOMsmEEj6qJk/dqLEAL+oQYuOwE2UuiX1Vnul8YReIyWd
# 4kgLn9gq6LNXM0UplkR6jL/QHxmb6fMoGBJYbnaUI7XD6cKDpekK2SVMld4iDbze
# HDtOaaxldH5IxuNusQ69nd8/ZXEiB5Hbxj3RlK13cX1W4DlFXKdv/CEhM8Cj1vvl
# mvhNroyPdRGbbpBlgyf8Wdu5N6ByhFwURn0U6ozlPoxN22v+fviUhP+6DR547OZn
# pBMWDfei1f5sVGwiiW/KQTWOK97g+4RJpPzPNV4VYMAwO2jM2Aty2QYPVmOQTJm0
# msuXnJrSbl2gf9JylpkJlWXqk1Q4LJsxz+TELoQCZIljbgvTJgoPU2R12ydv8i1U
# qL/adelA0y7U9Pmmtbze9Xx3rtajC5SzQd1jgfwAwsa90v9YcSPdmeoyoBBA/27c
# CL237l5DTYYPDLQ4ON3OLTGWnvRb6jDrf/T75gMRfUzSLCBQfBusm9+mSWRlC/Df
# 6S/e9Q8i13CuhzOT2Jx+V/nlbXM4QoBwlUAhelwwJT0CAwEAAaOCAZUwggGRMAwG
# A1UdEwEB/wQCMAAwHQYDVR0OBBYEFBTJY4owLtRK+26U8+bjQH717M3iMB8GA1Ud
# IwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAWBgNV
# HSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w
# aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCGTmh0
# dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFt
# cGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE
# AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAI3FOmEenVIK35msCYB+
# fShAsWvSYvLBItoNdAgQ2jIqrGsVsluXMJU/+mRebBc52s6lbKAvOVPXaizmKkML
# LflEEKDZQx4CkS2t8aHPjkXha3hYZ010htFa3dhNgmalH5vuWvh3tTCf4frTS7gP
# tGc4Z/xaPhQ2AB1mR8eEe/WbH0RWHvVIl6VwQ3+g5FKNfN2N/DWJkf13w2H+2Gfq
# Efbd35Ww8CvoYBjLNIDTadcPWdgsjsiOaK/7EsKJgLjUNIVgvcaFOLLQ/GlrA+0Z
# HJoFUbOr5SJN8zykPspXIXlpDJY/gqFUZRROeab9GVgmhbdOJcD/63RhxPahFUGb
# ckRONqMe6DYAv6/mOG0pWd3cPStsdcS7buj5DyniwRY8yooMH6ptx5vpP/pZzBPB
# eZD2U4IsthyxB5Jaa8qrOkB5z160TXiM5ADMspZ0TfD9MJoq0tFpFPssKRFhWeED
# YPvcUuN7U7lvcdHl4ezQ3NT/7Ffs1sR1yh/LRbdZ3B3Vc6q2WmD8mDC0p9kzl2o7
# 3iVtS946IkEj7FkRsZGww1teYxERROC745xrtjvcw9ZyyUjHZWGRIpJeMNsPquCD
# f0fkyHtB+J4AiNZqCQk23rxh+KbpyMTNVKItJ5l92Svl20U9NbqMBOVYl1h54NEY
# LJq1/xHWFKPNK903zJZA9P2DMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJ
# hjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl
# cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdp
# Q2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1
# OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkb
# sDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+
# AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAkt
# VJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3
# Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv8
# 2wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638Z
# xqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7V
# vwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2N
# L+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTK
# IsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUoz
# ZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIG
# A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtO
# MB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIB
# hjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQw
# QwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lD
# ZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZI
# AYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N
# 0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7J
# d2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1j
# EMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdog
# P8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMv
# NYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBld
# NE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjT
# Y6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/B
# dHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyI
# PbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8
# dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2j
# NrbM1pD2T7m3XDCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI
# hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ
# MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz
# dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow
# YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ
# d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290
# IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww
# IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5
# 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH
# hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6
# Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ
# ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b
# A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9
# WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU
# tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo
# ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J
# vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP
# orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB
# Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr
# oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt
# MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF
# BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl
# ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw
# BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH
# vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8
# UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn
# f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU
# jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j
# LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGCA3ww
# ggN4AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
# MUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0
# MDk2IFNIQTI1NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQME
# AgEFAKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkF
# MQ8XDTI2MTAyMjIxMzA0MVowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUUdmr2gNJ
# c9hPQmaspIJI5rNpxDkwLwYJKoZIhvcNAQkEMSIEIOHXqyTBjnfLEvI2oQjaM0s3
# 2oeP/9w9L45GwsgQMS55MDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEIC2gnaf0Ex+f
# 5y22xebpyWVnVa8EPx6nQswNISDhQev8MA0GCSqGSIb3DQEBAQUABIICACBKw1uD
# Og+5vopm7Gv252Um3gZEDCQFkj2aatibVqvune+j9NTr0DHfxGnj9Ot0dmIckUOm
# 563DReOA3IrRuTA6TWtg7HNsd7p5B8WOhjTEYimNzTPD02rivNRwtzXt4kxu2nub
# SjKEVQZgEDfMq2ceaBZjTDkQXgmXadLhgfDppEHIlwGv+RtoCkJWHGIT7UCaX4dt
# vHz1L4O3w6fxtVvxo+Z0BHvhnrsJeKZi4lqNiykXU6ADlqUK66YoTGN+mN3FDQ+v
# oDc264aMj/TaZsrUgb6uUWA63rIN3Y7nPVTEGsFuC3njY41vWdmg9mRKdVsir/Rp
# ARSENqotqOHsdVr/JArqFJREFqT0o46qsQs/IWoRz5WYk+sQV+5btCzoDnw0puBK
# PZYuAOAMC8eKIhEjHtntu65cn/j72SJiUHCneO1rADaCExKCLuMddEATvyKuUJ6b
# WnLL1OgsDjKbqn/d6M6gaHmO59O+JI7+Y7nbwQECKXIR3cCspMjMEOBtp30uip8K
# dhvNjmTEy1IcoYPdO0U0mEwswtTPFO6b/RVow3lN+djNiBAzKv2uU6oViy2ITevW
# bbwWQjwSh5rKESN8qZmw2mu1NfD1ZkVVWWAKmlkYXKIljMgWBfHnnj4yF6fVLg+9
# uqyts0AGOJnDaBaH16hKbhz9Rhmpi909UCKV
# SIG # End signature block