Modules/Invoke-EDCA.ps1

function Invoke-EDCA {
    <#
    .SYNOPSIS
        EDCA — Exchange Deployment & Compliance Assessment.
 
        Version: 1.0.0.0
        Author: Michel de Rooij
        Source: https://github.com/michelderooij/EDCA
        Website: https://eightwone.com
 
    .DESCRIPTION
        EDCA (Exchange Deployment & Compliance Assessment) collects configuration data from Exchange 2016,
        Exchange 2019, and Exchange SE servers, evaluates each server against a library of best-practice
        and security controls, and produces a detailed HTML report with pass/fail findings, severity
        ratings, and remediation guidance.
 
        Use -Collect to run the collection phase only, -Report to run the report phase only, or both
        switches together to run collection and reporting in a single run. When neither switch is
        specified, both phases run by default (equivalent to specifying -Collect -Report).
 
    .PARAMETER Collect
        Runs the collection phase only. Connects to the target Exchange servers, gathers configuration
        telemetry, and writes per-server and organization JSON files to the Data folder (-DataPath).
        Cannot be combined with -Report; -Servers and -ThrottleLimit are not available in -Report mode.
 
    .PARAMETER Report
        Runs the report phase only. Reads all *.json files from the Data folder (-DataPath), runs the
        analysis engine against the controls library, and generates an HTML report. Cannot be combined
        with -Collect; -Servers and -ThrottleLimit are not available in this mode. When neither -Collect
        nor -Report is specified, both phases run sequentially (equivalent to specifying both switches).
 
    .PARAMETER Servers
        List of Exchange server names to target during the collection phase.
 
    .PARAMETER ThrottleLimit
        Maximum number of parallel collection jobs (default: 4; range 1–128).
 
    .PARAMETER ControlsPath
        Path to the directory containing individual control JSON files. Defaults to the Controls folder
        inside the module directory. Override to use a custom controls library.
 
    .PARAMETER OutputPath
        Directory for HTML reports and remediation scripts (default: .\Output relative to the current
        working directory). Created automatically if it does not exist.
 
    .PARAMETER DataPath
        Directory for JSON data files (default: .\Data relative to the current working directory).
        During collection, per-server and organization JSON files are written here. During reporting,
        all *.json files in this directory are read as input for analysis. Created automatically if it
        does not exist.
 
    .PARAMETER RemediationScript
        When specified, generates a PowerShell remediation script file in the Output folder alongside
        the HTML report. Without -Collect, this switch behaves like -Report: it reads all *.json
        collection files from the Data folder (-DataPath) as its input data source; no live collection
        is performed. The generated script is a starting-point template containing sample code derived
        from each failed control's scriptTemplate — review and adapt it for your environment before
        running it in production.
 
    .PARAMETER Framework
        One or more framework names to include in the analysis. When specified, only controls tagged
        with at least one of the supplied frameworks are evaluated. Valid values are:
        Best Practice, ANSSI, BSI, CIS, CISA, DISA, ISM, NIS2.
        When omitted, all controls are evaluated regardless of framework.
 
    .PARAMETER Update
        When specified, downloads the latest exchange.builds.json from GitHub and saves it to
        the Config directory inside the module, then continues with the requested operation.
 
    .EXAMPLE
        Invoke-EDCA -Update
 
    .EXAMPLE
        Invoke-EDCA -Servers EX01,EX02
 
    .EXAMPLE
        Invoke-EDCA -Collect -Servers EX01,EX02
 
    .EXAMPLE
        Invoke-EDCA -Report
 
    .EXAMPLE
        Invoke-EDCA -Report -DataPath C:\EDCAData
 
    .EXAMPLE
        Invoke-EDCA -Servers EX01,EX02 -Framework NIS2
 
    .EXAMPLE
        Invoke-EDCA -Report -Framework 'Best Practice'
    #>

    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(ParameterSetName = 'Collect', Mandatory = $true)]
        [switch]$Collect,

        [Parameter(ParameterSetName = 'Report', Mandatory = $true)]
        [switch]$Report,

        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Collect')]
        [string[]]$Servers = @(),

        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Collect')]
        [switch]$Local,

        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Collect')]
        [ValidateRange(1, 128)]
        [int]$ThrottleLimit = 4,

        [string]$ControlsPath = '',

        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Report')]
        [string]$OutputPath = '.\Output',

        [string]$DataPath = '.\Data',

        [Parameter(ParameterSetName = 'Default')]
        [Parameter(ParameterSetName = 'Report')]
        [switch]$RemediationScript,

        [switch]$Update,

        [ValidateSet('Best Practice', 'ANSSI', 'BSI', 'CIS', 'CISA', 'DISA', 'ISM', 'NIS2')]
        [string[]]$Framework
    )

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

    $EDCAVersion = 'v1.0.0.0'

    # $moduleRoot resolves module-owned assets (Controls/, Config/).
    # $userBase resolves user workspace paths (DataPath, OutputPath).
    $moduleRoot = $script:EDCAModuleRoot
    $userBase   = (Get-Location).Path

    # Derive phase flags from the active parameter set.
    $doCollect = $PSCmdlet.ParameterSetName -in @('Collect', 'Default')
    $doReport  = $PSCmdlet.ParameterSetName -in @('Report', 'Default')

    Write-Host ('=============================================================')
    Write-Host ('EXCHANGE DEPLOYMENT & COMPLIANCE ASSESSMENT {0}' -f $EDCAVersion)
    Write-Host ('=============================================================')

    $resolvedDataPath   = Resolve-EDCAPath -Path $DataPath   -BasePath $userBase
    $resolvedOutputPath = Resolve-EDCAPath -Path $OutputPath -BasePath $userBase

    # Resolve ControlsPath: when empty (the default), fall back to the module's own Controls/ folder.
    if ([string]::IsNullOrEmpty($ControlsPath)) {
        $resolvedControlsPath = Join-Path -Path $moduleRoot -ChildPath 'Controls'
    }
    else {
        $resolvedControlsPath = Resolve-EDCAPath -Path $ControlsPath -BasePath $userBase
    }

    New-EDCADirectoryIfMissing -Path $resolvedDataPath

    Write-Verbose ('Collect: {0}; Report: {1}' -f $doCollect, $doReport)
    Write-Verbose ('Resolved controls path: {0}' -f $resolvedControlsPath)
    Write-Verbose ('Resolved data path: {0}' -f $resolvedDataPath)
    Write-Verbose ('Resolved output path: {0}' -f $resolvedOutputPath)
    Write-Verbose ('Collection throttle limit: {0}' -f $ThrottleLimit)

    if (-not (Test-Path -Path $resolvedControlsPath -PathType Container)) {
        throw ('Controls directory not found: {0}' -f $resolvedControlsPath)
    }

    $controls = @(Get-ChildItem -Path $resolvedControlsPath -Filter '*.json' | Sort-Object Name | ForEach-Object {
        Get-Content -Path $_.FullName -Raw | ConvertFrom-Json
    })
    if ($controls.Count -eq 0) {
        throw ('No control JSON files found in: {0}' -f $resolvedControlsPath)
    }
    Write-Verbose ('Loaded {0} control definition(s).' -f $controls.Count)

    if ($Framework -and $Framework.Count -gt 0) {
        $filteredForOutput = @($controls | Where-Object {
                $ctrl = $_
                @($ctrl.frameworks) | Where-Object { $Framework -contains $_ }
            })
        if ($filteredForOutput.Count -eq 0) {
            throw ('No controls match the specified framework(s): {0}' -f ($Framework -join ', '))
        }
        Write-Verbose ('Framework filter [{0}] will be applied to report and remediation output: {1} control(s) match.' -f ($Framework -join ', '), $filteredForOutput.Count)
        Write-EDCALog -Message ('Framework filter: {0} — {1} control(s) will appear in report and remediation output.' -f ($Framework -join ', '), $filteredForOutput.Count)
    }

    if ($Update) {
        Write-EDCALog -Message 'Updating build information.'
        $buildsUrl  = 'https://raw.githubusercontent.com/michelderooij/EDCA/refs/heads/main/Config/exchange.builds.json'
        $buildsPath = Join-Path -Path $moduleRoot -ChildPath 'Config\exchange.builds.json'
        try {
            $content = (Invoke-WebRequest -Uri $buildsUrl -UseBasicParsing -ErrorAction Stop).Content
            $null = $content | ConvertFrom-Json
            [System.IO.File]::WriteAllText($buildsPath, $content, [System.Text.UTF8Encoding]::new($false))
            Write-EDCALog -Message 'exchange.builds.json updated successfully.'
        }
        catch {
            Write-Warning ('Failed to update exchange.builds.json: {0}' -f $_.Exception.Message)
        }
        if ($doCollect -and @($Servers).Count -eq 0) {
            Write-EDCALog -Message 'Execution completed.'
            return
        }
    }

    $collectionData = $null
    $rawOrgId       = $null
    $selectedOrgId  = $null

    if ($doCollect) {
        Write-EDCALog -Message 'Starting collection mode.'
        $isElevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
        if (-not $isElevated) {
            $exchangeSetupKey = 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v15\Setup'
            if (Test-Path -Path $exchangeSetupKey) {
                Write-EDCALog -Level 'WARN' -Message ('Running in a non-elevated PowerShell session on Exchange server {0}. Connecting to the local Exchange PowerShell endpoint requires elevation. Re-run EDCA as Administrator to avoid ''Access is denied'' errors when collecting from the local server.' -f $env:COMPUTERNAME)
            }
        }
        if ($Local) {
            $Servers = @($env:COMPUTERNAME) + @($Servers)
            Write-Verbose ('Local switch set; added {0} to target list.' -f $env:COMPUTERNAME)
        }
        Write-Verbose ('Collect mode target count from parameters: {0}' -f @($Servers).Count)
        $collectionData = Invoke-EDCACollection -Servers $Servers -ThrottleLimit $ThrottleLimit -ToolVersion $EDCAVersion

        $stamp         = Get-Date -Format 'yyyyMMdd_HHmmss'
        $exportedFiles = [System.Collections.Generic.List[string]]::new()

        # Resolve OrganizationId before exporting server files so it can be stamped on each.
        $rawOrgId = $null
        if (($collectionData.Organization.PSObject.Properties.Name -contains 'OrganizationIdentity') -and
            -not [string]::IsNullOrWhiteSpace([string]$collectionData.Organization.OrganizationIdentity)) {
            $rawOrgId = [string]$collectionData.Organization.OrganizationIdentity
        }

        foreach ($serverRecord in @($collectionData.Servers)) {
            $serverFqdn = if ($serverRecord.PSObject.Properties.Name -contains 'Server') { [string]$serverRecord.Server } else { 'unknown' }
            $safeName   = $serverFqdn -replace '[^\w\.\-]', '_'
            $jsonOut    = Join-Path -Path $resolvedDataPath -ChildPath ('{0}_{1}.json' -f $safeName, $stamp)

            $perServerMetadata = [pscustomobject]@{
                FileType            = 'Server'
                ToolName            = $collectionData.Metadata.ToolName
                ToolVersion         = $collectionData.Metadata.ToolVersion
                CollectionTimestamp = $collectionData.Metadata.CollectionTimestamp
                ExecutedBy          = $collectionData.Metadata.ExecutedBy
                ServerName          = $serverFqdn
                OrganizationId      = $rawOrgId
            }

            $perServerData = [pscustomobject]@{
                Metadata = $perServerMetadata
                Servers  = @($serverRecord)
            }

            ConvertTo-EDCAJson -InputObject $perServerData | Set-Content -Path $jsonOut -Encoding UTF8
            $exportedFiles.Add($jsonOut)
            Write-Verbose ('Server JSON exported: {0}' -f $jsonOut)
        }

        # Write the organization-wide JSON file (separate from per-server files).
        # Skip when every collected server is an Edge Transport server: Edge servers are not
        # domain-joined and carry no meaningful organization data, so writing an org file would
        # only produce an empty/stub entry that pollutes analysis runs on a Mailbox server.
        $allServersAreEdge = (@($collectionData.Servers).Count -gt 0) -and (
            @($collectionData.Servers | Where-Object {
                    -not (($_.PSObject.Properties.Name -contains 'Exchange') -and
                        ($_.Exchange.PSObject.Properties.Name -contains 'IsEdge') -and
                        [bool]$_.Exchange.IsEdge)
                }).Count -eq 0
        )

        if (-not $allServersAreEdge) {
            $safeOrgId  = if ($null -ne $rawOrgId) { $rawOrgId -replace '[^\w\.\-]', '_' } else { 'organization' }
            $orgJsonOut = Join-Path -Path $resolvedDataPath -ChildPath ('{0}_{1}.json' -f $safeOrgId, $stamp)

            $orgMetadata = [pscustomobject]@{
                FileType            = 'Organization'
                ToolName            = $collectionData.Metadata.ToolName
                ToolVersion         = $collectionData.Metadata.ToolVersion
                CollectionTimestamp = $collectionData.Metadata.CollectionTimestamp
                ExecutedBy          = $collectionData.Metadata.ExecutedBy
                OrganizationId      = $rawOrgId
            }

            $orgData = [pscustomobject]@{
                Metadata            = $orgMetadata
                Organization        = $collectionData.Organization
                EmailAuthentication = $collectionData.EmailAuthentication
            }

            ConvertTo-EDCAJson -InputObject $orgData | Set-Content -Path $orgJsonOut -Encoding UTF8
            $exportedFiles.Add($orgJsonOut)
            Write-Verbose ('Organization JSON exported: {0}' -f $orgJsonOut)
        }
        else {
            Write-Verbose ('Organization JSON export skipped: all collected servers are Edge Transport servers.')
        }

        $orgFileCount = if ($allServersAreEdge) { 0 } else { 1 }
        Write-EDCALog -Message ('Collection complete: {0} server JSON file(s) and {1} organization JSON file written to {2}' -f ($exportedFiles.Count - $orgFileCount), $orgFileCount, $resolvedDataPath)

        # Identify Edge servers that were not fully collected (Exchange cmdlet phase was skipped because
        # EDCA was not run locally on the Edge server). Emit a visible advisory so the operator knows
        # to run EDCA on each Edge server and include the resulting file in the Data folder.
        $edgeServersNotCollected = [System.Collections.Generic.List[string]]::new()
        foreach ($serverRecord in @($collectionData.Servers)) {
            if ($serverRecord.PSObject.Properties.Name -contains 'CollectionError') { continue }
            $exInfo = if ($serverRecord.PSObject.Properties.Name -contains 'Exchange') { $serverRecord.Exchange } else { $null }
            if ($null -eq $exInfo) { continue }
            $recordIsEdge = ($exInfo.PSObject.Properties.Name -contains 'IsEdge') -and [bool]$exInfo.IsEdge
            $hasCmdlets   = ($exInfo.PSObject.Properties.Name -contains 'ExchangeCmdletsAvailable') -and [bool]$exInfo.ExchangeCmdletsAvailable
            if ($recordIsEdge -and -not $hasCmdlets) {
                $edgeServerName = if ($serverRecord.PSObject.Properties.Name -contains 'Server') { [string]$serverRecord.Server } else { 'unknown' }
                $edgeServersNotCollected.Add($edgeServerName)
            }
        }

        if ($edgeServersNotCollected.Count -gt 0) {
            Write-Warning '--------------------------------------------------------------------------------'
            Write-Warning ('The following Edge Transport server(s) were discovered but could not be collected:')
            foreach ($edgeName in $edgeServersNotCollected) {
                Write-Warning ('- {0}' -f $edgeName)
            }
            Write-Warning 'To include complete Edge server data in the assessment:'
            Write-Warning ' 1. Copy EDCA to each Edge Transport server locally'
            Write-Warning ' 2. On the Edge Transport server, run: .\EDCA.ps1 -Collect -Local'
            Write-Warning (' 3. Copy the results in Data\<ServerName>_*.json file to {0}' -f $resolvedDataPath)
            Write-Warning ' 4. Re-run -Report (or -Collect -Report) which should pick up the added JSON with Edge Transport data'
            Write-Warning '--------------------------------------------------------------------------------'
        }

        if ($doCollect -and -not $doReport) {
            Write-EDCALog -Message 'Execution completed.'
            return
        }
    }

    if ($doReport -and -not $doCollect) {
        $jsonFiles = [string[]](Get-ChildItem -Path $resolvedDataPath -Filter '*.json' -File |
            Select-Object -ExpandProperty FullName)
        if ($jsonFiles.Count -eq 0) {
            throw ('No JSON files found in data folder: {0}' -f $resolvedDataPath)
        }

        Write-EDCALog -Message ('Found {0} JSON file(s) to evaluate.' -f $jsonFiles.Count)

        # Pass 1: parse all files and bucket them into org files vs server files.
        # Org files are fully collected before server files are processed so the selected
        # organization is known when server files are filtered in Pass 2.
        $allOrgFiles    = [System.Collections.Generic.List[pscustomobject]]::new()
        $rawServerFiles = [System.Collections.Generic.List[pscustomobject]]::new()

        foreach ($jsonFile in $jsonFiles) {
            $parsed = Get-Content -Path $jsonFile -Raw | ConvertFrom-EDCAJson

            $fileTimestamp = [datetime]::MinValue
            if ($parsed.PSObject.Properties.Name -contains 'Metadata' -and
                $parsed.Metadata.PSObject.Properties.Name -contains 'CollectionTimestamp') {
                $ts = $parsed.Metadata.CollectionTimestamp
                if ($null -ne $ts) {
                    try {
                        if ($ts -is [datetime]) { $fileTimestamp = $ts }
                        else { $fileTimestamp = [datetime]::Parse([string]$ts) }
                    }
                    catch { }
                }
            }

            # Skip non-collection files (e.g., analysis_*.json exports written by this tool).
            $hasCollectionContent = ($parsed.PSObject.Properties.Name -contains 'Servers') -or
            ($parsed.PSObject.Properties.Name -contains 'Organization') -or
            ($parsed.PSObject.Properties.Name -contains 'EmailAuthentication')
            if (-not $hasCollectionContent) {
                Write-Verbose ('Discarding non-collection file: {0}' -f (Split-Path $jsonFile -Leaf))
                continue
            }

            # Detect organization file: explicit FileType or has Organization/EmailAuth but no Servers.
            $fileType = ''
            if ($parsed.PSObject.Properties.Name -contains 'Metadata' -and
                $parsed.Metadata.PSObject.Properties.Name -contains 'FileType') {
                $fileType = [string]$parsed.Metadata.FileType
            }
            $isOrgFile = ($fileType -eq 'Organization') -or
            ($parsed.PSObject.Properties.Name -notcontains 'Servers' -and
            ($parsed.PSObject.Properties.Name -contains 'Organization' -or
            $parsed.PSObject.Properties.Name -contains 'EmailAuthentication'))

            if ($isOrgFile) {
                $allOrgFiles.Add([pscustomobject]@{
                        Timestamp = $fileTimestamp
                        Parsed    = $parsed
                        FilePath  = $jsonFile
                    })
                # Legacy org files that also embed Servers are held for pass 2.
                if ($parsed.PSObject.Properties.Name -contains 'Servers') {
                    $rawServerFiles.Add([pscustomobject]@{
                            Timestamp = $fileTimestamp
                            Parsed    = $parsed
                            FilePath  = $jsonFile
                        })
                }
            }
            else {
                $rawServerFiles.Add([pscustomobject]@{
                        Timestamp = $fileTimestamp
                        Parsed    = $parsed
                        FilePath  = $jsonFile
                    })
                # Legacy server files that also embed org data are added as org candidates too.
                if ($parsed.PSObject.Properties.Name -contains 'Organization' -or
                    $parsed.PSObject.Properties.Name -contains 'EmailAuthentication') {
                    $allOrgFiles.Add([pscustomobject]@{
                            Timestamp = $fileTimestamp
                            Parsed    = $parsed
                            FilePath  = $jsonFile
                        })
                }
            }
        }

        # Determine the selected organization from the most recently collected org file.
        # Also gather all collection timestamps that belong to that org (for legacy timestamp matching).
        # Warn if org files from a different organization are present in the folder.
        $selectedOrgId         = $null
        $selectedOrgTimestamps = @()
        if ($allOrgFiles.Count -gt 0) {
            # Prefer org files where Organization.Available = true (from Mailbox servers) over
            # Edge-sourced org files (Available = false, limited data). Tiebreak by recency.
            $bestOrgEntry = $allOrgFiles | Sort-Object -Property @(
                @{ Expression     = {
                        $p = $_.Parsed
                        if ($p.PSObject.Properties.Name -contains 'Organization' -and $null -ne $p.Organization -and
                            $p.Organization.PSObject.Properties.Name -contains 'Available') { [int][bool]$p.Organization.Available } else { 0 }
                    }; Descending = $true
                },
                @{ Expression = 'Timestamp'; Descending = $true }) | Select-Object -First 1
            if ($bestOrgEntry.Parsed.PSObject.Properties.Name -contains 'Metadata' -and
                $bestOrgEntry.Parsed.Metadata.PSObject.Properties.Name -contains 'OrganizationId') {
                $selectedOrgId = [string]$bestOrgEntry.Parsed.Metadata.OrganizationId
            }
            $selectedOrgTimestamps = @(
                $allOrgFiles | Where-Object {
                    $oId = if ($_.Parsed.PSObject.Properties.Name -contains 'Metadata' -and
                        $_.Parsed.Metadata.PSObject.Properties.Name -contains 'OrganizationId') {
                        [string]$_.Parsed.Metadata.OrganizationId
                    }
                    else { $null }
                    ($null -eq $selectedOrgId) -or ($null -eq $oId) -or ($oId -eq $selectedOrgId)
                } | ForEach-Object { $_.Timestamp }
            )
            $excludedOrgs = @(
                $allOrgFiles | Where-Object {
                    $oId = if ($_.Parsed.PSObject.Properties.Name -contains 'Metadata' -and
                        $_.Parsed.Metadata.PSObject.Properties.Name -contains 'OrganizationId') {
                        [string]$_.Parsed.Metadata.OrganizationId
                    }
                    else { $null }
                    $null -ne $oId -and $null -ne $selectedOrgId -and $oId -ne $selectedOrgId
                } | ForEach-Object { [string]$_.Parsed.Metadata.OrganizationId } | Select-Object -Unique
            )
            foreach ($xOrg in $excludedOrgs) {
                Write-EDCALog -Level 'WARN' -Message ('Organization "{0}" files found but excluded; using most recent organization "{1}".' -f $xOrg, $selectedOrgId)
            }
        }

        # Pass 2: filter server files to the selected organization and build the parsed record list.
        $allParsed            = [System.Collections.Generic.List[pscustomobject]]::new()
        $latestBaseMetadata   = $null
        $latestBaseTimestamp  = [datetime]::MinValue

        foreach ($sf in $rawServerFiles) {
            $parsed        = $sf.Parsed
            $fileTimestamp = $sf.Timestamp
            $jsonFile      = $sf.FilePath

            # Determine which organization this server file declares.
            $sfOrgId = $null
            if ($parsed.PSObject.Properties.Name -contains 'Metadata' -and
                $parsed.Metadata.PSObject.Properties.Name -contains 'OrganizationId') {
                $sfOrgId = [string]$parsed.Metadata.OrganizationId
            }

            # Skip files whose OrganizationId explicitly belongs to a different organization.
            # Exception: Edge server files report a workgroup-derived org identity that never matches
            # the AD org; include them regardless of OrgId mismatch.
            $sfIsEdge = ($parsed.PSObject.Properties.Name -contains 'Servers') -and
            @($parsed.Servers).Count -gt 0 -and
            ($parsed.Servers[0].PSObject.Properties.Name -contains 'Exchange') -and
            $null -ne $parsed.Servers[0].Exchange -and
            ($parsed.Servers[0].Exchange.PSObject.Properties.Name -contains 'IsEdge') -and
            [bool]$parsed.Servers[0].Exchange.IsEdge
            if ($null -ne $sfOrgId -and $null -ne $selectedOrgId -and $sfOrgId -ne $selectedOrgId -and -not $sfIsEdge) {
                Write-EDCALog -Level 'WARN' -Message ('Excluding server file "{0}": belongs to organization "{1}", not "{2}".' -f (Split-Path $jsonFile -Leaf), $sfOrgId, $selectedOrgId)
                continue
            }
            # For legacy files without OrganizationId, match by CollectionTimestamp when possible.
            if ($null -eq $sfOrgId -and $selectedOrgTimestamps.Count -gt 0 -and
                $fileTimestamp -ne [datetime]::MinValue -and $fileTimestamp -notin $selectedOrgTimestamps) {
                Write-Verbose ('Excluding legacy server file "{0}": CollectionTimestamp ({1}) does not match any collection run of the selected organization.' -f (Split-Path $jsonFile -Leaf), $fileTimestamp)
                continue
            }

            # Track base metadata from the most recent accepted server file.
            if ($fileTimestamp -gt $latestBaseTimestamp) {
                if ($parsed.PSObject.Properties.Name -contains 'Metadata') {
                    $latestBaseMetadata = $parsed.Metadata
                }
                $latestBaseTimestamp = $fileTimestamp
            }

            $serverName = ''
            if ($parsed.PSObject.Properties.Name -contains 'Metadata' -and
                $parsed.Metadata.PSObject.Properties.Name -contains 'ServerName') {
                $serverName = [string]$parsed.Metadata.ServerName
            }

            foreach ($srv in @($parsed.Servers)) {
                $allParsed.Add([pscustomobject]@{
                        Timestamp  = $fileTimestamp
                        ServerName = $serverName
                        Record     = $srv
                        FilePath   = $jsonFile
                    })
            }

            Write-Verbose ('Parsed server file {0}: server={1}, timestamp={2}' -f $jsonFile, $serverName, $fileTimestamp)
        }

        # Deduplicate server records: for each ServerName keep the most recent file's record.
        $deduplicatedServers = [System.Collections.Generic.List[object]]::new()
        $namedGroups = $allParsed | Where-Object { -not [string]::IsNullOrWhiteSpace($_.ServerName) } |
        Group-Object -Property ServerName

        foreach ($group in $namedGroups) {
            $best = $group.Group | Sort-Object -Property Timestamp -Descending | Select-Object -First 1
            $deduplicatedServers.Add($best.Record)
            if ($group.Group.Count -gt 1) {
                $skipped = $group.Group.Count - 1
                Write-Verbose ('Server "{0}": {1} older file(s) skipped; using data from {2}' -f $group.Name, $skipped, $best.FilePath)
                Write-EDCALog -Message ('Server "{0}": {1} duplicate(s) found; using most recent collection ({2}).' -f $group.Name, $skipped, $best.Timestamp)
            }
        }

        # Append records without a ServerName in Metadata (legacy / unknown — always include).
        foreach ($entry in @($allParsed | Where-Object { [string]::IsNullOrWhiteSpace($_.ServerName) })) {
            $deduplicatedServers.Add($entry.Record)
        }

        # Pick organization data from the most recently collected org file.
        $latestOrganization = $null
        $latestEmailAuth    = $null
        $latestOrgTimestamp = [datetime]::MinValue

        foreach ($orgEntry in $allOrgFiles) {
            if ($orgEntry.Timestamp -gt $latestOrgTimestamp) {
                if ($orgEntry.Parsed.PSObject.Properties.Name -contains 'Organization') {
                    $latestOrganization = $orgEntry.Parsed.Organization
                }
                if ($orgEntry.Parsed.PSObject.Properties.Name -contains 'EmailAuthentication') {
                    $latestEmailAuth = $orgEntry.Parsed.EmailAuthentication
                }
                $latestOrgTimestamp = $orgEntry.Timestamp
            }
        }

        if ($allOrgFiles.Count -gt 1) {
            $skippedOrg  = $allOrgFiles.Count - 1
            $bestOrgFile = ($allOrgFiles | Sort-Object { $_.Timestamp } -Descending | Select-Object -First 1).FilePath
            Write-EDCALog -Message ('Organization data: {0} file(s) found; using most recent ({1}).' -f $allOrgFiles.Count, $bestOrgFile)
        }

        # Assemble merged collection object.
        $collectionData = [pscustomobject]@{
            Metadata            = $latestBaseMetadata
            Servers             = $deduplicatedServers.ToArray()
            Organization        = $latestOrganization
            EmailAuthentication = $latestEmailAuth
        }

        Write-Verbose ('Total collection data contains {0} server record(s) after deduplication.' -f @($collectionData.Servers).Count)
    }

    Write-Verbose 'Starting analysis phase.'
    $analysis = Invoke-EDCAAnalysis -CollectionData $collectionData -Controls $controls

    # Determine the effective organisation ID for this run (collect path uses $rawOrgId,
    # report-only path uses $selectedOrgId; both may be $null for older/anonymous data).
    $effectiveOrgId = if (-not [string]::IsNullOrWhiteSpace($selectedOrgId)) { $selectedOrgId }
    elseif (-not [string]::IsNullOrWhiteSpace($rawOrgId)) { $rawOrgId }
    else { $null }

    # Stamp the organisation ID into the analysis Metadata so trend filtering works on future runs.
    if ($null -ne $effectiveOrgId) {
        $analysis.Metadata | Add-Member -MemberType NoteProperty -Name OrganizationId -Value $effectiveOrgId -Force
    }

    $analysisStamp = Get-Date -Format 'yyyyMMdd_HHmmss'
    $analysisOut   = Join-Path -Path $resolvedDataPath -ChildPath ('analysis_{0}.json' -f $analysisStamp)
    ConvertTo-EDCAJson -InputObject $analysis | Set-Content -Path $analysisOut -Encoding UTF8
    Write-EDCALog -Message ('Analysis JSON exported: {0}' -f $analysisOut)
    Write-Verbose ('Analysis produced {0} finding(s).' -f @($analysis.Findings).Count)

    # Load up to 10 most-recent analysis files (including the one just written) for the trend chart.
    # Filter to the same organisation as the current run so mixed-organisation data folders do not
    # pollute the trend chart. Files that pre-date the OrganizationId stamp are included when no
    # effective org ID is known, or skipped only when a different org ID is explicitly recorded.
    $historyData = @(
        Get-ChildItem -Path $resolvedDataPath -Filter 'analysis_*.json' -ErrorAction SilentlyContinue |
        Sort-Object -Property Name |
        ForEach-Object {
            try {
                $parsed = Get-Content -Path $_.FullName -Raw -ErrorAction Stop | ConvertFrom-Json
                if (($parsed.PSObject.Properties.Name -contains 'Scores') -and
                    ($parsed.PSObject.Properties.Name -contains 'Metadata')) {
                    # Filter by OrganizationId when the file declares one and we know the current org.
                    $fileOrgId = if ($parsed.Metadata.PSObject.Properties.Name -contains 'OrganizationId') {
                        [string]$parsed.Metadata.OrganizationId
                    }
                    else { $null }
                    $orgMatch = ($null -eq $effectiveOrgId) -or
                    ($null -eq $fileOrgId) -or
                    ($fileOrgId -eq $effectiveOrgId)
                    if ($orgMatch) { $parsed }
                }
            }
            catch { }
        } |
        Select-Object -Last 10
    )

    $outputAnalysis = $analysis
    if ($Framework -and $Framework.Count -gt 0) {
        $filteredFindings = @($analysis.Findings | Where-Object {
                $f = $_
                @($f.Frameworks) | Where-Object { $Framework -contains $_ }
            })
        $filteredScores = @($analysis.Scores | Where-Object { $_.Framework -eq 'All' -or $Framework -contains $_.Framework })
        $outputAnalysis = [pscustomobject]@{
            Metadata = $analysis.Metadata
            Scores   = $filteredScores
            Findings = $filteredFindings
        }
        Write-Verbose ('Framework filter applied to output: {0} finding(s) included in report and remediation.' -f $filteredFindings.Count)
    }

    New-EDCADirectoryIfMissing -Path $resolvedOutputPath
    Write-Verbose 'Starting HTML report generation phase.'
    $reportOut  = Join-Path -Path $resolvedOutputPath -ChildPath ('report_{0}.html' -f $analysisStamp)
    $reportPath = New-EDCAHtmlReport -CollectionData $collectionData -AnalysisData $outputAnalysis -HistoryData $historyData -OutputFile $reportOut
    Write-EDCALog -Message ('HTML report generated: {0}' -f $reportPath)

    if ($RemediationScript) {
        Write-Verbose 'Starting remediation script generation phase.'
        $remediationOut  = Join-Path -Path $resolvedOutputPath -ChildPath ('remediation_{0}.ps1' -f $analysisStamp)
        $remediationPath = New-EDCARemediationScript -AnalysisData $outputAnalysis -OutputFile $remediationOut
        Write-EDCALog -Message ('Remediation script generated: {0}' -f $remediationPath)
    }

    Write-EDCALog -Message 'Execution completed.'
}