Invoke-M365Assessment.ps1

<#
.SYNOPSIS
    Runs a comprehensive read-only Microsoft 365 environment assessment.
.DESCRIPTION
    Orchestrates all M365 assessment collector scripts to produce a folder of CSV
    reports covering identity, email, security, devices, collaboration, and hybrid
    sync. Each section runs independently — failures in one section do not block
    others. All operations are strictly read-only (Get-* cmdlets only).

    Designed for IT consultants assessing SMB clients (10-500 users) with
    Microsoft-based cloud environments.
.NOTES
    Author: Daren9m
.PARAMETER Section
    One or more assessment sections to run. Valid values: Tenant, Identity,
    Licensing, Email, Intune, Security, Collaboration, Hybrid, PowerBI,
    Inventory, ActiveDirectory, SOC2, ValueOpportunity, All. Defaults to all
    standard sections. Use 'All' to include opt-in sections (Inventory,
    ActiveDirectory, SOC2, ValueOpportunity) in a single value.
.PARAMETER TenantId
    Tenant ID or domain (e.g., 'contoso.onmicrosoft.com').
.PARAMETER OutputFolder
    Root folder for assessment output. A timestamped subfolder is created
    automatically. Defaults to '.\M365-Assessment'.
.PARAMETER SkipConnection
    Use pre-existing service connections instead of connecting automatically.
.PARAMETER ClientId
    Application (client) ID for app-only authentication.
.PARAMETER CertificateThumbprint
    Certificate thumbprint for app-only authentication. For Exchange Online and Purview
    this is Windows-only; on Linux/macOS use -Certificate or -CertificatePath.
.PARAMETER Certificate
    App-only authentication certificate as an X509Certificate2 object. Portable across
    Windows, Linux and macOS -- recommended for non-Windows runs.
.PARAMETER CertificatePath
    Path to a certificate file (.pfx/.p12) for app-only authentication, loaded with
    -CertificatePassword. Portable alternative to -CertificateThumbprint.
.PARAMETER CertificatePassword
    SecureString password protecting the -CertificatePath file, if any.
.PARAMETER ClientSecret
    Client secret for app-only authentication. Less secure than certificate
    auth -- prefer -CertificateThumbprint for production use.
.PARAMETER UserPrincipalName
    User principal name (e.g., 'admin@contoso.onmicrosoft.com') for interactive
    authentication to Exchange Online and Purview. Specifying this can bypass
    Windows Authentication Manager (WAM) broker errors on some systems.
.PARAMETER ManagedIdentity
    Use Azure managed identity authentication. Requires the script to be running
    on an Azure resource with a system-assigned or user-assigned managed identity
    (e.g., Azure VM, Azure Functions, Azure Automation). Purview and Power BI do
    not support managed identity and will fall back with a warning.
.PARAMETER UseDeviceCode
    Use device code authentication flow instead of browser-based interactive auth.
    Displays a code and URL that you can open in any browser profile, which is
    useful on machines with multiple Edge profiles (e.g., corporate + GCC).
    Note: Purview (Security & Compliance) does not support device code and will
    fall back to browser-based or UPN-hint authentication.
.PARAMETER M365Environment
    Target cloud environment for all service connections. Commercial and GCC
    use standard endpoints. GCCHigh and DoD use sovereign cloud endpoints.
    Auto-detected from tenant metadata when not explicitly specified.
.PARAMETER SkipPurview
    Skips all Purview-connected collectors (DLP Policies, Compliance Security
    Config, Purview Retention Config) and their Security and Compliance
    connection. Saves ~46 seconds of latency when Purview data is not needed.
.PARAMETER OpenReport
    Automatically open the generated HTML report in the default browser after
    generation. Works on Windows, macOS, and Linux.
.PARAMETER ReportTheme
    Default visual theme baked into the generated HTML report. Users can switch themes
    via the report UI. Valid values: Neon (default), Console, Saas, HighContrast.
.PARAMETER WhiteLabel
    Strips all M365-Assess and GitHub identity from the report (hides the GitHub
    link and open-source attribution in the React app).
.PARAMETER CompactReport
    Omit cover page, executive summary, and compliance overview from the HTML
    report. Produces a lean, findings-focused report. Automatically set by
    -QuickScan unless overridden with -CompactReport:$false.
.PARAMETER QuickScan
    Run only Critical and High severity checks. Useful for CI/CD pipelines
    and daily monitoring. Collectors with no qualifying checks are skipped
    entirely. The report shows a "Quick Scan Mode" banner and automatically
    sets -CompactReport. Override with -CompactReport:$false to keep the
    full report structure.
.PARAMETER SaveBaseline
    Save the current assessment as a baseline snapshot under the output folder's
    Baselines subfolder. Use with -CompareBaseline on a later run to detect
    policy drift.

    Switch form:
      -SaveBaseline Auto-labels as 'manual-yyyyMMdd-HHmmss'.
      -SaveBaseline -BaselineLabel '<label>' Saves under the supplied label (e.g. 'sprint-end').

    For unattended/scheduled runs that auto-compare to the previous run, prefer
    -AutoBaseline (saves under 'auto-<timestamp>' and reads the most-recent auto
    baseline back automatically).
.PARAMETER BaselineLabel
    Optional custom label for the baseline snapshot. Only takes effect when
    -SaveBaseline is also supplied. Without -SaveBaseline this parameter is ignored.
    The label is sanitized by Export-AssessmentBaseline; non-word characters
    become underscores.
.PARAMETER CompareBaseline
    Label of a previously saved baseline to compare against. Generates a drift
    report highlighting settings that changed since the baseline was captured.
    Version-aware: when the registry version differs from the baseline, only
    shared CheckIDs are compared and schema changes are reported separately.
.PARAMETER AutoBaseline
    Automatically saves a dated snapshot after every run and compares against
    the most recent previous auto-snapshot for this tenant. No label management
    required. Ideal for scheduled assessments and continuous drift tracking.
.PARAMETER ListBaselines
    Lists all saved baselines for the tenant (label, date, registry version,
    check count) and exits without running an assessment. Use -TenantId to
    scope results; omit to list baselines for all tenants.
.PARAMETER IncludeTrend
    Renders the Posture trend section in the HTML report when two or more
    baselines exist for the tenant. Off by default — baselines still auto-save
    for drift comparison, but the trend section appears only when the user
    explicitly opts in to longitudinal posture tracking.
.PARAMETER AssessmentDecisionsPath
    Validated, tenant-scoped JSON sidecar containing accepted risks and manual
    attestations. Raw observations remain unchanged. Export decisions from the
    HTML assessor panel, then pass the file on subsequent runs or regeneration.
.PARAMETER HeadlineFramework
    Framework id(s) that headline the report's Executive Briefing first screen
    (e.g. 'cis-m365-v6', 'cmmc'). Validated against the framework definitions
    discovered in controls/frameworks/*.json; unknown ids abort the run with
    the list of valid ids. When omitted, the report defaults to CIS Microsoft
    365 (cis-m365-v6). Viewers can still switch frameworks inside the report.
.PARAMETER DryRun
    Show a dry-run preview of what the assessment would do (sections,
    services, Graph scopes, check counts) without connecting or collecting
    data. Useful for validating configuration before a real run.
.PARAMETER ConnectionProfile
    Name of a saved connection profile from .m365assess.json. Use
    Save-M365ConnectionProfile to create profiles. The profile provides
    TenantId, ClientId, auth method, and other connection parameters.
.PARAMETER NonInteractive
    Suppresses all interactive prompts for module installation, EXO downgrade,
    and script unblocking. When a required module is missing or incompatible,
    the exact install/fix command is logged and the script exits with an error.
    When an optional module is missing (e.g., MicrosoftPowerBIMgmt), the
    dependent section is skipped with a warning and the assessment continues.
    Use this switch for CI/CD pipelines, scheduled tasks, and headless
    environments. Also triggered automatically when the session is not
    user-interactive ([Environment]::UserInteractive is false).
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com'

    Full assessment with interactive browser auth.
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -Section All

    Full assessment including all opt-in sections (Inventory, ActiveDirectory,
    SOC2, ValueOpportunity).
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -ClientId '00000000-0000-0000-0000-000000000000' -CertificateThumbprint 'ABC123'

    App-only authentication using a certificate. Recommended for automation.
.EXAMPLE
    PS> Invoke-M365Assessment -ManagedIdentity -Section Tenant,Identity,Security

    Runs selected sections using Azure managed identity (no credentials needed).
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.us' -UseDeviceCode

    Device code auth — choose which browser profile to sign in with.
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -QuickScan

    Critical and High checks only. Produces a compact triage report.
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -AutoBaseline

    Runs assessment and auto-saves a dated snapshot. On subsequent runs,
    generates a drift report showing what changed since the last snapshot.
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -ListBaselines

    Lists all saved baseline snapshots for the tenant without running an assessment.
.EXAMPLE
    PS> Invoke-M365Assessment -TenantId 'contoso.onmicrosoft.com' -Section Identity,Email -DryRun

    Dry-run preview: sections, services, Graph scopes, and check counts —
    no connections made, no data collected.
#>

#Requires -Version 7.0

# Self-bootstrap: load dependencies when run directly as a .ps1 (not via Import-Module).
# When dot-sourced by M365-Assess.psm1, InvocationName is '.' and this block is skipped.
if ($MyInvocation.InvocationName -ne '.') {
    Get-ChildItem -Path "$PSScriptRoot\Orchestrator\*.ps1" | ForEach-Object { . $_.FullName }
    . "$PSScriptRoot\Common\SecurityConfigHelper.ps1"
    . "$PSScriptRoot\Common\Resolve-DnsRecord.ps1"
    . "$PSScriptRoot\Common\Resolve-TenantIdentity.ps1"
    . "$PSScriptRoot\Common\Export-M365Remediation.ps1"
    . "$PSScriptRoot\Orchestrator\Compare-M365Baseline.ps1"
    . "$PSScriptRoot\Setup\Grant-M365AssessConsent.ps1"
    . "$PSScriptRoot\Setup\Save-M365ConnectionProfile.ps1"
    . "$PSScriptRoot\Setup\Get-M365ConnectionProfile.ps1"
}

function Invoke-M365Assessment {
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'connectedServices',
    Justification = 'Used by Connect-RequiredService in Orchestrator/ via parent scope')]
param(
    [Parameter()]
    [ValidateSet('Tenant', 'Identity', 'Licensing', 'Email', 'Intune', 'Security', 'Collaboration',
                 'PowerBI', 'Hybrid', 'Inventory', 'ActiveDirectory', 'SOC2', 'ValueOpportunity', 'All')]
    [string[]]$Section = @('Tenant', 'Identity', 'Licensing', 'Email', 'Intune', 'Security', 'Collaboration', 'PowerBI', 'Hybrid'),

    # TenantId: optional in interactive sets; mandatory in app-only sets where the
    # tenant cannot be inferred interactively. Must be listed explicitly in every
    # set — mixing named-set attributes with a bare [Parameter()] (__AllParameterSets)
    # causes parameter-set resolution failures in PowerShell 7.6+.
    [Parameter(ParameterSetName = 'AppOnlyCert',       Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlyCertObject', Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlyCertFile',   Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlySecret',     Mandatory)]
    [Parameter(ParameterSetName = 'Interactive')]
    [Parameter(ParameterSetName = 'DeviceCode')]
    [Parameter(ParameterSetName = 'ManagedIdentity')]
    [Parameter(ParameterSetName = 'SkipConnection')]
    [Parameter(ParameterSetName = 'ConnectionProfile')]
    [string]$TenantId,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$OutputFolder = '.\M365-Assessment',

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

    [Parameter(ParameterSetName = 'AppOnlyCert',       Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlyCertObject', Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlyCertFile',   Mandatory)]
    [Parameter(ParameterSetName = 'AppOnlySecret',     Mandatory)]
    [string]$ClientId,

    [Parameter(ParameterSetName = 'AppOnlyCert', Mandatory)]
    [string]$CertificateThumbprint,

    # Portable app-only cert inputs -- work on Windows, Linux and macOS (unlike a bare
    # thumbprint for Exchange/Purview, which is resolved through the Windows cert store).
    [Parameter(ParameterSetName = 'AppOnlyCertObject', Mandatory)]
    [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

    [Parameter(ParameterSetName = 'AppOnlyCertFile', Mandatory)]
    [string]$CertificatePath,

    [Parameter(ParameterSetName = 'AppOnlyCertFile')]
    [SecureString]$CertificatePassword,

    [Parameter(ParameterSetName = 'AppOnlySecret', Mandatory)]
    [SecureString]$ClientSecret,

    [Parameter()]
    [string]$UserPrincipalName,

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

    [Parameter(ParameterSetName = 'DeviceCode', Mandatory)]
    [switch]$UseDeviceCode,

    [Parameter()]
    [ValidateSet('commercial', 'gcc', 'gcchigh', 'dod')]
    [string]$M365Environment = 'commercial',

    [Parameter()]
    [switch]$SkipPurview,

    [Parameter()]
    [switch]$OpenReport,

    [Parameter()]
    [ValidateSet('Neon', 'Console', 'Light', 'HighContrast')]
    [string]$ReportTheme = 'Neon',

    [Parameter()]
    [ValidateSet('Compact', 'Comfort')]
    [string]$ReportDensity = 'Compact',

    [Parameter()]
    [switch]$WhiteLabel,

    [Parameter()]
    [switch]$CompactReport,

    [Parameter()]
    [switch]$NonInteractive,

    [Parameter()]
    [switch]$QuickScan,

    [Parameter()]
    [switch]$DryRun,

    # Issue #809: -SaveBaseline is now a switch (was [string]). Pass it bare to
    # save under an auto-generated 'manual-<timestamp>' label, OR combine with
    # -BaselineLabel to use a custom label. Breaking change for callers that
    # previously did `-SaveBaseline 'mylabel'` -- migrate to
    # `-SaveBaseline -BaselineLabel 'mylabel'`.
    [Parameter()]
    [switch]$SaveBaseline,

    [Parameter()]
    [string]$BaselineLabel,

    [Parameter()]
    [string]$CompareBaseline,

    [Parameter()]
    [switch]$AutoBaseline,

    [Parameter()]
    [switch]$ListBaselines,

    [Parameter()]
    [switch]$IncludeTrend,

    # D4 #788 -- sanitized evidence package mode
    [Parameter()]
    [switch]$EvidencePackage,

    [Parameter()]
    [switch]$Redact,

    # #963 -- framework id(s) that headline the Executive Briefing first screen.
    # Completer offers the JSON basenames (= framework ids by convention);
    # hard validation against Import-FrameworkDefinitions happens in the body.
    [Parameter()]
    [ArgumentCompleter({
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
        $root = Split-Path -Parent $PSCommandPath
        $fwPath = Join-Path -Path $root -ChildPath 'controls/frameworks'
        if (Test-Path -Path $fwPath) {
            Get-ChildItem -Path $fwPath -Filter '*.json' |
                ForEach-Object { $_.BaseName } |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object {
                    [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
                }
        }
    })]
    [string[]]$HeadlineFramework,

    [Parameter()]
    [string]$AssessmentDecisionsPath,

    [Parameter(ParameterSetName = 'ConnectionProfile', Mandatory)]
    [ArgumentCompleter({
        param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
        $root = Split-Path -Parent $PSCommandPath
        $configPath = Join-Path -Path $root -ChildPath '.m365assess.json'
        if (Test-Path -Path $configPath) {
            try {
                $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json -AsHashtable
                $profiles = if ($config.ContainsKey('profiles')) { $config['profiles'] } else { @{} }
                $profiles.Keys | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
                    [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $profiles[$_]['tenantId'])
                }
            }
            catch { Write-Verbose "Profile completer: $_" }
        }
    })]
    [string]$ConnectionProfile
)

$ErrorActionPreference = 'Stop'

# ------------------------------------------------------------------
# Version — read from module manifest (single source of truth)
# ------------------------------------------------------------------
$projectRoot = if ($PSCommandPath) { Split-Path -Parent $PSCommandPath } else { $PSScriptRoot }
$script:AssessmentVersion = (Import-PowerShellDataFile -Path "$projectRoot/M365-Assess.psd1").ModuleVersion

# #963: fail fast on unknown -HeadlineFramework ids, before any connection work
if ($HeadlineFramework) {
    . (Join-Path -Path $projectRoot -ChildPath 'Common/Import-FrameworkDefinitions.ps1')
    $validHeadlineIds = @((Import-FrameworkDefinitions -FrameworksPath (Join-Path -Path $projectRoot -ChildPath 'controls/frameworks')).frameworkId)
    $unknownHeadline = @($HeadlineFramework | Where-Object { $_ -notin $validHeadlineIds })
    if ($unknownHeadline.Count -gt 0) {
        throw "Unknown -HeadlineFramework id(s): $($unknownHeadline -join ', '). Valid ids: $($validHeadlineIds -join ', ')"
    }
}


# When invoked directly (not via module), load internal dependencies
if (-not (Get-Command -Name Show-InteractiveWizard -ErrorAction SilentlyContinue)) {
    Get-ChildItem -Path (Join-Path $projectRoot 'Orchestrator') -Filter '*.ps1' |
        ForEach-Object { . $_.FullName }
}
# Show-InteractiveWizard -- extracted to Orchestrator/Show-InteractiveWizard.ps1
# Resolve-M365Environment -- extracted to Orchestrator/Resolve-M365Environment.ps1

# ------------------------------------------------------------------
# Detect interactive mode: no connection parameters supplied
# The wizard should launch whenever the user hasn't told us HOW to
# connect (TenantId, SkipConnection, or app-only auth). Passing
# -Section alone should still trigger the wizard for tenant input.
# ------------------------------------------------------------------
$launchWizard = -not $PSBoundParameters.ContainsKey('TenantId') -and
                -not $PSBoundParameters.ContainsKey('SkipConnection') -and
                -not $PSBoundParameters.ContainsKey('ClientId') -and
                -not $PSBoundParameters.ContainsKey('ManagedIdentity') -and
                -not $PSBoundParameters.ContainsKey('ConnectionProfile')

if ($launchWizard -and [Environment]::UserInteractive) {
    try {
        $wizSplat = @{}
        if ($PSBoundParameters.ContainsKey('Section')) {
            $wizSplat['PreSelectedSections'] = $Section
        }
        if ($PSBoundParameters.ContainsKey('OutputFolder')) {
            $wizSplat['PreSelectedOutputFolder'] = $OutputFolder
        }
        $wizardParams = Show-InteractiveWizard @wizSplat
    }
    catch {
        Write-Warning "Interactive wizard failed: $($_.Exception.Message)"
        Write-Host ''
        Write-Host ' Run with parameters instead:' -ForegroundColor Yellow
        Write-Host ' ./Invoke-M365Assessment.ps1 -TenantId "contoso.onmicrosoft.com"' -ForegroundColor Cyan
        Write-Host ''
        Write-Host ' For full usage: Get-Help ./Invoke-M365Assessment.ps1 -Full' -ForegroundColor Gray
        return
    }

    if ($null -eq $wizardParams) {
        return
    }

    # Override script parameters with wizard selections, but preserve
    # any values the user already provided on the command line
    if (-not $PSBoundParameters.ContainsKey('Section')) {
        $Section = $wizardParams['Section']
    }
    if (-not $PSBoundParameters.ContainsKey('OutputFolder')) {
        $OutputFolder = $wizardParams['OutputFolder']
    }

    if ($wizardParams.ContainsKey('TenantId')) {
        $TenantId = $wizardParams['TenantId']
    }
    if ($wizardParams.ContainsKey('SkipConnection')) {
        $SkipConnection = [switch]$true
    }
    if ($wizardParams.ContainsKey('ClientId')) {
        $ClientId = $wizardParams['ClientId']
    }
    if ($wizardParams.ContainsKey('CertificateThumbprint')) {
        $CertificateThumbprint = $wizardParams['CertificateThumbprint']
    }
    if ($wizardParams.ContainsKey('UserPrincipalName')) {
        $UserPrincipalName = $wizardParams['UserPrincipalName']
    }

    # Report options from wizard
    if ($wizardParams.ContainsKey('CompactReport')) {
        $CompactReport = [switch]$true
    }
    if ($wizardParams.ContainsKey('ConnectionProfile') -and -not $PSBoundParameters.ContainsKey('ConnectionProfile')) {
        $ConnectionProfile = $wizardParams['ConnectionProfile']
    }
}

# ------------------------------------------------------------------
# Load connection profile (if specified)
# ------------------------------------------------------------------
if ($ConnectionProfile) {
    $profileHelper = Join-Path -Path $projectRoot -ChildPath 'Setup\Get-M365ConnectionProfile.ps1'
    if (Test-Path -Path $profileHelper) {
        . $profileHelper
        $loadedProfile = Get-M365ConnectionProfile -ProfileName $ConnectionProfile
        if ($loadedProfile) {
            if (-not $TenantId) { $TenantId = $loadedProfile.TenantId }
            if ($loadedProfile.ClientId -and -not $ClientId) { $ClientId = $loadedProfile.ClientId }
            if ($loadedProfile.Thumbprint -and -not $CertificateThumbprint) { $CertificateThumbprint = $loadedProfile.Thumbprint }
            if ($loadedProfile.UPN -and -not $UserPrincipalName) { $UserPrincipalName = $loadedProfile.UPN }
            if ($loadedProfile.Environment -and -not $PSBoundParameters.ContainsKey('M365Environment')) {
                $M365Environment = $loadedProfile.Environment
            }
            if ($loadedProfile.AuthMethod -eq 'DeviceCode' -and -not $UseDeviceCode) { $UseDeviceCode = [switch]$true }
            if ($loadedProfile.AuthMethod -eq 'ManagedIdentity' -and -not $ManagedIdentity) { $ManagedIdentity = [switch]$true }

            # Update last used timestamp
            $saveHelper = Join-Path -Path $projectRoot -ChildPath 'Setup\Save-M365ConnectionProfile.ps1'
            $configPath = Join-Path -Path $projectRoot -ChildPath '.m365assess.json'
            if ((Test-Path -Path $configPath) -and (Test-Path -Path $saveHelper)) {
                try {
                    $config = Get-Content -Path $configPath -Raw | ConvertFrom-Json -AsHashtable
                    if ($config.ContainsKey('profiles') -and $config['profiles'].ContainsKey($ConnectionProfile)) {
                        $config['profiles'][$ConnectionProfile]['lastUsed'] = (Get-Date -Format 'yyyy-MM-dd HH:mm')
                        $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
                    }
                }
                catch { Write-Verbose "Could not update lastUsed timestamp: $_" }
            }

            Write-Host ''
            Write-Host " Connection profile: $ConnectionProfile ($TenantId)" -ForegroundColor Cyan
        }
        else {
            Write-Error "Connection profile '$ConnectionProfile' not found. Use Get-M365ConnectionProfile to list available profiles."
            return
        }
    }
}

# ------------------------------------------------------------------
# Auto-detect saved credentials from .m365assess.json or cert store
# When TenantId is known but no auth params provided, check for saved
# credentials from a previous Setup run. This enables zero-config
# repeat runs: just provide -TenantId and the rest is automatic.
# ------------------------------------------------------------------
if ($TenantId -and -not $ClientId -and -not $CertificateThumbprint -and
    -not $ManagedIdentity -and -not $UseDeviceCode -and -not $SkipConnection -and
    -not $ClientSecret) {

    $autoDetected = $false

    # Strategy 1: Check .m365assess.json config file
    $configPath = Join-Path $projectRoot '.m365assess.json'
    if (Test-Path $configPath) {
        try {
            $savedConfig = Get-Content -Path $configPath -Raw | ConvertFrom-Json -AsHashtable
            if ($savedConfig.ContainsKey($TenantId)) {
                $entry = $savedConfig[$TenantId]
                $savedThumbprint = $entry['thumbprint']
                # Verify the certificate still exists in the user's cert store
                $savedCert = Get-Item "Cert:\CurrentUser\My\$savedThumbprint" -ErrorAction SilentlyContinue
                if ($savedCert) {
                    $ClientId = $entry['clientId']
                    $CertificateThumbprint = $savedThumbprint
                    $autoDetected = $true
                    $appLabel = if ($entry['appName']) { " ($($entry['appName']))" } else { '' }
                    Write-Verbose "Auto-detected saved credentials for $TenantId$appLabel"
                }
                else {
                    Write-Verbose "Saved cert $savedThumbprint for $TenantId not found in cert store -- skipping auto-detect"
                }
            }
        }
        catch {
            Write-Verbose "Could not read .m365assess.json: $_"
        }
    }

    # Strategy 2: Cert store auto-detect (CN=M365-Assess-{TenantId})
    if (-not $autoDetected) {
        $certSubject = "CN=M365-Assess-$TenantId"
        $matchingCerts = @(Get-ChildItem -Path 'Cert:\CurrentUser\My' -ErrorAction SilentlyContinue |
            Where-Object { $_.Subject -eq $certSubject -and $_.NotAfter -gt (Get-Date) } |
            Sort-Object -Property NotAfter -Descending)
        if ($matchingCerts.Count -gt 0) {
            $detectedCert = $matchingCerts[0]
            $CertificateThumbprint = $detectedCert.Thumbprint
            # Try to find the ClientId from the config file or leave it for manual entry
            if ($savedConfig -and $savedConfig.ContainsKey($TenantId)) {
                $ClientId = $savedConfig[$TenantId]['clientId']
                $autoDetected = $true
                Write-Verbose "Auto-detected cert $certSubject (thumbprint: $CertificateThumbprint) with saved ClientId"
            }
            else {
                Write-Verbose "Found cert $certSubject but no saved ClientId -- certificate auth requires -ClientId"
                $CertificateThumbprint = $null  # Reset -- can't use without ClientId
            }
        }
    }
}


# Assessment helpers (8 functions) -- extracted to Orchestrator/AssessmentHelpers.ps1

# Section maps -- extracted to Orchestrator/AssessmentMaps.ps1
$maps = Get-AssessmentMaps
$sectionServiceMap = $maps.SectionServiceMap
$sectionScopeMap   = $maps.SectionScopeMap
$sectionModuleMap  = $maps.SectionModuleMap
$collectorMap      = $maps.CollectorMap
$dnsCollector      = $maps.DnsCollector

# ------------------------------------------------------------------
# Section 'All' — expand shorthand to full section list
# ------------------------------------------------------------------
if ($Section -contains 'All') {
    $Section = @('Tenant','Identity','Licensing','Email','Intune','Security',
                 'Collaboration','PowerBI','Hybrid','Inventory',
                 'ActiveDirectory','SOC2','ValueOpportunity')
}

# ------------------------------------------------------------------
# ListBaselines — display saved snapshots and exit (no assessment)
# ------------------------------------------------------------------
if ($ListBaselines) {
    $baselineRoot = Join-Path -Path $OutputFolder -ChildPath 'Baselines'
    if (-not (Test-Path -Path $baselineRoot)) {
        Write-Host "No baselines found at '$baselineRoot'." -ForegroundColor Yellow
        return
    }
    $manifests = Get-ChildItem -Path $baselineRoot -Recurse -Filter 'manifest.json' |
        Where-Object { -not $TenantId -or $_.FullName -match [regex]::Escape($TenantId) }
    if ($manifests.Count -eq 0) {
        Write-Host "No baselines found$(if ($TenantId) { " for tenant '$TenantId'" })." -ForegroundColor Yellow
        return
    }
    $rows = foreach ($m in $manifests | Sort-Object LastWriteTime -Descending) {
        try { $data = Get-Content -Path $m.FullName -Raw | ConvertFrom-Json } catch { continue }
        [PSCustomObject]@{
            Label             = $data.Label
            SavedAt           = $data.SavedAt
            RegistryVersion   = $data.RegistryVersion
            AssessmentVersion = $data.AssessmentVersion
            CheckCount        = $data.CheckCount
            TenantId          = $data.TenantId
        }
    }
    $rows | Format-Table -AutoSize
    return
}

# ------------------------------------------------------------------
# Auto-detect cloud environment (when not explicitly specified)
# ------------------------------------------------------------------
if ($TenantId -and -not $PSBoundParameters.ContainsKey('M365Environment')) {
    $detectedEnv = Resolve-M365Environment -TenantId $TenantId
    if ($detectedEnv -and $detectedEnv -ne $M365Environment) {
        $envDisplayNames = @{
            'commercial' = 'Commercial'
            'gcc'        = 'GCC'
            'gcchigh'    = 'GCC High'
            'dod'        = 'DoD'
        }
        $M365Environment = $detectedEnv
        Write-Host ''
        Write-Host " Cloud environment detected: $($envDisplayNames[$detectedEnv])" -ForegroundColor Cyan
        if ($detectedEnv -eq 'gcchigh') {
            Write-Warning 'GCC High and DoD share the same pre-authentication endpoint signals. If this is a DoD tenant, re-run with -M365Environment dod.'
        }
    }
}

# ------------------------------------------------------------------
# Create timestamped output folder
# ------------------------------------------------------------------
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'

# Extract domain prefix for folder/file naming (Phase A: from TenantId)
# Handles onmicrosoft domains (extract prefix) and custom domains (extract label before first dot).
# GUIDs are left empty — Phase B resolves them after Graph connects.
$script:domainPrefix = ''
if ($TenantId -match '^([^.]+)\.onmicrosoft\.(com|us)$') {
    $script:domainPrefix = $Matches[1]
}
elseif ($TenantId -match '^([^.]+)\.' -and $TenantId -notmatch '^[0-9a-f]{8}-') {
    $script:domainPrefix = $Matches[1]
}

$folderSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' }
$assessmentFolder = Join-Path -Path $OutputFolder -ChildPath "Assessment_${timestamp}${folderSuffix}"

try {
    $null = New-Item -Path $assessmentFolder -ItemType Directory -Force
}
catch {
    Write-Error "Failed to create output folder '$assessmentFolder': $_"
    return
}

# ------------------------------------------------------------------
# Initialize log file
# ------------------------------------------------------------------
$logFileSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' }
$script:logFileName = "_Assessment-Log${logFileSuffix}.txt"
$script:logFilePath = Join-Path -Path $assessmentFolder -ChildPath $script:logFileName
$logHeaderLines = @(
    ('=' * 80)
    ' M365 Environment Assessment Log'
    " Version: v$script:AssessmentVersion"
    " Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
    " Tenant: $TenantId"
    " Cloud: $M365Environment"
    " Domain: $($script:domainPrefix)"
)
$logHeaderLines += @(
    " Sections: $($Section -join ', ')"
    ('=' * 80)
    ''
)
$logHeader = $logHeaderLines
Set-Content -Path $script:logFilePath -Value ($logHeader -join "`n") -Encoding UTF8
Write-AssessmentLog -Level INFO -Message "Assessment started. Output folder: $assessmentFolder"

# ------------------------------------------------------------------
# Show assessment header
# ------------------------------------------------------------------
Show-AssessmentHeader -TenantName $TenantId -OutputPath $assessmentFolder -LogPath $script:logFilePath -Version $script:AssessmentVersion

# ------------------------------------------------------------------
# Prepare service connections (lazy — connected per-section as needed)
# ------------------------------------------------------------------
$connectedServices = [System.Collections.Generic.HashSet[string]]::new()  # used by Connect-RequiredService via scope
$failedServices = [System.Collections.Generic.HashSet[string]]::new()
$assessmentEvidence = @{} # Fresh evidence for this invocation; shared with connection helper.
$script:resolvedTenantId = $null
$script:resolvedTenantDomain = $null
$script:resolvedTenantDisplayName = $null
$script:graphPermissionsChecked = $false
$script:tenantLicensesResolved = $false

# ------------------------------------------------------------------
# Module compatibility check — Graph SDK and EXO ship conflicting
# versions of Microsoft.Identity.Client (MSAL). Incompatible combos
# cause silent auth failures with no useful error message.
# ------------------------------------------------------------------

# Module compatibility check -- extracted to Orchestrator/Test-ModuleCompatibility.ps1
if (-not $SkipConnection) {
    $modResult = Test-ModuleCompatibility -Section $Section -SectionServiceMap $sectionServiceMap -NonInteractive:$NonInteractive -SkipDLP:$SkipPurview
    if (-not $modResult.Passed) { return }
    $Section = $modResult.Section

    # Pre-compute combined Graph scopes across all selected sections
    # (Graph scopes must be requested at initial connection time)
    $graphScopes = @()
    foreach ($s in $Section) {
        if ($sectionScopeMap.ContainsKey($s)) {
            $graphScopes += $sectionScopeMap[$s]
        }
    }
    $graphScopes = $graphScopes | Select-Object -Unique

    # Resolve Connect-Service script path
    $connectServicePath = Join-Path -Path $projectRoot -ChildPath 'Common\Connect-Service.ps1'
    if (-not (Test-Path -Path $connectServicePath)) {
        Write-Error "Connect-Service.ps1 not found at '$connectServicePath'."
        return
    }
}

# Connect-RequiredService -- extracted to Orchestrator/Connect-RequiredService.ps1

# ------------------------------------------------------------------
# Run collectors
# ------------------------------------------------------------------
$summaryResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$issues = [System.Collections.Generic.List[PSCustomObject]]::new()
$overallStart = Get-Date


# Blocked scripts check -- extracted to Orchestrator/Test-BlockedScripts.ps1
if (-not (Test-BlockedScripts -ProjectRoot $projectRoot -NonInteractive:$NonInteractive)) { return }

# Initialize real-time security check progress display
$progressHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Show-CheckProgress.ps1'
if (Test-Path -Path $progressHelper) {
    . $progressHelper
    $registryHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Import-ControlRegistry.ps1'
    if (Test-Path -Path $registryHelper) {
        . $registryHelper
        $controlsDir = Join-Path -Path $projectRoot -ChildPath 'controls'
        $progressRegistry = Import-ControlRegistry -ControlsPath $controlsDir
        # Exposed globally so dot-sourced collectors can resolve registry.remediation as fallback
        $global:M365AssessRegistry = $progressRegistry
        if ($progressRegistry.Count -gt 1) {
            # When connections are active, initialize progress silently --
            # the console summary is deferred until Connect-RequiredService
            # resolves tenant licenses after the first Graph connection.
            # When connections are skipped, print immediately (no licenses to resolve).
            $progressParams = @{
                ControlRegistry = $progressRegistry
                ActiveSections  = $Section
            }
            if (-not $SkipConnection) { $progressParams['Silent'] = $true }
            if ($QuickScan) { $progressParams['SeverityFilter'] = @('Critical', 'High') }
            Initialize-CheckProgress @progressParams
        }
    } else {
        Write-Warning "Import-ControlRegistry.ps1 not found - progress tracking disabled."
    }
} else {
    Write-Warning "Show-CheckProgress.ps1 not found - progress display disabled."
}

# Load cross-platform DNS resolver (Resolve-DnsName on Windows, dig on macOS/Linux)
$dnsHelper = Join-Path -Path $projectRoot -ChildPath 'Common\Resolve-DnsRecord.ps1'
if (Test-Path -Path $dnsHelper) { . $dnsHelper }

# Optimize section execution order to minimize service reconnections.
# Group all EXO-dependent sections before Purview-dependent sections so
# that running both Inventory and Security avoids EXO→Purview→EXO thrashing.
$sectionOrder = @(
    'Tenant', 'Identity', 'Licensing', 'Email', 'Intune',
    'Inventory',        # EXO-dependent — run before Security's Purview collectors
    'Security',         # Graph → EXO (Defender) → Purview (DLP/Compliance)
    'Collaboration', 'PowerBI', 'Hybrid',
    'ActiveDirectory', 'SOC2',
    'ValueOpportunity'  # Must run last — reads adoption signals from all other sections
)
$Section = $sectionOrder | Where-Object { $_ -in $Section }

# ------------------------------------------------------------------
# DryRun — preview, then exit
# ------------------------------------------------------------------
if ($DryRun) {
    Write-Host ''
    Write-Host ' ── Dry Run Preview ──' -ForegroundColor Cyan
    Write-Host ''
    Write-Host " Tenant: $TenantId" -ForegroundColor White
    Write-Host " Environment: $M365Environment" -ForegroundColor White
    Write-Host " Version: v$script:AssessmentVersion" -ForegroundColor White
    Write-Host " Output: $assessmentFolder" -ForegroundColor White
    if ($QuickScan) { Write-Host ' Mode: QuickScan (Critical + High only)' -ForegroundColor Yellow }
    Write-Host ''

    # Sections and their services
    Write-Host ' Sections:' -ForegroundColor Cyan
    foreach ($s in $Section) {
        $services = if ($sectionServiceMap.ContainsKey($s) -and $sectionServiceMap[$s].Count -gt 0) { $sectionServiceMap[$s] -join ', ' } else { '(none)' }
        $collectorCount = if ($collectorMap.Contains($s)) { $collectorMap[$s].Count } else { 0 }
        $collectorNoun = if ($collectorCount -eq 1) { 'collector' } else { 'collectors' }
        Write-Host " $([char]0x25B8) $s — $collectorCount $collectorNoun — services: $services" -ForegroundColor DarkGray
    }
    Write-Host ''

    # Graph scopes
    if ($graphScopes -and $graphScopes.Count -gt 0) {
        Write-Host " Graph scopes ($($graphScopes.Count)):" -ForegroundColor Cyan
        foreach ($scope in ($graphScopes | Sort-Object)) {
            Write-Host " $scope" -ForegroundColor DarkGray
        }
        Write-Host ''
    }

    # Check counts from progress state
    if ($global:CheckProgressState) {
        $totalChecks = $global:CheckProgressState.Total
        $collectorCounts = $global:CheckProgressState.CollectorCounts
        $labelMap = $global:CheckProgressState.LabelMap
        $checkNoun = if ($totalChecks -eq 1) { 'check' } else { 'checks' }
        Write-Host " Security checks: $totalChecks $checkNoun queued" -ForegroundColor Cyan
        if ($collectorCounts) {
            # Sort by count descending for quick visual scan
            $sorted = $collectorCounts.GetEnumerator() | Sort-Object -Property Value -Descending
            foreach ($entry in $sorted) {
                $label = if ($labelMap -and $labelMap.ContainsKey($entry.Key)) { $labelMap[$entry.Key] } else { $entry.Key }
                Write-Host " $([char]0x25B8) $label — $($entry.Value) checks" -ForegroundColor DarkGray
            }
        }
        Write-Host ''
    }

    Write-Host ' No connections made. No data collected.' -ForegroundColor DarkGray
    Write-Host ' Remove -DryRun to run the assessment.' -ForegroundColor DarkGray
    Write-Host ''

    # Clean up the empty output folder created earlier
    if (Test-Path -Path $assessmentFolder) {
        Remove-Item -Path $assessmentFolder -Recurse -Force -ErrorAction SilentlyContinue
    }
    return
}

foreach ($sectionName in $Section) {
    if (-not $collectorMap.Contains($sectionName)) {
        Write-AssessmentLog -Level WARN -Message "Unknown section '$sectionName' — skipping."
        continue
    }

    $collectors = $collectorMap[$sectionName]

    # Skip Purview collectors (and their Security and Compliance connection overhead) when -SkipPurview is set
    if ($SkipPurview) {
        $purviewCollectors = @($collectors | Where-Object { $_.ContainsKey('RequiredServices') -and $_.RequiredServices -contains 'Purview' })
        if ($purviewCollectors.Count -gt 0) {
            $collectors = @($collectors | Where-Object { -not ($_.ContainsKey('RequiredServices') -and $_.RequiredServices -contains 'Purview') })
            foreach ($skipped in $purviewCollectors) {
                Write-AssessmentLog -Level INFO -Message "Skipped: $($skipped.Label) (-SkipPurview)" -Section $sectionName -Collector $skipped.Label
            }
        }
    }

    Show-SectionHeader -Name $sectionName

    # For sections that require Graph, verify the token is still valid before
    # running collectors. Device code tokens expire mid-run for long assessments.
    # Only check if Graph was already connected in a prior section — on the first
    # Graph section the token cannot have expired yet (Connect-RequiredService runs below).
    if (-not $SkipConnection -and $sectionServiceMap[$sectionName] -contains 'Graph' -and $connectedServices.Contains('Graph')) {
        if (-not (Test-GraphTokenValid)) {
            Write-Warning "Graph token is no longer valid before starting $sectionName. Skipping section — re-run with Interactive or Certificate auth."
            foreach ($collector in $collectors) {
                $summaryResults.Add([PSCustomObject]@{
                    Section   = $sectionName
                    Collector = $collector.Label
                    FileName  = "$($collector.Name).csv"
                    Status    = 'Skipped'
                    Items     = 0
                    Duration  = '00:00'
                    Error     = 'Graph token expired'
                })
                Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage 'Graph token expired'
                Write-AssessmentLog -Level WARN -Message "Skipped: $($collector.Label) — Graph token expired" -Section $sectionName -Collector $collector.Label
            }
            continue
        }
    }

    # Connect to services: use per-collector RequiredServices if defined,
    # otherwise connect all section-level services up front.
    # If the section is MIXED (some collectors have RequiredServices, others do not),
    # connect section-level services upfront so un-annotated collectors are never
    # dispatched without a connection. Per-collector Connect-RequiredService calls
    # below are idempotent and will no-op if already connected.
    $hasPerCollectorRequirements = ($collectors | Where-Object { $_.ContainsKey('RequiredServices') }).Count -gt 0
    $hasMixedRequirements        = $hasPerCollectorRequirements -and ($collectors | Where-Object { -not $_.ContainsKey('RequiredServices') }).Count -gt 0
    if (-not $SkipConnection -and (-not $hasPerCollectorRequirements -or $hasMixedRequirements)) {
        $sectionServices = $sectionServiceMap[$sectionName]
        Connect-RequiredService -Services $sectionServices -SectionName $sectionName
    }

    # Check if ALL section services failed — skip entire section if so
    $sectionServices = $sectionServiceMap[$sectionName]
    $unavailableServices = @($sectionServices | Where-Object { $failedServices.Contains($_) })
    $allSectionServicesFailed = ($unavailableServices.Count -eq $sectionServices.Count -and $sectionServices.Count -gt 0 -and -not $SkipConnection)

    if ($allSectionServicesFailed) {
        $skipReason = "$($unavailableServices -join ', ') not connected"
        foreach ($collector in $collectors) {
            $summaryResults.Add([PSCustomObject]@{
                Section   = $sectionName
                Collector = $collector.Label
                FileName  = "$($collector.Name).csv"
                Status    = 'Skipped'
                Items     = 0
                Duration  = '00:00'
                Error     = $skipReason
            })
            Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason
            Write-AssessmentLog -Level WARN -Message "Skipped: $($collector.Label) — $skipReason" -Section $sectionName -Collector $collector.Label
        }

        # Also skip DNS collector if Email section services are unavailable
        if ($sectionName -eq 'Email') {
            $summaryResults.Add([PSCustomObject]@{
                Section   = 'Email'
                Collector = $dnsCollector.Label
                FileName  = "$($dnsCollector.Name).csv"
                Status    = 'Skipped'
                Items     = 0
                Duration  = '00:00'
                Error     = $skipReason
            })
            Show-CollectorResult -Label $dnsCollector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason
            Write-AssessmentLog -Level WARN -Message "Skipped: $($dnsCollector.Label) — $skipReason" -Section 'Email' -Collector $dnsCollector.Label
        }
        continue
    }

    # Import Graph submodules required by this section's collectors
    if ($sectionModuleMap.ContainsKey($sectionName)) {
        foreach ($mod in $sectionModuleMap[$sectionName]) {
            Import-Module -Name $mod -ErrorAction SilentlyContinue
        }
    }

    foreach ($collector in $collectors) {
        # Per-collector service requirement: connect just-in-time, then check
        if ($collector.ContainsKey('RequiredServices') -and -not $SkipConnection) {
            Connect-RequiredService -Services $collector.RequiredServices -SectionName $sectionName

            $collectorUnavailable = @($collector.RequiredServices | Where-Object { $failedServices.Contains($_) })
            if ($collectorUnavailable.Count -gt 0) {
                $skipReason = "$($collectorUnavailable -join ', ') not connected"
                $summaryResults.Add([PSCustomObject]@{
                    Section   = $sectionName
                    Collector = $collector.Label
                    FileName  = "$($collector.Name).csv"
                    Status    = 'Skipped'
                    Items     = 0
                    Duration  = '00:00'
                    Error     = $skipReason
                })
                Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage $skipReason
                Write-AssessmentLog -Level WARN -Message "Skipped: $($collector.Label) — $skipReason" -Section $sectionName -Collector $collector.Label
                continue
            }
        }

        $collectorStart = Get-Date
        $scriptPath = Join-Path -Path $projectRoot -ChildPath $collector.Script
        $csvPath = Join-Path -Path $assessmentFolder -ChildPath "$($collector.Name).csv"
        $status = 'Failed'
        $itemCount = 0
        $errorMessage = ''

        Write-AssessmentLog -Level INFO -Message "Running: $($collector.Label)" -Section $sectionName -Collector $collector.Label
        if (Get-Command -Name Update-ProgressStatus -ErrorAction SilentlyContinue) {
            Update-ProgressStatus -Message "Running $($collector.Label)..."
        }

        try {
            if (-not (Test-Path -Path $scriptPath)) {
                throw "Script not found: $scriptPath"
            }

            # Build parameters for the collector
            $collectorParams = @{}
            if ($collector.ContainsKey('Params')) {
                $collectorParams = $collector.Params.Clone()
            }

            # Reuse audit evidence collected before the Exchange-to-Purview transition.
            if ($collector.Name -eq '19b-Compliance-Security-Config' -and $assessmentEvidence.ContainsKey('ExchangeAudit')) {
                $collectorParams['AuditConfigEvidence'] = $assessmentEvidence.ExchangeAudit
            }

            # Value Opportunity collectors need project root + assessment folder paths
            if ($collector.ContainsKey('PassProjectContext') -and $collector.PassProjectContext) {
                $collectorParams['ProjectRoot'] = $projectRoot
                $collectorParams['AssessmentFolder'] = $assessmentFolder
            }

            # Special handling for Secure Score (two outputs)
            if ($collector.ContainsKey('HasSecondary') -and $collector.HasSecondary) {
                $secondaryCsvPath = Join-Path -Path $assessmentFolder -ChildPath "$($collector.SecondaryName).csv"
                $collectorParams['ImprovementActionsPath'] = $secondaryCsvPath
            }

            # Child-process collectors (e.g., PowerBI) run in an isolated pwsh
            # process to avoid .NET assembly version conflicts. The PowerBI module
            # ships Microsoft.Identity.Client 4.64 while Microsoft.Graph loads 4.78;
            # a child process gets its own AppDomain and avoids the clash.
            if ($collector.ContainsKey('IsChildProcess') -and $collector.IsChildProcess) {
                # MicrosoftPowerBIMgmt device code auth hangs on Linux/macOS.
                # Service principal auth works cross-platform; interactive auth requires Windows.
                $hasSp = $ClientId -and ($CertificateThumbprint -or $ClientSecret)
                if (-not $IsWindows -and -not $hasSp) {
                    $skipMsg = 'Power BI collector skipped: MicrosoftPowerBIMgmt interactive auth is not supported on non-Windows platforms. Re-run on Windows, or supply -ClientId with -ClientSecret / -CertificateThumbprint to use service principal auth.'
                    Write-Warning $skipMsg
                    Write-AssessmentLog -Level WARN -Message $skipMsg -Section $sectionName -Collector $collector.Label
                    $summaryResults.Add([PSCustomObject]@{
                        Section   = $sectionName
                        Collector = $collector.Label
                        FileName  = "$($collector.Name).csv"
                        Status    = 'Skipped'
                        Items     = 0
                        Duration  = '00:00'
                        Error     = 'Platform not supported for interactive auth'
                    })
                    Show-CollectorResult -Label $collector.Label -Status 'Skipped' -Items 0 -DurationSeconds 0 -ErrorMessage 'Platform not supported for interactive auth'
                    continue
                }
                Write-Host " Connecting to Power BI..." -ForegroundColor Yellow
                Write-Host " Running in isolated process (assembly compatibility)..." -ForegroundColor Gray
                Write-AssessmentLog -Level INFO -Message "Running $($collector.Label) in child process to avoid MSAL assembly conflict" -Section $sectionName -Collector $collector.Label
                $childCsvPath = $csvPath
                # Build a self-contained script that connects + runs the collector
                $scriptLines = [System.Collections.Generic.List[string]]::new()
                $scriptLines.Add('$ErrorActionPreference = "Stop"')
                # Call Connect-Service.ps1 directly (do NOT dot-source -- it has a
                # Mandatory param block that would prompt for input).
                $scriptLines.Add("`$connectParams = @{ Service = 'PowerBI' }")
                if ($TenantId)              { $scriptLines.Add("`$connectParams['TenantId'] = '$TenantId'") }
                # Thread the cloud through to the child so Connect-Service can route
                # Power BI to the sovereign environment (gcchigh->USGovHigh, etc.).
                # Without this the child defaults to commercial and the WAM broker
                # uses the commercial redirect URI -> IncorrectConfiguration (#943).
                if ($M365Environment -and $M365Environment -ne 'commercial') {
                    $scriptLines.Add("`$connectParams['M365Environment'] = '$M365Environment'")
                }
                if ($ClientId -and $CertificateThumbprint) {
                    $scriptLines.Add("`$connectParams['ClientId'] = '$ClientId'")
                    $scriptLines.Add("`$connectParams['CertificateThumbprint'] = '$CertificateThumbprint'")
                }
                elseif ($ClientId -and $ClientSecret) {
                    # Convert SecureString to plain text for child process serialization
                    $plainSecret = [System.Net.NetworkCredential]::new('', $ClientSecret).Password
                    $scriptLines.Add("`$connectParams['ClientId'] = '$ClientId'")
                    $scriptLines.Add("`$connectParams['ClientSecret'] = (ConvertTo-SecureString '$plainSecret' -AsPlainText -Force)")
                }
                # On macOS/Linux, interactive browser auth hangs silently for Power BI.
                # Force device code flow unless a service principal is configured.
                if ($UseDeviceCode) {
                    $scriptLines.Add('$connectParams["UseDeviceCode"] = $true')
                }
                elseif (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret))) {
                    $scriptLines.Add('$connectParams["UseDeviceCode"] = $true')
                    Write-Host " Using device code auth (interactive browser not supported on this platform)" -ForegroundColor Yellow
                }
                $scriptLines.Add("try { & '$connectServicePath' @connectParams } catch { Write-Error `$_; exit 1 }")
                $scriptLines.Add("& '$scriptPath' -OutputPath '$childCsvPath'")

                $childScriptFile = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "m365assess_pbi_$([System.IO.Path]::GetRandomFileName()).ps1"
                $childOutputFile = [System.IO.Path]::ChangeExtension($childScriptFile, '.log')
                $childErrFile    = [System.IO.Path]::ChangeExtension($childScriptFile, '.err')
                Set-Content -Path $childScriptFile -Value ($scriptLines -join "`n") -Encoding UTF8
                $childTimeoutSec = if ($UseDeviceCode -or (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret)))) { 120 } else { 90 }
                $childNeedsConsole = $UseDeviceCode -or (-not $IsWindows -and -not ($ClientId -and ($CertificateThumbprint -or $ClientSecret)))
                try {
                    if ($childNeedsConsole) {
                        # Device code auth: don't redirect output so the user sees the
                        # login prompt. Use a background job with timeout instead.
                        $childProc = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile', '-File', $childScriptFile `
                            -NoNewWindow -PassThru
                    }
                    else {
                        # Service principal / Windows interactive: redirect output for
                        # clean console and capture errors.
                        $childProc = Start-Process -FilePath 'pwsh' -ArgumentList '-NoProfile', '-File', $childScriptFile `
                            -RedirectStandardOutput $childOutputFile -RedirectStandardError $childErrFile `
                            -NoNewWindow -PassThru
                    }

                    $exited = $childProc.WaitForExit($childTimeoutSec * 1000)

                    if (-not $exited) {
                        $childProc.Kill()
                        $childProc.WaitForExit(5000)
                        throw "Child process timed out after ${childTimeoutSec}s — Power BI connection or API is unresponsive. Verify the account has Power BI Service Administrator role. The assessment will continue without Power BI data."
                    }

                    # Read captured output for warnings/errors (only when redirected)
                    if (-not $childNeedsConsole) {
                        $childStderrContent = if (Test-Path $childErrFile) { Get-Content -Path $childErrFile -Raw } else { '' }
                        if ($childStderrContent) {
                            Write-AssessmentLog -Level WARN -Message "Child process stderr: $($childStderrContent.Trim())" -Section $sectionName -Collector $collector.Label
                        }
                    }

                    if ($childProc.ExitCode -ne 0) {
                        $errDetail = if (-not $childNeedsConsole -and (Test-Path $childErrFile)) { (Get-Content -Path $childErrFile -Raw).Trim() } else { "Exit code $($childProc.ExitCode)" }
                        throw "Child process failed: $errDetail"
                    }

                    if (Test-Path -Path $childCsvPath) {
                        $results = @(Import-Csv -Path $childCsvPath)
                        $itemCount = $results.Count
                        $status = 'Complete'
                    }
                    else {
                        throw "Child process completed but CSV output not found at $childCsvPath"
                    }
                }
                finally {
                    Remove-Item -Path $childScriptFile -ErrorAction SilentlyContinue
                    Remove-Item -Path $childOutputFile -ErrorAction SilentlyContinue
                    Remove-Item -Path $childErrFile -ErrorAction SilentlyContinue
                }

                # Skip normal in-process execution
                $collectorDuration = ((Get-Date) - $collectorStart).TotalSeconds
                Show-CollectorResult -Label $collector.Label -Status $status -Items $itemCount -DurationSeconds $collectorDuration -ErrorMessage $errorMessage
                $summaryResults.Add([PSCustomObject]@{
                    Section   = $sectionName
                    Collector = $collector.Label
                    FileName  = "$($collector.Name).csv"
                    Status    = $status
                    Items     = $itemCount
                    Duration  = '{0:mm\:ss}' -f [timespan]::FromSeconds($collectorDuration)
                    Error     = $errorMessage
                })
                Write-AssessmentLog -Level INFO -Message "Completed: $($collector.Label) -- $status, $itemCount items, $([math]::Round($collectorDuration, 1))s" -Section $sectionName -Collector $collector.Label
                continue
            }

            # Capture warnings (3>&1) so they go to log instead of console.
            # Suppress error stream (2>$null) to prevent Graph SDK cmdlets from
            # dumping raw API errors to console; terminating errors still propagate
            # to the catch block below via the exception mechanism.
            $rawOutput = & $scriptPath @collectorParams 3>&1 2>$null
            $capturedWarnings = @($rawOutput | Where-Object { $_ -is [System.Management.Automation.WarningRecord] })
            $results = @($rawOutput | Where-Object { $null -ne $_ -and $_ -isnot [System.Management.Automation.WarningRecord] })

            # Log collection failures consistently, including non-permission HTTP errors.
            $hasPermissionWarning = $false
            foreach ($w in $capturedWarnings) {
                Write-AssessmentLog -Level WARN -Message $w.Message -Section $sectionName -Collector $collector.Label
                if ($w.Message -match '401|403|Unauthorized|Forbidden|permission|consent') { $hasPermissionWarning = $true }
                $collectionIssue = New-CollectionIssue -Message $w.Message -Section $sectionName -Collector $collector.Label
                if ($collectionIssue) { $issues.Add($collectionIssue) }
            }

            $collectionWarnings = @($capturedWarnings | Where-Object { $_.Message -match 'GraphCollectionIncomplete|Could not|Unable to|Cannot index|401|403|Forbidden|permission|consent' })
            if ($collectionWarnings.Count) { $errorMessage = ($collectionWarnings.Message -join '; ') }
            if ($null -ne $results -and @($results).Count -gt 0) {
                $itemCount = Export-AssessmentCsv -Path $csvPath -Data @($results) -Label $collector.Label
                $status = if ($collectionWarnings.Count) { 'Partial' } else { 'Complete' }
            }
            else {
                $itemCount = 0
                if ($hasPermissionWarning -or $collectionWarnings.Count) {
                    $status = 'Failed'
                    $errorMessage = $collectionWarnings[0].Message
                    Write-AssessmentLog -Level ERROR -Message "Collector returned no data after a collection error" `
                        -Section $sectionName -Collector $collector.Label -Detail $errorMessage
                }
                else {
                    $status = 'Complete'
                    Write-AssessmentLog -Level INFO -Message "No data returned" -Section $sectionName -Collector $collector.Label
                }
            }
        }
        catch {
            $errorMessage = $_.Exception.Message
            if (-not $errorMessage) { $errorMessage = $_.Exception.ToString() }
            if ($errorMessage -match '403|Forbidden|Insufficient privileges') {
                $status = 'Skipped'
                Write-AssessmentLog -Level WARN -Message "Insufficient permissions" -Section $sectionName -Collector $collector.Label -Detail $errorMessage
                $issues.Add([PSCustomObject]@{
                    Severity     = 'WARNING'
                    Section      = $sectionName
                    Collector    = $collector.Label
                    Description  = 'Insufficient permissions'
                    ErrorMessage = $errorMessage
                    Action       = Get-RecommendedAction -ErrorMessage $errorMessage
                })
            }
            elseif ($errorMessage -match 'not found|not installed|not connected') {
                $status = 'Skipped'
                Write-AssessmentLog -Level WARN -Message "Prerequisite not met" -Section $sectionName -Collector $collector.Label -Detail $errorMessage
                $issues.Add([PSCustomObject]@{
                    Severity     = 'WARNING'
                    Section      = $sectionName
                    Collector    = $collector.Label
                    Description  = 'Prerequisite not met'
                    ErrorMessage = $errorMessage
                    Action       = Get-RecommendedAction -ErrorMessage $errorMessage
                })
            }
            else {
                $status = 'Failed'
                Write-AssessmentLog -Level ERROR -Message "Collector failed" -Section $sectionName -Collector $collector.Label -Detail $_.Exception.ToString()
                $issues.Add([PSCustomObject]@{
                    Severity     = 'ERROR'
                    Section      = $sectionName
                    Collector    = $collector.Label
                    Description  = 'Collector error'
                    ErrorMessage = $errorMessage
                    Action       = Get-RecommendedAction -ErrorMessage $errorMessage
                })
            }
        }

        $collectorEnd = Get-Date
        $duration = $collectorEnd - $collectorStart

        $summaryResults.Add([PSCustomObject]@{
            Section   = $sectionName
            Collector = $collector.Label
            FileName  = "$($collector.Name).csv"
            Status    = $status
            Items     = $itemCount
            Duration  = '{0:mm\:ss}' -f $duration
            Error     = $errorMessage
        })

        Show-CollectorResult -Label $collector.Label -Status $status -Items $itemCount -DurationSeconds $duration.TotalSeconds -ErrorMessage $errorMessage
        Write-AssessmentLog -Level INFO -Message "Completed: $($collector.Label) — $status, $itemCount items, $($duration.TotalSeconds.ToString('F1'))s" -Section $sectionName -Collector $collector.Label
    }

    # DNS Authentication: deferred until after all sections complete
    if ($sectionName -eq 'Email') {
        $script:runDnsAuthentication = $true
        # Cache accepted domains and DKIM data for deferred DNS checks (avoids EXO session timeout)
        if (-not $SkipConnection) {
            try {
                $script:cachedAcceptedDomains = @(Get-AcceptedDomain -ErrorAction Stop)
                Write-AssessmentLog -Level INFO -Message "Cached $($script:cachedAcceptedDomains.Count) accepted domain(s) for deferred DNS" -Section 'Email'
            }
            catch {
                Write-AssessmentLog -Level WARN -Message "Could not cache accepted domains: $($_.Exception.Message)" -Section 'Email'
            }
            try {
                $script:cachedDkimConfigs = @(Get-DkimSigningConfig -ErrorAction Stop)
                Write-AssessmentLog -Level INFO -Message "Cached $($script:cachedDkimConfigs.Count) DKIM config(s) for deferred DNS" -Section 'Email'
            }
            catch {
                Write-Verbose "Could not cache DKIM configs: $($_.Exception.Message)"
            }
        }
    }
}


# ------------------------------------------------------------------
# Deferred DNS checks (runs after all sections, uses prefetch cache)
# ------------------------------------------------------------------

# Deferred DNS checks -- extracted to Orchestrator/Invoke-DnsAuthentication.ps1
if ($script:runDnsAuthentication) {
    Invoke-DnsAuthentication -AssessmentFolder $assessmentFolder -ProjectRoot $projectRoot -SummaryResults $summaryResults -Issues $issues -DnsCollector $dnsCollector
}
# ------------------------------------------------------------------
# Export assessment summary
# ------------------------------------------------------------------
$overallEnd = Get-Date
$overallDuration = $overallEnd - $overallStart

$summarySuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' }
$summaryCsvPath = Join-Path -Path $assessmentFolder -ChildPath "_Assessment-Summary${summarySuffix}.csv"
# Issue #867: prepend a comment header row so the version + timestamp travel
# with the CSV. Lines starting with '#' are treated as comments by sensible
# CSV parsers (pandas read_csv comment='#', PowerShell Import-Csv trips on
# them but 99% of actual consumers use Excel/Python/Power BI which handle it).
$summaryHeader = "# M365-Assess v$($script:AssessmentVersion) -- generated $((Get-Date).ToUniversalTime().ToString('o'))"
$summaryCsvBody = $summaryResults | ConvertTo-Csv -NoTypeInformation
Set-Content -Path $summaryCsvPath -Value (@($summaryHeader) + @($summaryCsvBody)) -Encoding UTF8

# ------------------------------------------------------------------
# Export issue report (if any issues exist)
# ------------------------------------------------------------------
if ($issues.Count -gt 0) {
    $issueFileSuffix = if ($script:domainPrefix) { "_$($script:domainPrefix)" } else { '' }
    $script:issueFileName = "_Assessment-Issues${issueFileSuffix}.log"
    $issueReportPath = Join-Path -Path $assessmentFolder -ChildPath $script:issueFileName
    Export-IssueReport -Path $issueReportPath -Issues @($issues) -TenantName $TenantId -OutputPath $assessmentFolder -Version $script:AssessmentVersion
    Write-AssessmentLog -Level INFO -Message "Issue report exported: $issueReportPath ($($issues.Count) issues)"
}

Write-AssessmentLog -Level INFO -Message "Assessment complete. Duration: $($overallDuration.ToString('mm\:ss')). Summary CSV: $summaryCsvPath"

# ------------------------------------------------------------------
# Baseline: save and/or compare
# ------------------------------------------------------------------
$driftReport         = @()
if ($AssessmentDecisionsPath) {
    . (Join-Path -Path $PSScriptRoot -ChildPath 'Common/AssessmentDecisions.ps1')
    $decisionTenantPath = Join-Path -Path $assessmentFolder -ChildPath '01-Tenant-Info.csv'
    $decisionTenant = if (Test-Path -LiteralPath $decisionTenantPath) { @(Import-Csv -LiteralPath $decisionTenantPath)[0].TenantId } else { '' }
    $decisionDocument = Import-AssessmentDecisions -Path $AssessmentDecisionsPath -TenantId $decisionTenant
    ConvertTo-Json -InputObject $decisionDocument -Depth 10 | Set-Content -LiteralPath (Join-Path -Path $assessmentFolder -ChildPath '_Assessment-Decisions.json') -Encoding utf8
}
$driftBaselineLabel  = ''
$driftBaselineTimestamp = ''

# C1 #780: resolve canonical tenant identity once for all baseline operations.
# GUID becomes the folder key; the rest enriches the manifest.
$tenantIdentity = Resolve-TenantIdentity -TenantIdInput $TenantId -Environment $M365Environment

if ($SaveBaseline) {
    # Issue #809: -SaveBaseline is a switch; -BaselineLabel supplies an optional
    # custom label. Without -BaselineLabel, auto-generate 'manual-<timestamp>'.
    $resolvedLabel = if ($BaselineLabel) {
        $BaselineLabel
    } else {
        "manual-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    }
    Write-AssessmentLog -Level INFO -Message "Saving baseline '$resolvedLabel'..."
    $savedBaselineDir = Export-AssessmentBaseline `
        -AssessmentFolder $assessmentFolder `
        -OutputFolder $OutputFolder `
        -Label $resolvedLabel `
        -TenantId $TenantId `
        -TenantGuid $tenantIdentity.Guid `
        -DisplayName $tenantIdentity.DisplayName `
        -PrimaryDomain $tenantIdentity.PrimaryDomain `
        -Environment $tenantIdentity.Environment `
        -Sections @($sections | ForEach-Object { $_ }) `
        -Version $script:AssessmentVersion `
        -RegistryVersion (Get-RegistryVersion -ProjectRoot $projectRoot)
    Write-AssessmentLog -Level INFO -Message "Baseline saved: $savedBaselineDir"
}

if ($CompareBaseline) {
    $baselineFolder = Resolve-BaselineFolder `
        -OutputFolder $OutputFolder `
        -Label $CompareBaseline `
        -TenantGuid $tenantIdentity.Guid `
        -TenantId $TenantId
    if (Test-Path -Path $baselineFolder -PathType Container) {
        Write-AssessmentLog -Level INFO -Message "Comparing against baseline '$CompareBaseline'..."
        $driftReport = Compare-AssessmentBaseline `
            -AssessmentFolder $assessmentFolder `
            -BaselineFolder $baselineFolder `
            -RegistryVersion (Get-RegistryVersion -ProjectRoot $projectRoot)
        $driftBaselineLabel = $CompareBaseline
        $metaPath = Join-Path -Path $baselineFolder -ChildPath 'manifest.json'
        if (Test-Path -Path $metaPath) {
            try {
                $meta = Get-Content -Path $metaPath -Raw | ConvertFrom-Json
                $driftBaselineTimestamp = $meta.SavedAt
            }
            catch { Write-Verbose "Drift: could not read baseline manifest: $_" }
        }
        Write-AssessmentLog -Level INFO -Message "Drift analysis: $($driftReport.Count) changes detected vs baseline '$CompareBaseline'"
    }
    else {
        Write-Warning "Baseline '$CompareBaseline' not found at '$baselineFolder'. Skipping drift analysis."
    }
}

# ------------------------------------------------------------------
# AutoBaseline — save dated snapshot and compare to previous auto-*
# ------------------------------------------------------------------
if ($AutoBaseline) {
    $autoLabel = "auto_$(Get-Date -Format 'yyyy-MM-ddTHH-mm-ss')"
    # C1 #780: prefer GUID-keyed folder names; fall back to legacy
    # TenantId-based regex for finding pre-v2.9.0 auto baselines.
    $folderSuffix = if ($tenantIdentity.Guid) { $tenantIdentity.Guid -replace '[^\w\-]', '' } else { $TenantId -replace '[^\w\.\-]', '_' }
    $legacySuffix = $TenantId -replace '[^\w\.\-]', '_'
    $sections = @($Section | ForEach-Object { $_ })
    Export-AssessmentBaseline `
        -AssessmentFolder $assessmentFolder `
        -OutputFolder $OutputFolder `
        -TenantId $TenantId `
        -TenantGuid $tenantIdentity.Guid `
        -DisplayName $tenantIdentity.DisplayName `
        -PrimaryDomain $tenantIdentity.PrimaryDomain `
        -Environment $tenantIdentity.Environment `
        -Label $autoLabel `
        -Sections $sections `
        -Version $script:AssessmentVersion `
        -RegistryVersion (Get-RegistryVersion -ProjectRoot $projectRoot)
    Write-AssessmentLog -Level INFO -Message "AutoBaseline saved: $autoLabel"

    # Compare to most recent previous auto-snapshot for this tenant. Search
    # the canonical GUID-keyed names first, then legacy TenantId names.
    $autoPattern = "^auto_.*_(?:${folderSuffix}|${legacySuffix})$"
    $prevAuto = Get-ChildItem -Path (Join-Path $OutputFolder 'Baselines') -Directory -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -match $autoPattern } |
        Sort-Object LastWriteTime -Descending |
        Select-Object -Skip 1 -First 1
    if ($prevAuto) {
        Write-AssessmentLog -Level INFO -Message "AutoBaseline: comparing to $($prevAuto.Name)..."
        $driftReport = Compare-AssessmentBaseline `
            -AssessmentFolder $assessmentFolder `
            -BaselineFolder $prevAuto.FullName `
            -RegistryVersion (Get-RegistryVersion -ProjectRoot $projectRoot)
        # Strip either suffix off the label for display
        $driftBaselineLabel = $prevAuto.Name -replace "_(?:${folderSuffix}|${legacySuffix})$", ''
        try {
            $meta = Get-Content -Path (Join-Path $prevAuto.FullName 'manifest.json') -Raw | ConvertFrom-Json
            $driftBaselineTimestamp = $meta.SavedAt
        }
        catch { Write-Verbose "AutoBaseline: could not read previous manifest: $_" }
        Write-AssessmentLog -Level INFO -Message "AutoBaseline drift: $($driftReport.Count) changes vs $($prevAuto.Name)"
    }
}

# ------------------------------------------------------------------
# Generate HTML report
# ------------------------------------------------------------------
$reportScriptPath = Join-Path -Path $projectRoot -ChildPath 'Common\Export-AssessmentReport.ps1'
if (Test-Path -Path $reportScriptPath) {
    try {
        # QuickScan: default to compact report unless the user explicitly overrode it
        if ($QuickScan -and -not $PSBoundParameters.ContainsKey('CompactReport')) {
            $CompactReport = $true
        }

        $reportParams = @{
            AssessmentFolder = $assessmentFolder
        }
        if ($script:domainPrefix) { $reportParams['TenantName'] = $script:domainPrefix }
        elseif ($TenantId)        { $reportParams['TenantName'] = $TenantId }
        $reportParams['ReportTheme']   = $ReportTheme
        $reportParams['ReportDensity'] = $ReportDensity
        if ($WhiteLabel)        { $reportParams['WhiteLabel']        = $true }
        if ($CompactReport)     { $reportParams['CompactReport']     = $true }
        if ($OpenReport)        { $reportParams['OpenReport']        = $true }
        if ($QuickScan)         { $reportParams['QuickScan']         = $true }
        if ($IncludeTrend)      { $reportParams['IncludeTrend']      = $true }
        if ($HeadlineFramework) { $reportParams['HeadlineFramework'] = $HeadlineFramework }
        if ($driftReport.Count -gt 0 -or $driftBaselineLabel) {
            $reportParams['DriftReport']            = $driftReport
            $reportParams['DriftBaselineLabel']     = $driftBaselineLabel
            $reportParams['DriftBaselineTimestamp'] = $driftBaselineTimestamp
        }

        $reportOutput = & $reportScriptPath @reportParams
        foreach ($line in $reportOutput) {
            Write-AssessmentLog -Level INFO -Message $line
        }
    }
    catch {
        # Surface report-generation failures to BOTH the log file AND the console.
        # This block exists so a partial-data run still leaves the per-collector
        # CSVs on disk, but a SILENT failure here means consultants run a 5-minute
        # assessment and never find out the report didn't generate. Show it.
        $msg = "HTML report generation failed: $($_.Exception.Message)"
        Write-AssessmentLog -Level WARN -Message $msg
        Write-Warning $msg
        Write-Host " See $script:logFilePath for the full error context." -ForegroundColor Yellow
    }
}

# ------------------------------------------------------------------
# Issue #867: write _Assessment-Provenance.json — canonical chain-of-
# custody artifact. Captures tool version, registry version, run metadata,
# and SHA-256 hashes of every other artifact in the folder. Must run
# AFTER the HTML/XLSX/CSV writes so the hashes reflect final content.
# ------------------------------------------------------------------
try {
    $registryVersion = ''
    try {
        $regManifest = Join-Path -Path $projectRoot -ChildPath 'controls/registry.json'
        if (Test-Path -Path $regManifest) {
            $regJson = Get-Content -Raw -Path $regManifest | ConvertFrom-Json
            $registryVersion = if ($regJson.dataVersion) { $regJson.dataVersion } else { '' }
        }
    } catch { Write-Verbose "Could not read registry dataVersion: $($_.Exception.Message)" }

    $artifactHashes = @()
    Get-ChildItem -Path $assessmentFolder -File -ErrorAction SilentlyContinue |
        Where-Object { $_.Name -ne '_Assessment-Provenance.json' } |
        Sort-Object -Property Name |
        ForEach-Object {
            try {
                $hash = (Get-FileHash -Path $_.FullName -Algorithm SHA256).Hash
                $artifactHashes += [ordered]@{
                    name   = $_.Name
                    bytes  = $_.Length
                    sha256 = $hash
                }
            } catch {
                Write-Verbose "Could not hash $($_.FullName): $($_.Exception.Message)"
            }
        }

    $tenantNameForProv = if ($script:domainPrefix) { $script:domainPrefix } else { $TenantId }
    $provenance = [ordered]@{
        toolName              = 'M365-Assess'
        toolVersion           = $script:AssessmentVersion
        registryDataVersion   = $registryVersion
        generatedAtUtc        = (Get-Date).ToUniversalTime().ToString('o')
        tenantId              = if ($tenantIdentity -and $tenantIdentity.Guid) { $tenantIdentity.Guid } else { $TenantId }
        tenantDisplayName     = if ($tenantIdentity -and $tenantIdentity.DisplayName) { $tenantIdentity.DisplayName } else { '' }
        tenantPrimaryDomain   = if ($tenantIdentity -and $tenantIdentity.PrimaryDomain) { $tenantIdentity.PrimaryDomain } else { '' }
        tenantNameSlug        = $tenantNameForProv
        environment           = $M365Environment
        sectionsRun           = @($Section)
        collectorsRun         = @($summaryResults).Count
        durationSeconds       = [int]$overallDuration.TotalSeconds
        outputArtifacts       = $artifactHashes
    }
    $provenancePath = Join-Path -Path $assessmentFolder -ChildPath '_Assessment-Provenance.json'
    $provenance | ConvertTo-Json -Depth 6 | Set-Content -Path $provenancePath -Encoding UTF8
    Write-AssessmentLog -Level INFO -Message "Provenance file written: $provenancePath ($($artifactHashes.Count) artifacts hashed)"
}
catch {
    Write-AssessmentLog -Level WARN -Message "Failed to write provenance file: $($_.Exception.Message)"
}

# ------------------------------------------------------------------
# D4 #788 -- Sanitized evidence package
# Runs after HTML/XLSX so we can read the just-written artifacts. Failures
# here are non-fatal -- the assessment itself is already complete on disk.
# ------------------------------------------------------------------
if ($EvidencePackage) {
    $packageScriptPath = Join-Path -Path $projectRoot -ChildPath 'Common/Export-EvidencePackage.ps1'
    if (Test-Path -Path $packageScriptPath) {
        try {
            . $packageScriptPath
            $pkgParams = @{
                AssessmentFolder = $assessmentFolder
            }
            if ($script:domainPrefix) { $pkgParams['TenantName'] = $script:domainPrefix }
            elseif ($TenantId)        { $pkgParams['TenantName'] = $TenantId }
            if ($Redact) {
                $pkgParams['Redact'] = $true
                if ($script:domainPrefix) { $pkgParams['TenantDisplayName'] = $script:domainPrefix }
            }
            $packagePath = Export-EvidencePackage @pkgParams
            Write-AssessmentLog -Level INFO -Message "Evidence package written: $packagePath"
        }
        catch {
            Write-AssessmentLog -Level WARN -Message "Evidence package generation failed: $($_.Exception.Message)"
        }
    }
}

# ------------------------------------------------------------------
# Disconnect services
# ------------------------------------------------------------------
if (-not $SkipConnection) {
    foreach ($svc in @($connectedServices)) {
        try {
            switch ($svc) {
                'Graph' {
                    Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
                    Write-AssessmentLog -Level INFO -Message "Disconnected from Microsoft Graph."
                }
                'ExchangeOnline' {
                    Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
                    Write-AssessmentLog -Level INFO -Message "Disconnected from Exchange Online."
                }
                'Purview' {
                    Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
                    Write-AssessmentLog -Level INFO -Message "Disconnected from Purview."
                }
            }
        }
        catch {
            Write-AssessmentLog -Level WARN -Message "Failed to disconnect $svc`: $($_.Exception.Message)"
        }
    }
}

# ------------------------------------------------------------------
# Console summary
# ------------------------------------------------------------------
Show-AssessmentSummary -SummaryResults @($summaryResults) -Issues @($issues) -Duration $overallDuration -AssessmentFolder $assessmentFolder -SectionCount $Section.Count -Version $script:AssessmentVersion

# Summary is exported to _Assessment-Summary.csv for programmatic access

} # end function Invoke-M365Assessment

# ------------------------------------------------------------------
# Backward-compatible direct invocation: when this script is called
# directly (not dot-sourced from the module .psm1), invoke the
# function so '.\Invoke-M365Assessment.ps1 -Section Tenant ...' works.
# ------------------------------------------------------------------
if ($MyInvocation.InvocationName -ne '.') {
    Invoke-M365Assessment @args
}