Src/Private/Helpers.ps1

function Test-UserPrincipalName {
    <#
    .SYNOPSIS
    Validates that a string is a properly formatted User Principal Name.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param (
        [Parameter(Mandatory)]
        [string]$UserPrincipalName
    )
    return ($UserPrincipalName -match '^[^@\s]+@[^@\s]+\.[^@\s]+$')
}

function Write-TranscriptLog {
    <#
    .SYNOPSIS
    Writes a structured log entry to the host and optionally to a transcript file.
    Level values: DEBUG | INFO | SUCCESS | WARNING | ERROR
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]$Message,

        [Parameter(Position = 1)]
        [ValidateSet('DEBUG', 'INFO', 'SUCCESS', 'WARNING', 'ERROR')]
        [string]$Level = 'INFO',

        [Parameter(Position = 2)]
        [string]$Category = 'GENERAL'
    )

    $Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $Entry     = "[$Timestamp] [$Level] [$Category] $Message"

    $null = switch ($Level) {
        'DEBUG'   { Write-PScriboMessage -Message $Entry }
        'INFO'    { Write-Host " $Entry" }
        'SUCCESS' { Write-Host " $Entry" -ForegroundColor Green }
        'WARNING' { Write-PScriboMessage -IsWarning -Message $Entry }
        'ERROR'   { Write-Host " $Entry" -ForegroundColor Red }
    }

    if ($script:TranscriptLogPath) {
        try {
            Add-Content -Path $script:TranscriptLogPath -Value $Entry -ErrorAction SilentlyContinue | Out-Null
        } catch { }
    }
}

function Invoke-WithRetry {
    <#
    .SYNOPSIS
    Executes a ScriptBlock with configurable retry logic and exponential back-off.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [scriptblock]$ScriptBlock,

        [Parameter(Mandatory)]
        [string]$OperationName,

        [int]$MaxAttempts = 3,
        [int]$DelaySeconds = 5
    )

    $Attempt      = 0
    $CurrentDelay = $DelaySeconds

    while ($Attempt -lt $MaxAttempts) {
        $Attempt++
        try {
            Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts}: $OperationName" 'DEBUG' 'RETRY'
            $null = (& $ScriptBlock)
            Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} succeeded: $OperationName" 'DEBUG' 'RETRY'
            return
        } catch {
            if ($Attempt -lt $MaxAttempts) {
                Write-TranscriptLog "Attempt ${Attempt}/${MaxAttempts} failed for '$OperationName'. Retrying in ${CurrentDelay}s... Error: $($_.Exception.Message)" 'WARNING' 'RETRY'
                Start-Sleep -Seconds $CurrentDelay
                $CurrentDelay = $CurrentDelay * 2
            } else {
                Write-TranscriptLog "All ${MaxAttempts} attempts failed for '$OperationName'. Error: $($_.Exception.Message)" 'ERROR' 'RETRY'
                throw
            }
        }
    }
}

function Show-AbrDebugExecutionTime {
    <#
    .SYNOPSIS
    Records section start/stop timing for debug output.
    Compatible shim -- outputs timing to debug log if enabled.
    #>

    [CmdletBinding()]
    param(
        [switch]$Start,
        [switch]$End,
        [string]$TitleMessage = 'Section'
    )

    if ($Start) {
        $script:SectionTimers[$TitleMessage] = [System.Diagnostics.Stopwatch]::StartNew()
        Write-AbrDebugLog "[$TitleMessage] Started" 'DEBUG' 'TIMER'
    }
    if ($End) {
        if ($script:SectionTimers.ContainsKey($TitleMessage)) {
            $sw = $script:SectionTimers[$TitleMessage]
            $sw.Stop()
            Write-AbrDebugLog "[$TitleMessage] Completed in $($sw.Elapsed.TotalSeconds.ToString('F2'))s" 'DEBUG' 'TIMER'
        }
    }
}

function ConvertTo-HashToYN {
    <#
    .SYNOPSIS
    Converts boolean values in a hashtable/ordered-dictionary to Yes/No strings.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Collections.IDictionary]$Hash
    )

    process {
        $Result = [ordered]@{}
        foreach ($Key in $Hash.Keys) {
            $Value = $Hash[$Key]
            if ($Value -is [bool]) {
                $Result[$Key] = if ($Value) { 'Yes' } else { 'No' }
            } elseif ($null -eq $Value -or $Value -eq '') {
                $Result[$Key] = '--'
            } else {
                $Result[$Key] = $Value
            }
        }
        return $Result
    }
}

function Export-SharePointToExcel {
    <#
    .SYNOPSIS
    Exports one or more arrays of PSCustomObjects to a multi-sheet Excel workbook.
    .DESCRIPTION
    Uses the ImportExcel module to generate a formatted .xlsx report.
    Each key in the Sheets hashtable becomes a worksheet tab.
    Falls back to CSV if ImportExcel is not installed.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [System.Collections.Specialized.OrderedDictionary]$Sheets,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$TenantId = 'Unknown Tenant'
    )

    if (-not (Get-Module -ListAvailable -Name ImportExcel -ErrorAction SilentlyContinue)) {
        Write-Warning "ImportExcel module is not installed. Run 'Install-Module -Name ImportExcel -Force' to enable Excel exports."
        Write-Warning "Falling back to CSV export..."

        foreach ($SheetName in $Sheets.Keys) {
            $CsvPath = $Path -replace '\.xlsx$', "_$($SheetName -replace '\s','_').csv"
            $Sheets[$SheetName] | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
            Write-Host " - CSV exported: $CsvPath" -ForegroundColor Cyan
        }
        return
    }

    Import-Module ImportExcel -ErrorAction Stop

    if (Test-Path $Path) { Remove-Item $Path -Force }

    $SheetIndex = 0
    foreach ($SheetName in $Sheets.Keys) {
        $Data = $Sheets[$SheetName]
        if (-not $Data -or @($Data).Count -eq 0) {
            Write-TranscriptLog "Sheet '$SheetName' has no data -- skipping." 'WARNING' 'EXPORT'
            continue
        }

        $ExcelParams = @{
            Path          = $Path
            WorksheetName = $SheetName
            AutoSize      = $true
            AutoFilter    = $true
            FreezeTopRow  = $true
            BoldTopRow    = $true
            TableStyle    = 'Medium9'
            Append        = ($SheetIndex -gt 0)
            PassThru      = $false
        }

        $Data | Export-Excel @ExcelParams
        $SheetIndex++
        Write-Host " - Sheet '$SheetName' written ($(@($Data).Count) rows)." -ForegroundColor Cyan
    }

    Write-Host " - Excel report saved to: $Path" -ForegroundColor Green
    Write-TranscriptLog "Excel export complete: $Path" 'SUCCESS' 'EXPORT'
}

function Write-AbrSectionError {
    <#
    .SYNOPSIS
    Writes a standardised error paragraph inside a PScribo section.
    Prevents empty sections that crash the Word exporter.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Section,
        [Parameter(Mandatory)][string]$Message
    )
    Write-PScriboMessage -IsWarning "[$Section] $Message"
    Paragraph " [!] Unable to retrieve $Section data: $Message"
    Write-TranscriptLog "Section error [$Section]: $Message" 'ERROR' $Section
    if ($script:ErrorLogPath) {
        try {
            $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [ERROR] [$Section] $Message"
            Add-Content -Path $script:ErrorLogPath -Value $entry -ErrorAction SilentlyContinue
        } catch {}
    }
}

function Write-AbrDebugLog {
    <#
    .SYNOPSIS
    Writes a structured debug log entry (no-op if DebugLog is disabled).
    #>

    [CmdletBinding()]
    param (
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG', 'SUCCESS')][string]$Level = 'INFO',
        [string]$Section = 'GENERAL'
    )
    # Forward to the script-scoped function defined in Invoke function if it exists
    # This stub ensures Private functions can call it without knowing if it's defined yet
    $Entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] [$Level] [$Section] $Message"
    if ($script:DebugLogEnabled) {
        $null = $script:DebugLogEntries.Add($Entry)
        switch ($Level) {
            'ERROR'   { Write-Host " [DBG-ERR] $Entry" -ForegroundColor Red }
            'WARN'    { Write-Host " [DBG-WRN] $Entry" -ForegroundColor Yellow }
            'SUCCESS' { Write-Host " [DBG] $Entry" -ForegroundColor Green }
            default   { Write-Host " [DBG] $Entry" -ForegroundColor DarkGray }
        }
    }
}

function Write-ReportModuleInfo {
    <#
    .SYNOPSIS
    Writes the module header banner to console.
    #>

    [CmdletBinding()]
    param([string]$ModuleName = 'Microsoft.SharePoint')

    Write-Host ""
    Write-Host " AsBuiltReport.Microsoft.SharePoint" -ForegroundColor Cyan
    Write-Host " SharePoint Online & OneDrive for Business As-Built Report" -ForegroundColor Cyan
    Write-Host ""
}

function ConvertTo-SPEnumString {
    <#
    .SYNOPSIS
    Safely converts a SharePoint/PnP enum value to a string.
    Guards against null values which cause "You cannot call a method on a null-valued expression"
    errors when calling .ToString() directly on PnP v3 enum properties.
    .PARAMETER Value
        The enum or object to convert. May be $null.
    .PARAMETER Default
        The string to return when Value is null or empty.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Position = 0)]
        $Value,

        [Parameter(Position = 1)]
        [string]$Default = 'Unknown'
    )
    if ($null -eq $Value) { return $Default }
    $str = "$Value".Trim()
    if ($str -eq '' -or $str -eq '0') { return $Default }
    return $str
}

function Invoke-SPGraphRequest {
    <#
    .SYNOPSIS
    Makes a Microsoft Graph REST API call without using the Microsoft.Graph SDK.
    .DESCRIPTION
    PnP.PowerShell v3 loads Microsoft.Graph.Core v1.25.1 into the AppDomain when it
    connects. This conflicts with Graph SDK v2.x (which needs Core v3.x) and breaks
    ALL Microsoft.Graph.* cmdlets after PnP connects, including Get-MgOrganization,
    Get-MgSubscribedSku, and Invoke-MgGraphRequest.

    This function bypasses the Graph SDK entirely using PowerShell's built-in
    Invoke-RestMethod with a Bearer token obtained from PnP's own auth stack.

    Token acquisition is attempted in this order:
      1. Get-PnPAccessToken -ResourceTypeName Graph (PnP v3 standard)
      2. Get-PnPAccessToken (no param, some builds)
      3. Get-PnPGraphAccessToken (PnP v2, removed in some v3 builds)
      4. PnP connection context MSAL token
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,
        [string]$Method = 'GET'
    )

    $BaseUrl = 'https://graph.microsoft.com'
    $FullUri  = if ($Uri -like 'https://*') { $Uri } else { "$BaseUrl/$($Uri.TrimStart('/'))" }

    #region Obtain access token
    $Token = $null

    # Method 1: Get-PnPAccessToken -ResourceTypeName Graph (PnP v3 preferred)
    if (-not $Token) {
        try {
            $Result = Get-PnPAccessToken -ResourceTypeName Graph -ErrorAction Stop
            $Token  = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { "$Result" }
        } catch {
            Write-Verbose "Invoke-SPGraphRequest[1]: $($_.Exception.Message)"
        }
    }

    # Method 2: Get-PnPAccessToken (no ResourceTypeName -- returns SPO token in some builds,
    # but Graph in others when the app has Graph permissions)
    if (-not $Token) {
        try {
            $Result = Get-PnPAccessToken -ErrorAction Stop
            $Candidate = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { "$Result" }
            # Only use if it looks like a JWT (starts with 'ey')
            if ($Candidate -and $Candidate.StartsWith('ey')) { $Token = $Candidate }
        } catch {
            Write-Verbose "Invoke-SPGraphRequest[2]: $($_.Exception.Message)"
        }
    }

    # Method 3: Get-PnPGraphAccessToken (PnP v2 / some v3 builds)
    if (-not $Token) {
        try {
            $Result = Get-PnPGraphAccessToken -ErrorAction Stop
            $Token  = if ($Result -is [string]) { $Result } elseif ($Result.AccessToken) { $Result.AccessToken } else { $null }
        } catch {
            Write-Verbose "Invoke-SPGraphRequest[3]: $($_.Exception.Message)"
        }
    }

    # Method 4: Pull raw token from PnP connection context's MSAL provider
    if (-not $Token) {
        try {
            $Conn = Get-PnPConnection -ErrorAction Stop
            if ($Conn -and $Conn.Context) {
                # Try GetAccessTokenAsync if it exists on the context object
                $ContextType = $Conn.Context.GetType()
                $Method4     = $ContextType.GetMethod('GetAccessTokenAsync', [type[]]@([string[]]))
                if ($Method4) {
                    $Task  = $Method4.Invoke($Conn.Context, @(, @('https://graph.microsoft.com/.default')))
                    $Token = $Task.GetAwaiter().GetResult()
                }
            }
        } catch {
            Write-Verbose "Invoke-SPGraphRequest[4]: $($_.Exception.Message)"
        }
    }

    if (-not $Token -or $Token.Length -lt 20) {
        throw "Invoke-SPGraphRequest: Could not obtain a Graph access token from PnP. Tried 4 methods. URI: $FullUri"
    }
    #endregion

    $Headers = @{
        'Authorization' = "Bearer $Token"
        'Content-Type'  = 'application/json'
        'Accept'        = 'application/json'
    }

    try {
        return Invoke-RestMethod -Uri $FullUri -Method $Method -Headers $Headers -ErrorAction Stop
    } catch {
        # Surface the HTTP status code if available
        $StatusCode = $_.Exception.Response.StatusCode.value__
        $ErrMsg     = if ($StatusCode) { "HTTP $StatusCode -- $($_.Exception.Message)" } else { $_.Exception.Message }
        throw "Invoke-SPGraphRequest: REST call failed for '$FullUri': $ErrMsg"
    }
}

function ConvertTo-SPEnumString {
    <#
    .SYNOPSIS
    Safely converts a SharePoint/PnP enum value to a string.
    Guards against null values which cause "You cannot call a method on a null-valued expression"
    errors when calling .ToString() directly on PnP v3 enum properties.
    .PARAMETER Value
        The enum or object to convert. May be $null.
    .PARAMETER Default
        The string to return when Value is null or empty.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Position = 0)]
        $Value,

        [Parameter(Position = 1)]
        [string]$Default = 'Unknown'
    )
    if ($null -eq $Value) { return $Default }
    $str = "$Value".Trim()
    if ($str -eq '' -or $str -eq '0') { return $Default }
    return $str
}

function Invoke-SPGraphRequest {
    <#
    .SYNOPSIS
    Makes a Microsoft Graph REST API call using PnP's access token.

    .DESCRIPTION
    This function is the ONLY safe way to call Graph APIs in this module when
    PnP.PowerShell v3 is loaded. PnP v3 loads Microsoft.Graph.Core v1.25.1 into
    the AppDomain which conflicts with Graph SDK v2.x (needs Core v3.x). After PnP
    connects, ALL Microsoft.Graph.* cmdlets (including Get-MgOrganization,
    Get-MgSubscribedSku, Invoke-MgGraphRequest) throw:
      "Could not load type 'AzureIdentityAccessTokenProvider' from assembly
       'Microsoft.Graph.Core, Version=1.25.1.0'"

    This function bypasses the Graph SDK entirely by:
      1. Obtaining a Bearer token from PnP via Get-PnPGraphAccessToken
      2. Calling Graph REST directly with PowerShell's built-in Invoke-RestMethod

    .PARAMETER Uri
        The Graph API URI (relative path like '/v1.0/organization' or full URL).

    .PARAMETER Method
        HTTP method. Default: GET.

    .EXAMPLE
        $org = Invoke-SPGraphRequest '/v1.0/organization?$select=id,displayName'
        $org.value[0].displayName
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Uri,

        [string]$Method = 'GET'
    )

    # Build full URL
    $BaseUrl = 'https://graph.microsoft.com'
    $FullUri  = if ($Uri -like 'https://*') { $Uri } else { "$BaseUrl/$($Uri.TrimStart('/'))" }

    # Get access token -- try PnP first (works even when Graph SDK is broken by assembly conflict)
    $Token = $null
    try {
        $Token = Get-PnPGraphAccessToken -ErrorAction Stop
    } catch {
        # Fallback: try Graph SDK token (may fail if assembly conflict is present)
        try {
            $TokenObj = [Microsoft.Graph.PowerShell.Authentication.GraphSession]::Instance.AuthContext
            if ($TokenObj) {
                $TokenResult = $TokenObj.AuthenticationProvider.GetAuthorizationTokenAsync(
                    [Uri]"https://graph.microsoft.com", @{}, [System.Threading.CancellationToken]::None
                ).GetAwaiter().GetResult()
                $Token = $TokenResult
            }
        } catch { }
    }

    if (-not $Token) {
        throw "Invoke-SPGraphRequest: Could not obtain a Graph access token. Ensure PnP.PowerShell is connected."
    }

    $Headers = @{
        'Authorization' = "Bearer $Token"
        'Content-Type'  = 'application/json'
        'Accept'        = 'application/json'
    }

    try {
        $Response = Invoke-RestMethod -Uri $FullUri -Method $Method -Headers $Headers -ErrorAction Stop
        return $Response
    } catch {
        throw "Invoke-SPGraphRequest: Graph call failed for '$FullUri': $($_.Exception.Message)"
    }
}

function Test-SPGraphToken {
    <#
    .SYNOPSIS
    Diagnostic helper -- tests all Graph token acquisition methods and prints results.
    Run after connecting PnP to diagnose token issues:
        Connect-PnPOnline -Url https://tenant-admin.sharepoint.com -ClientId <id> -Interactive
        Test-SPGraphToken
    #>

    [CmdletBinding()]
    param()

    Write-Host ""
    Write-Host " === Graph Token Diagnostic ===" -ForegroundColor Cyan

    $Methods = @(
        @{ Name = 'Get-PnPAccessToken -ResourceTypeName Graph'; Script = { Get-PnPAccessToken -ResourceTypeName Graph -ErrorAction Stop } }
        @{ Name = 'Get-PnPAccessToken (no param)';              Script = { Get-PnPAccessToken -ErrorAction Stop } }
        @{ Name = 'Get-PnPGraphAccessToken';                    Script = { Get-PnPGraphAccessToken -ErrorAction Stop } }
    )

    $WorkingMethod = $null
    foreach ($m in $Methods) {
        try {
            $result = & $m.Script
            $token  = if ($result -is [string]) { $result } elseif ($result.AccessToken) { $result.AccessToken } else { "$result" }
            if ($token -and $token.Length -gt 20) {
                Write-Host " [OK] $($m.Name) -- token length $($token.Length)" -ForegroundColor Green
                if (-not $WorkingMethod) { $WorkingMethod = $m.Name }
            } else {
                Write-Host " [EMPTY] $($m.Name) -- returned empty/short value" -ForegroundColor Yellow
            }
        } catch {
            Write-Host " [FAIL] $($m.Name) -- $($_.Exception.Message)" -ForegroundColor Red
        }
    }

    if ($WorkingMethod) {
        Write-Host ""
        Write-Host " Working method: $WorkingMethod" -ForegroundColor Green

        # Test a real Graph call
        try {
            $token = & ($Methods | Where-Object { $_.Name -eq $WorkingMethod }).Script
            $t = if ($token -is [string]) { $token } elseif ($token.AccessToken) { $token.AccessToken } else { "$token" }
            $resp = Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/organization?$select=displayName' `
                       -Headers @{ Authorization = "Bearer $t"; Accept = 'application/json' } -ErrorAction Stop
            Write-Host " [OK] Graph REST test -- Tenant: $($resp.value[0].displayName)" -ForegroundColor Green
        } catch {
            Write-Host " [FAIL] Graph REST test -- $($_.Exception.Message)" -ForegroundColor Red
        }
    } else {
        Write-Host ""
        Write-Host " [ERROR] No working token method found. Is PnP connected?" -ForegroundColor Red
        Write-Host " Run: Connect-PnPOnline -Url <adminUrl> -ClientId <id> -Interactive" -ForegroundColor Yellow
    }
    Write-Host ""
}