Permission.psm1


function ConvertTo-ClassExclusionDiv {

    param (

        <#
        Accounts whose objectClass property is in this list are excluded from the HTML report
 
        Note on the 'group' class:
        By default, a group with members is replaced in the report by its members unless the -NoGroupMembers switch is used.
        Any remaining groups are empty and not useful to see in the middle of a list of users/job titles/departments/etc).
        So the 'group' class is excluded here by default.
        #>

        [string[]]$ExcludeClass

    )

    if ($ExcludeClass) {

        $ListGroup = $ExcludeClass |
        ConvertTo-HtmlList |
        ConvertTo-BootstrapListGroup

        $Content = "Accounts whose objectClass property is in this list were excluded from the report.$ListGroup"

    } else {

        $Content = 'No accounts were excluded based on objectClass.'

    }

    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText 'Accounts Excluded by Class' -Content `$Content"
    return New-BootstrapDivWithHeading -HeadingText 'Accounts Excluded by Class' -Content $Content -HeadingLevel 6

}
function ConvertTo-FileList {

    param (
        [string[]]$Format,

        # Path to the folder to save the logs and reports generated by this script
        $OutputDir,

        [cultureinfo]$Culture = (Get-Culture),

        <#
        Level of detail to export to file
            0 Item paths $TargetPath
            1 Resolved item paths (server names resolved, DFS targets resolved) $Parents
            2 Expanded resolved item paths (parent paths expanded into children) $ACLsByPath.Keys
            3 Access rules $ACLsByPath.Values
            4 Resolved access rules (server names resolved, inheritance flags resolved) $ACEsByGUID.Values | %{$_} | Sort Path,IdentityReferenceResolved
            5 Accounts with access $PrincipalsByResolvedID.Values | %{$_} | Sort ResolvedAccountName
            6 Expanded resolved access rules (expanded with account info) $Permissions
            7 Formatted permissions $FormattedPermissions
            8 Best Practice issues $BestPracticeIssues
            9 XML custom sensor output for Paessler PRTG Network Monitor $PrtgXml
            10 Permission Report
        #>

        [int[]]$Detail = @(0..10),

        [String]$FileName

    )

    $FileList = @{}

    ForEach ($ThisFormat in $Format) {

        # String translations indexed by value in the $Detail parameter
        # TODO: Move to i18n
        $DetailStrings = @(
            'Target paths',
            'Network paths (target path servers and DFS targets resolved)',
            'Item paths (network paths expanded into their children)',
            'Access lists',
            'Access rules (resolved identity references and inheritance flags)',
            'Accounts with access',
            'Expanded access rules (expanded with account info)', # #ToDo: Expand DirectoryEntry objects in the DirectoryEntry and Members properties
            'Formatted permissions',
            'Best Practice issues',
            'Custom sensor output for Paessler PRTG Network Monitor'
            'Permission report'
        )

        $FileList[$ThisFormat] = switch ($ThisFormat) {

            'csv' {

                $Suffix = '.csv'

                ForEach ($Level in $Detail) {

                    # Currently no CSV reports are generated for detail levels 8/9/10
                    if ($Detail -lt 8) {

                        # Get shorter versions of the detail strings to use in file names
                        $ShortDetail = $DetailStrings[$Level] -replace '\([^\)]*\)', ''

                        # Convert the shorter strings to Title Case
                        $TitleCaseDetail = $Culture.TextInfo.ToTitleCase($ShortDetail)

                        # Remove spaces from the shorter strings
                        $SpacelessDetail = $TitleCaseDetail -replace '\s', ''

                        # Build the file path
                        "$OutputDir\$Level`_$SpacelessDetail$Suffix"

                    }

                }

                break

            }

            'html' {

                $Suffix = "_$FileName.htm"

                ForEach ($Level in $Detail) {

                    # Currently no HTML reports are generated for detail levels 8/9
                    if ($Level -notin 8, 9) {

                        # Get shorter versions of the detail strings to use in file names
                        $ShortDetail = $DetailStrings[$Level] -replace '\([^\)]*\)', ''

                        # Convert the shorter strings to Title Case
                        $TitleCaseDetail = $Culture.TextInfo.ToTitleCase($ShortDetail)

                        # Remove spaces from the shorter strings
                        $SpacelessDetail = $TitleCaseDetail -replace '\s', ''

                        # Build the file path
                        "$OutputDir\$Level`_$SpacelessDetail$Suffix"

                    }

                }

                break

            }

            'js' {

                $Suffix = "_js_$FileName.htm"

                ForEach ($Level in $Detail) {

                    # Currently no JS reports are generated for detail levels 8/9
                    if ($Level -notin 8, 9) {

                        # Get shorter versions of the detail strings to use in file names
                        $ShortDetail = $DetailStrings[$Level] -replace '\([^\)]*\)', ''

                        # Convert the shorter strings to Title Case
                        $TitleCaseDetail = $Culture.TextInfo.ToTitleCase($ShortDetail)

                        # Remove spaces from the shorter strings
                        $SpacelessDetail = $TitleCaseDetail -replace '\s', ''

                        # Build the file path
                        "$OutputDir\$Level`_$SpacelessDetail$Suffix"

                    }

                }

                break

            }

            'prtgxml' {

                $Suffix = '.xml'
                $Level = 9

                # Level 9 is the only level applicable for the PrtgXml format
                if ($Detail -contains $Level) {

                    # Get shorter versions of the detail strings to use in file names
                    $ShortDetail = $DetailStrings[$Level] -replace '\([^\)]*\)', ''

                    # Convert the shorter strings to Title Case
                    $TitleCaseDetail = $Culture.TextInfo.ToTitleCase($ShortDetail)

                    # Remove spaces from the shorter strings
                    $SpacelessDetail = $TitleCaseDetail -replace '\s', ''

                    # Build the file path
                    "$OutputDir\$Level`_$SpacelessDetail$Suffix"

                }

                break

            }

            'json' {

                $Suffix = "_$FileName.json"

                #TODO

                break

            }

            'xml' {

                $Suffix = '.xml'

                #TODO

                break

            }

        }

    }

    return $FileList

}
function ConvertTo-FileListDiv {

    param (
        [Hashtable]$FileList
    )

    ForEach ($Format in ($FileList.Keys | Sort-Object)) {

        $Files = $FileList[$Format]

        if ($Files) {

            New-BootstrapAlert -Text $Format -Class Dark -Padding ' p-2 mb-0 mt-2'

            $Files |
            Sort-Object |
            Split-Path -Leaf |
            ConvertTo-HtmlList |
            ConvertTo-BootstrapListGroup

        }

    }

}
function ConvertTo-IgnoredDomainDiv {

    param (

        <#
        Domain(s) to ignore (they will be removed from the username)
 
        Can be used:
        to ensure accounts only appear once on the report when they have matching SamAccountNames in multiple domains.
        when the domain is often the same and doesn't need to be displayed
        #>

        [string[]]$IgnoreDomain

    )

    if ($IgnoreDomain) {

        $ListGroup = $IgnoreDomain |
        ConvertTo-HtmlList |
        ConvertTo-BootstrapListGroup

        $Content = "Accounts from these domains are listed in the report without their domain.$ListGroup"

    } else {

        $Content = 'No domains were ignored. All accounts have their domain listed.'

    }

    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText 'Domains Ignored by Name' -Content `$Content"
    return New-BootstrapDivWithHeading -HeadingText 'Domains Ignored by Name' -Content $Content -HeadingLevel 6

}
function ConvertTo-MemberExclusionDiv {

    param (

        <#
        Do not get group members (only report the groups themselves)
 
        Note: By default, the -ExcludeClass parameter will exclude groups from the report.
        If using -NoGroupMembers, you most likely want to modify the value of -ExcludeClass.
        Remove the 'group' class from ExcludeClass in order to see groups on the report.
        #>

        [switch]$NoMembers

    )

    if ($NoMembers) {

        $Content = 'Group members were excluded from the report.<br />Only accounts directly from the ACLs are included in the report.'

    } else {

        $Content = 'No accounts were excluded based on group membership.<br />Members of groups from the ACLs are included in the report.'

    }

    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText 'Group Members' -Content '$Content'"
    return New-BootstrapDivWithHeading -HeadingText 'Group Members' -Content $Content -HeadingLevel 6

}
function ConvertTo-NameExclusionDiv {

    param (

        # Regular expressions matching names of security principals to exclude from the HTML report
        [string[]]$ExcludeAccount

    )

    if ($ExcludeAccount) {

        $ListGroup = $ExcludeAccount |
        ConvertTo-HtmlList |
        ConvertTo-BootstrapListGroup

        $Content = "Accounts whose names match these regular expressions were excluded from the report.$ListGroup"

    } else {

        $Content = 'No accounts were excluded based on name.'

    }

    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText 'Accounts Excluded by Name' -Content `$Content"
    return New-BootstrapDivWithHeading -HeadingText 'Accounts Excluded by Name' -Content $Content -HeadingLevel 6

}
function ConvertTo-PermissionGroup {
    [CmdletBinding()]
    param (

        # Permission object from Expand-Permission
        [PSCustomObject[]]$Permission,

        # Type of output returned to the output stream
        [ValidateSet('csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [String]$Format,

        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        [string[]]$AccountProperty = @('Account', 'Name', 'DisplayName', 'Description', 'Department', 'Title'),

        [string[]]$ItemProperty = @('Folder', 'Inheritance'),

        [Hashtable]$HowToSplit

    )

    $OutputObject = @{}

    if (
        $GroupBy -eq 'none' -or
        $HowToSplit[$GroupBy]
    ) {
        return
    }

    switch ($Format) {

        'csv' {
            $OutputObject['Data'] = $Permission | ConvertTo-Csv
            break
        }

        'html' {

            #Write-LogMsg @LogParams -Text "`$Permission | ConvertTo-Html -Fragment | New-BootstrapTable"
            $Html = $Permission | ConvertTo-Html -Fragment
            $OutputObject['Data'] = $Html
            $OutputObject['Table'] = $Html | New-BootstrapTable
            break

        }

        'js' {

            #TODO: Change table id to "Groupings" instead of Folders to allow for Grouping by Account
            $JavaScriptTable = @{
                ID = 'Folders'
            }

            switch ($GroupBy) {

                'account' {
                    $OrderedProperties = $AccountProperty
                    $JavaScriptTable['SearchableColumn'] = $OrderedProperties
                    break
                }

                'item' {
                    $OrderedProperties = $ItemProperty
                    $JavaScriptTable['SearchableColumn'] = 'Folder'
                    $JavaScriptTable['DropdownColumn'] = 'Inheritance'
                    break
                }

            }

            # Wrap input in a array because output must be a JSON array for jquery to work properly.
            $OutputObject['Data'] = ConvertTo-Json -Compress -InputObject @($Permission)
            $OutputObject['Columns'] = Get-ColumnJson -InputObject $Permission -PropNames $OrderedProperties
            $OutputObject['Table'] = ConvertTo-BootstrapJavaScriptTable -InputObject $Permission -PropNames $OrderedProperties -DataFilterControl -PageSize 25 @JavaScriptTable
            break

        }

        'xml' {
            $OutputObject['Data'] = ($Permission | ConvertTo-Xml).InnerXml
            break
        }

        default {}

    }

    return [PSCustomObject]$OutputObject

}
function ConvertTo-PermissionList {

    param (

        # Permission object from Expand-Permission
        [Hashtable]$Permission,

        [PSCustomObject[]]$PermissionGrouping,

        # Type of output returned to the output stream
        [ValidateSet('csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [String]$Format,

        [String]$ShortestPath,

        [String]$NetworkPath,

        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        [Hashtable]$HowToSplit,

        [PSCustomObject]$Analysis

    )

    switch ($Format) {

        'csv' {

            if (
                $GroupBy -eq 'none' -or
                $HowToSplit[$GroupBy]
            ) {

                $Sorted = $Permission.Values | Sort-Object -Property Item, Account

                [PSCustomObject]@{
                    PSTypeName = 'Permission.PermissionList'
                    Data       = $Sorted | ConvertTo-Csv
                    PassThru   = $Sorted
                }

            } else {

                switch ($GroupBy) {

                    'account' {

                        ForEach ($Group in $PermissionGrouping) {
                            [PSCustomObject]@{
                                PSTypeName = 'Permission.PermissionList'
                                Data       = $Permission[$Group.Account.ResolvedAccountName] | ConvertTo-Csv
                                PassThru   = $Permission[$Group.Account.ResolvedAccountName]
                                Grouping   = $Group.Account.ResolvedAccountName
                            }
                        }
                        break

                    }

                    'item' {

                        ForEach ($Group in $PermissionGrouping) {
                            [PSCustomObject]@{
                                PSTypeName = 'Permission.PermissionList'
                                Data       = $Permission[$Group.Item.Path] | ConvertTo-Csv
                                PassThru   = $Permission[$Group.Item.Path]
                                Grouping   = $Group.Item.Path
                            }
                        }
                        break

                    }

                    'target' {

                        ForEach ($Group in $PermissionGrouping) {

                            $Perm = $Permission[$Group.Path]

                            if ($Perm) {
                                [PSCustomObject]@{
                                    PSTypeName = 'Permission.PermissionList'
                                    Data       = $Perm | ConvertTo-Csv
                                    Grouping   = $Group.Path
                                    PassThru   = $Perm
                                }

                            }

                        }
                        break

                    }

                }

            }
            break

        }

        'html' {

            if (
                $GroupBy -eq 'none' -or
                $HowToSplit[$GroupBy]
            ) {

                $Heading = New-HtmlHeading "Permissions in $NetworkPath" -Level 6
                $Sorted = $Permission.Values | Sort-Object -Property Item, Account
                $Html = $Sorted | ConvertTo-Html -Fragment
                $Table = $Html | New-BootstrapTable

                [PSCustomObject]@{
                    PSTypeName = 'Permission.PermissionList'
                    Data       = $Html
                    Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                    PassThru   = $Sorted
                }

            } else {

                switch ($GroupBy) {

                    'account' {

                        ##ForEach ($Group in $PermissionGrouping) {
                        ForEach ($GroupID in $Permission.Keys) {

                            ##$GroupID = $Group.Account.ResolvedAccountName
                            $Heading = New-HtmlHeading "Folders accessible to $GroupID" -Level 6
                            $StartingPermissions = $Permission[$GroupID]
                            $Html = $StartingPermissions | ConvertTo-Html -Fragment
                            $Table = $Html | New-BootstrapTable

                            [PSCustomObject]@{
                                PSTypeName = 'Permission.PermissionList'
                                Data       = $Html
                                Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                Grouping   = $GroupID
                                PassThru   = $StartingPermissions
                            }

                        }
                        break

                    }

                    'item' {

                        ForEach ($Group in $PermissionGrouping) {

                            $GroupID = $Group.Item.Path
                            $Heading = New-HtmlHeading "Accounts with access to $GroupID" -Level 6
                            $SubHeading = Get-FolderPermissionTableHeader -Group $Group -GroupID $GroupID -ShortestFolderPath $ShortestPath
                            $StartingPermissions = $Permission[$GroupID]
                            $Html = $StartingPermissions | ConvertTo-Html -Fragment
                            $Table = $Html | New-BootstrapTable

                            [PSCustomObject]@{
                                PSTypeName = 'Permission.PermissionList'
                                Data       = $Html
                                Div        = New-BootstrapDiv -Text ($Heading + $SubHeading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                Grouping   = $GroupID
                                PassThru   = $StartingPermissions
                            }

                        }
                        break

                    }

                    'target' {

                        ForEach ($Group in $PermissionGrouping) {

                            $GroupID = $Group.Path
                            $Heading = New-HtmlHeading "Permissions in $GroupID" -Level 5
                            $StartingPermissions = $Permission[$GroupID]
                            $Html = $StartingPermissions | ConvertTo-Html -Fragment
                            $Table = $Html | New-BootstrapTable

                            [PSCustomObject]@{
                                PSTypeName = 'Permission.PermissionList'
                                Data       = $Html
                                Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                Grouping   = $GroupID
                                PassThru   = $StartingPermissions
                            }

                        }
                        break

                    }

                }

            }
            break

        }

        'js' {

            if (
                $GroupBy -eq 'none' -or
                $HowToSplit[$GroupBy]
            ) {

                $Heading = New-HtmlHeading "Permissions in $NetworkPath" -Level 6
                $StartingPermissions = $Permission.Values | Sort-Object -Property Item, Account

                # Remove spaces from property titles
                $ObjectsForJsonData = ForEach ($Obj in $StartingPermissions) {
                    [PSCustomObject]@{
                        Item              = $Obj.Item
                        Account           = $Obj.Account
                        Access            = $Obj.Access
                        DuetoMembershipIn = $Obj.'Due to Membership In'
                        SourceofAccess    = $Obj.'Source of Access'
                        Name              = $Obj.Name
                        Department        = $Obj.Department
                        Title             = $Obj.Title
                    }

                }

                $TableId = 'Perms'
                $Table = ConvertTo-BootstrapJavaScriptTable -Id $TableId -InputObject $StartingPermissions -DataFilterControl -AllColumnsSearchable -PageSize 25

                [PSCustomObject]@{
                    PSTypeName = 'Permission.PermissionList'
                    Columns    = Get-ColumnJson -InputObject $StartingPermissions -PropNames Item, Account, Access, 'Due to Membership In', 'Source of Access', Name, Department, Title
                    Data       = ConvertTo-Json -Compress -InputObject @($ObjectsForJsonData)
                    Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                    PassThru   = $ObjectsForJsonData
                    Table      = $TableId
                }

            } else {

                switch ($GroupBy) {

                    'account' {

                        ##ForEach ($Group in $PermissionGrouping) {
                        ForEach ($GroupID in $Permission.Keys) {
                            # TODO: $Permission.Keys | Sort-Object would result in this being sorted, but this wasn't needed previously. Investigate to avoid redundant sorting.

                            ##$GroupID = $Group.Account.ResolvedAccountName
                            $Heading = New-HtmlHeading "Items accessible to $GroupID" -Level 6
                            $StartingPermissions = $Permission[$GroupID]

                            # Remove spaces from property titles
                            $ObjectsForJsonData = ForEach ($Obj in $StartingPermissions) {
                                [PSCustomObject]@{
                                    Path              = $Obj.Path
                                    Access            = $Obj.Access
                                    DuetoMembershipIn = $Obj.'Due to Membership In'
                                    SourceofAccess    = $Obj.'Source of Access'
                                }
                            }

                            $TableId = "Perms_$($GroupID -replace '[^A-Za-z0-9\-_]', '-')"
                            $Table = ConvertTo-BootstrapJavaScriptTable -Id $TableId -InputObject $StartingPermissions -DataFilterControl -AllColumnsSearchable

                            [PSCustomObject]@{
                                PSTypeName = 'Permission.AccountPermissionList'
                                Columns    = Get-ColumnJson -InputObject $StartingPermissions-PropNames Path, Access, 'Due to Membership In', 'Source of Access'
                                Data       = ConvertTo-Json -Compress -InputObject @($ObjectsForJsonData)
                                Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                PassThru   = $ObjectsForJsonData
                                Grouping   = $GroupID
                                Table      = $TableId
                            }

                        }
                        break

                    }

                    'item' {

                        ForEach ($Group in $PermissionGrouping) {

                            $GroupID = $Group.Item.Path
                            $Heading = New-HtmlHeading "Accounts with access to $GroupID" -Level 6
                            $SubHeading = Get-FolderPermissionTableHeader -Group $Group -GroupID $GroupID -ShortestFolderPath $ShortestPath
                            $StartingPermissions = $Permission[$GroupID]

                            if ($StartingPermissions) {

                                # Remove spaces from property titles
                                $ObjectsForJsonData = ForEach ($Obj in $StartingPermissions) {
                                    [PSCustomObject]@{
                                        Account           = $Obj.Account
                                        Access            = $Obj.Access
                                        DuetoMembershipIn = $Obj.'Due to Membership In'
                                        SourceofAccess    = $Obj.'Source of Access'
                                        Name              = $Obj.Name
                                        Department        = $Obj.Department
                                        Title             = $Obj.Title
                                    }
                                }

                                $TableId = "Perms_$($GroupID -replace '[^A-Za-z0-9\-_]', '-')"
                                $Table = ConvertTo-BootstrapJavaScriptTable -Id $TableId -InputObject $StartingPermissions -DataFilterControl -AllColumnsSearchable

                                [PSCustomObject]@{
                                    PSTypeName = 'Permission.ItemPermissionList'
                                    Columns    = Get-ColumnJson -InputObject $StartingPermissions -PropNames Account, Access, 'Due to Membership In', 'Source of Access', Name, Department, Title
                                    Data       = ConvertTo-Json -Compress -InputObject @($ObjectsForJsonData)
                                    Div        = New-BootstrapDiv -Text ($Heading + $SubHeading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                    Grouping   = $GroupID
                                    PassThru   = $ObjectsForJsonData
                                    Table      = $TableId
                                }

                            }

                        }
                        break

                    }

                    'target' {

                        ForEach ($Group in $PermissionGrouping) {

                            $GroupID = $Group.Path
                            $Heading = New-HtmlHeading "Permissions in $GroupID" -Level 5
                            $StartingPermissions = $Permission[$GroupID]

                            # Remove spaces from property titles
                            $ObjectsForJsonData = ForEach ($Obj in $StartingPermissions) {
                                [PSCustomObject]@{
                                    #Path = $Obj.Item.Path
                                    #Access = ($Obj.Access.Access.Access | Sort-Object -Unique) -join ' ; '
                                    #SourceofAccess = ($Obj.Access.Access.SourceOfAccess | Sort-Object -Unique) -join ' ; '

                                    Item              = $Obj.Item
                                    Account           = $Obj.Account
                                    Access            = $Obj.Access
                                    DuetoMembershipIn = $Obj.'Due to Membership In'
                                    SourceofAccess    = $Obj.'Source of Access'
                                    Name              = $Obj.Name
                                    Department        = $Obj.Department
                                    Title             = $Obj.Title
                                }
                            }

                            $TableId = "Perms_$($GroupID -replace '[^A-Za-z0-9\-_]', '-')"
                            $Table = ConvertTo-BootstrapJavaScriptTable -Id $TableId -InputObject $StartingPermissions -DataFilterControl -AllColumnsSearchable -PageSize 25

                            [PSCustomObject]@{
                                PSTypeName = 'Permission.TargetPermissionList'
                                Columns    = Get-ColumnJson -InputObject $StartingPermissions -PropNames Item, Account, Access, 'Due to Membership In', 'Source of Access', Name, Department, Title
                                Data       = ConvertTo-Json -Compress -InputObject @($ObjectsForJsonData)
                                Div        = New-BootstrapDiv -Text ($Heading + $Table) -Class 'h-100 p-1 bg-light border rounded-3 table-responsive'
                                Grouping   = $GroupID
                                PassThru   = $ObjectsForJsonData
                                Table      = $TableId
                            }

                        }
                        break

                    }

                }

            }
            break

        }

        'prtgxml' {

            # Format the issues as a custom XML sensor for Paessler PRTG Network Monitor
            [PSCustomObject]@{
                PSTypeName = 'Permission.BestPracticeAnalysisPrtg'
                Data       = ConvertTo-PermissionPrtgXml -Analysis $Analysis
                PassThru   = $Analysis
            }
            break

        }

        'xml' {

            if (
                $GroupBy -eq 'none' -or
                $HowToSplit[$GroupBy]
            ) {

                [PSCustomObject]@{
                    PSTypeName = 'Permission.PermissionList'
                    Data       = ($Permission.Values | ConvertTo-Xml).InnerXml
                    PassThru   = $Permission.Values
                }

            } else {

                switch ($GroupBy) {

                    'account' {

                        ForEach ($Group in $PermissionGrouping) {
                            [PSCustomObject]@{
                                PSTypeName = 'Permission.AccountPermissionList'
                                Data       = ($Permission[$Group.Account.ResolvedAccountName] | ConvertTo-Xml).InnerXml
                                PassThru   = $Permission[$Account.ResolvedAccountName]
                                Grouping   = $Account.ResolvedAccountName
                            }
                        }
                        break

                    }

                    'item' {

                        ForEach ($Group in $PermissionGrouping) {
                            [PSCustomObject]@{
                                PSTypeName = 'Permission.ItemPermissionList'
                                Data       = ($Permission[$Group.Item.Path] | ConvertTo-Xml).InnerXml
                                PassThru   = $Permission[$Group.Item.Path]
                                Grouping   = $Group.Item.Path
                            }
                        }
                        break

                    }

                    'target' {

                        ForEach ($Group in $PermissionGrouping) {
                            [PSCustomObject]@{
                                PSTypeName = 'Permission.TargetPermissionList'
                                Data       = ($Permission[$Group.Path] | ConvertTo-Xml).InnerXml
                                PassThru   = $Permission[$Group.Path]
                                Grouping   = $Group.Path
                            }
                        }
                        break

                    }

                }

            }
            break

        }

    }

}
function ConvertTo-PermissionPrtgXml {

    param (
        [PSCustomObject]$Analysis
    )

    $IssuesDetected = $false

    # Group by item rather than by ACE
    # TODO: Do this for some of the other issue types
    $ItemsWithCreatorOwner = $Analysis.ACEsWithCreatorOwner.Path | Sort-Object -Unique

    # Count occurrences of each issue
    $CountItemsWithBrokenInheritance = $Analysis.ItemsWithBrokenInheritance.Count
    $CountACEsWithNonCompliantAccounts = $Analysis.ACEsWithNonCompliantAccounts.Count
    $CountACEsWithUsers = $Analysis.ACEsWithUsers.Count
    $CountACEsWithUnresolvedSIDs = $Analysis.ACEsWithUnresolvedSIDs.Count
    $CountItemsWithCreatorOwner = $ItemsWithCreatorOwner.Count

    # Use the counts to determine whether any issues occurred
    if (
        (
            $CountItemsWithBrokenInheritance +
            $CountACEsWithNonCompliantAccounts +
            $CountACEsWithUsers +
            $CountACEsWithUnresolvedSIDs +
            $CountItemsWithCreatorOwner
        ) -gt 0
    ) {
        $IssuesDetected = $true
    }

    $Channels = [System.Collections.Generic.List[String]]::new()

    # Build our XML output formatted for PRTG.
    $ChannelParams = @{
        MaxError   = 0.5
        Channel    = 'Folders with inheritance disabled'
        Value      = $CountItemsWithBrokenInheritance
        CustomUnit = 'folders'
    }
    $null = $Channels.Add((Format-PrtgXmlResult @ChannelParams))

    $ChannelParams = @{
        MaxError   = 0.5
        Channel    = 'ACEs for groups breaking naming convention'
        Value      = $CountACEsWithNonCompliantAccounts
        CustomUnit = 'ACEs'
    }
    $null = $Channels.Add((Format-PrtgXmlResult @ChannelParams))

    $ChannelParams = @{
        MaxError   = 0.5
        Channel    = 'ACEs for users instead of groups'
        Value      = $CountACEsWithUsers
        CustomUnit = 'ACEs'
    }
    $null = $Channels.Add((Format-PrtgXmlResult @ChannelParams))

    $ChannelParams = @{
        MaxError   = 0.5
        Channel    = 'ACEs for unresolvable SIDs'
        Value      = $CountACEsWithUnresolvedSIDs
        CustomUnit = 'ACEs'
    }
    $null = $Channels.Add((Format-PrtgXmlResult @ChannelParams))

    $ChannelParams = @{
        MaxError   = 0.5
        Channel    = "Folders with 'CREATOR OWNER' access"
        Value      = $CountItemsWithCreatorOwner
        CustomUnit = 'folders'
    }
    $null = $Channels.Add((Format-PrtgXmlResult @ChannelParams))

    Format-PrtgXmlSensorOutput -PrtgXmlResult $Channels -IssueDetected:$IssuesDetected

}
function ConvertTo-ScriptHtml {

    param (
        $Permission,
        $PermissionGrouping,
        [String]$GroupBy,
        [String]$Split
    )

    $ScriptHtmlBuilder = [System.Text.StringBuilder]::new()

    ForEach ($Group in $Permission) {
        $null = $ScriptHtmlBuilder.AppendLine((ConvertTo-BootstrapTableScript -TableId "#$($Group.Table)" -ColumnJson $Group.Columns -DataJson $Group.Data))
    }

    if ($GroupBy -ne 'none' -and $GroupBy -ne $Split) {
        $null = $ScriptHtmlBuilder.AppendLine((ConvertTo-BootstrapTableScript -TableId '#Folders' -ColumnJson $PermissionGrouping.Columns -DataJson $PermissionGrouping.Data))

    }

    return $ScriptHtmlBuilder.ToString()

}
function Expand-AccountPermissionReference {

    param (

        $Reference,
        [ref]$PrincipalsByResolvedID,
        [ref]$ACEsByGUID

    )

    ForEach ($Account in $Reference) {

        $Access = ForEach ($PermissionRef in $Account.Access) {

            [PSCustomObject]@{
                Path       = $PermissionRef.Path
                PSTypeName = 'Permission.AccountPermissionItemAccess'
                # Enumerate the list because the returned dictionary value is a list
                Access     = ForEach ($ACE in $ACEsByGUID.Value[$PermissionRef.AceGUIDs]) {
                    $ACE
                }
            }

        }

        [PSCustomObject]@{
            Account     = $PrincipalsByResolvedID.Value[$Account.Account]
            AccountName = $Account.Account
            Access      = $Access
            PSTypeName  = 'Permission.AccountPermission'
        }

    }

}
function Expand-FlatPermissionReference {

    # Expand each Access Control Entry with the Security Principal for the resolved IdentityReference.

    param (

        $SortedPath,
        [ref]$PrincipalsByResolvedID,
        [ref]$ACEsByGUID,
        [ref]$AceGUIDsByPath

    )

    ForEach ($Item in $SortedPath) {

        $AceGUIDs = $AceGUIDsByPath.Value[$Item]

        if (-not $AceGUIDs) { continue }

        ForEach ($ACE in $ACEsByGUID.Value[$AceGUIDs]) {

            Merge-AceAndPrincipal -ACE $ACE -Principal $PrincipalsByResolvedID.Value[$ACE.IdentityReferenceResolved] -PrincipalByResolvedID $PrincipalsByResolvedID

        }

    }

}
function Expand-ItemPermissionAccountAccessReference {

    param (
        $Reference,
        [ref]$PrincipalByResolvedID,
        [ref]$AceByGUID
    )

    if ($Reference) {

        if ($Reference -is [System.Collections.IEnumerable]) {
            $FirstRef = $Reference[0]
        } else {
            $FirstRef = $Reference
        }

        if ($FirstRef) {

            if ($FirstRef.AceGUIDs -is [System.Collections.IEnumerable]) {
                $FirstACEGuid = $FirstRef.AceGUIDs[0]
            } else {
                $FirstACEGuid = $FirstRef.AceGUIDs
            }

        }

        if ($FirstACEGuid) {
            $ACEList = $AceByGUID.Value[$FirstACEGuid]
        }

        if ($ACEList -is [System.Collections.IEnumerable]) {
            $FirstACE = $ACEList[0]
        } else {
            $FirstACE = $ACEList
        }

        $ACEProps = $FirstACE.PSObject.Properties.GetEnumerator().Name

    }

    ForEach ($PermissionRef in $Reference) {

        $Account = $PrincipalByResolvedID.Value[$PermissionRef.Account]

        [PSCustomObject]@{
            Account     = $Account
            AccountName = $PermissionRef.Account
            Access      = ForEach ($GuidList in $PermissionRef.AceGUIDs) {

                ForEach ($Guid in $GuidList) {

                    $ACE = $AceByGUID.Value[$Guid]

                    $OutputProperties = @{
                        Account = $Account
                    }

                    ForEach ($Prop in $ACEProps) {
                        $OutputProperties[$Prop] = $ACE.$Prop
                    }

                    [pscustomobject]$OutputProperties

                }
            }
            PSTypeName  = 'Permission.ItemPermissionAccountAccess'
        }

    }

}
function Expand-ItemPermissionReference {

    param (

        $Reference,
        [ref]$PrincipalsByResolvedID,
        [ref]$ACEsByGUID,
        [ref]$ACLsByPath

    )

    ForEach ($Item in $Reference) {

        [PSCustomObject]@{
            Item       = $ACLsByPath.Value[$Item.Path]
            Access     = Expand-ItemPermissionAccountAccessReference -Reference $Item.Access -AceByGUID $ACEsByGUID -PrincipalByResolvedID $PrincipalsByResolvedID
            PSTypeName = 'Permission.ItemPermission'
        }

    }

}
function Expand-TargetPermissionReference {

    # Expand each Access Control Entry with the Security Principal for the resolved IdentityReference.

    param (

        $Reference,
        [ref]$PrincipalsByResolvedID,
        [ref]$ACEsByGUID,
        [ref]$ACLsByPath,
        [ref]$AceGuidByPath,
        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item'

    )

    switch ($GroupBy) {

        'account' {

            ForEach ($Target in $Reference) {

                $TargetProperties = @{
                    PSTypeName = 'Permission.TargetPermission'
                    Path       = $Target.Path
                }

                # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $Target.NetworkPaths) {

                    [pscustomobject]@{
                        Item       = $AclsByPath.Value[$NetworkPath.Path]
                        PSTypeName = 'Permission.ParentItemPermission'
                        Accounts   = Expand-AccountPermissionReference -Reference $NetworkPath.Accounts -ACEsByGUID $ACEsByGUID -PrincipalsByResolvedID $PrincipalsByResolvedID
                    }

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

        'item' {

            ForEach ($Target in $Reference) {

                $TargetProperties = @{
                    Path = $Target.Path
                }

                # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $Target.NetworkPaths) {

                    [pscustomobject]@{
                        Access = Expand-ItemPermissionAccountAccessReference -Reference $NetworkPath.Access -AceByGUID $ACEsByGUID -PrincipalByResolvedID $PrincipalsByResolvedID
                        Item   = $AclsByPath.Value[$NetworkPath.Path]
                        Items  = ForEach ($TargetChild in $NetworkPath.Items) {

                            $Access = Expand-ItemPermissionAccountAccessReference -Reference $TargetChild.Access -AceByGUID $ACEsByGUID -PrincipalByResolvedID $PrincipalsByResolvedID

                            if ($Access) {

                                [pscustomobject]@{
                                    Access     = $Access
                                    Item       = $AclsByPath.Value[$TargetChild.Path]
                                    PSTypeName = 'Permission.ChildItemPermission'
                                }

                            }

                        }

                    }

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

        # 'none' and 'target' behave the same
        default {

            $ExpansionParameters = @{
                AceGUIDsByPath         = $AceGuidByPath
                ACEsByGUID             = $ACEsByGUID
                PrincipalsByResolvedID = $PrincipalsByResolvedID
            }

            ForEach ($Target in $Reference) {

                $TargetProperties = @{
                    PSTypeName = 'Permission.TargetPermission'
                    Path       = $Target.Path
                }

                # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $Target.NetworkPaths) {

                    [pscustomobject]@{
                        Access     = Expand-FlatPermissionReference -SortedPath $SortedPaths @ExpansionParameters
                        Item       = $AclsByPath[$NetworkPath.Path]
                        PSTypeName = 'Permission.FlatPermission'

                    }

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

    }

}
function Get-ColumnJson {

    # For the JSON that will be used by JavaScript to generate the table
    param (
        $InputObject,
        [string[]]$PropNames,
        [Hashtable]$ColumnDefinition = @{
            'Inheritance' = @{
                'width' = '1'
            }
        }
    )

    if (-not $PSBoundParameters.ContainsKey('PropNames')) {

        if ($InputObject -is [System.Collections.IEnumerable]) {
            $FirstInputObject = $InputObject[0]
        } else {
            $FirstInputObject = $InputObject
        }

        $PropNames = $FirstInputObject.PSObject.Properties.GetEnumerator().Name

    }

    $Columns = ForEach ($Prop in $PropNames) {

        $Props = $ColumnDefinition[$Prop]

        if ($Props) {

            $Props['field'] = $Prop -replace '\s', ''
            $Props['title'] = $Prop

        } else {

            $Props = @{
                'field' = $Prop -replace '\s', ''
                'title' = $Prop
            }

        }

        [PSCustomObject]$Props
    }

    $Columns |
    ConvertTo-Json -Compress

}
function Get-DetailDivHeader {

    param (
        [String]$GroupBy,
        [String]$Split
    )

    if ( $GroupBy -eq $Split ) {

        'Permissions'

    } else {

        switch ($GroupBy) {
            'account' { 'Folders Included in Those Permissions'; break }
            'item' { 'Accounts Included in Those Permissions'; break }
            'target' { 'Target Paths'; break }
            'none' { 'Permissions'; break }
        }

    }

}
function Get-FolderPermissionTableHeader {
    [OutputType([String])]
    param (
        $Group,
        [String]$GroupID,
        [String]$ShortestFolderPath
    )
    $Parent = $GroupID | Split-Path -Parent
    $Leaf = $Parent | Split-Path -Leaf -ErrorAction SilentlyContinue
    if ($Leaf) {
        $ParentLeaf = $Leaf
    } else {
        $ParentLeaf = $Parent
    }
    if ('' -ne $ParentLeaf) {
        if ($Group.Item.AreAccessRulesProtected) {
            return "Inheritance is disabled on this folder. Accounts with access to the parent ($ParentLeaf) and its subfolders cannot access this folder unless they are listed here:"
        } else {
            if ($Group.Item.Path -eq $ShortestFolderPath) {
                return "Inherited permissions from the parent ($ParentLeaf) are included. This folder can only be accessed by the accounts listed here:"
            } else {
                return "Inheritance is enabled on this folder. Accounts with access to the parent ($ParentLeaf) and its subfolders can access this folder. So can the accounts listed here:"
            }
        }
    } else {
        return 'This is the top-level folder. It can only be accessed by the accounts listed here:'
    }
}
function Get-HtmlBody {

    param (
        $NetworkPathDiv,
        $TableOfContents,
        $HtmlFolderPermissions,
        $ReportFooter,
        $HtmlFileList,
        $HtmlExclusions,
        $SummaryDivHeader,
        $DetailDivHeader
    )

    $StringBuilder = [System.Text.StringBuilder]::new()
    $null = $StringBuilder.Append((New-HtmlHeading 'Network Paths' -Level 5))
    $null = $StringBuilder.Append($NetworkPathDiv)

    if ($TableOfContents) {
        $null = $StringBuilder.Append((New-HtmlHeading $SummaryDivHeader -Level 5))
        $null = $StringBuilder.Append($TableOfContents)
    }

    $null = $StringBuilder.Append((New-HtmlHeading $DetailDivHeader -Level 5))

    ForEach ($Perm in $HtmlFolderPermissions) {
        $null = $StringBuilder.Append($Perm)
    }

    if ($HtmlExclusions) {
        $null = $StringBuilder.Append((New-HtmlHeading "Exclusions from This Report" -Level 5))
        $null = $StringBuilder.Append($HtmlExclusions)
    }

    $null = $StringBuilder.Append((New-HtmlHeading "Files Generated" -Level 5))
    $null = $StringBuilder.Append($HtmlFileList)
    $null = $StringBuilder.Append($ReportFooter)

    return $StringBuilder.ToString()

}
function Get-HtmlReportElements {

    # missing

    param (

        # Regular expressions matching names of security principals to exclude from the HTML report
        [string[]]$ExcludeAccount,

        # Accounts whose objectClass property is in this list are excluded from the HTML report
        [string[]]$ExcludeClass = @('group', 'computer'),

        <#
        Domain(s) to ignore (they will be removed from the username)
 
        Intended when a user has matching SamAccountNames in multiple domains but you only want them to appear once on the report.
 
        Can also be used to remove all domains simply for brevity in the report.
        #>

        $IgnoreDomain,

        # Path to the NTFS folder whose permissions are being exported
        [string[]]$TargetPath,

        # Network Path to the NTFS folder whose permissions are being exported
        $NetworkPath,

        # Group members are not being exported (only the groups themselves)
        [switch]$NoMembers,

        # Path to the folder to save the logs and reports generated by this script
        $OutputDir,

        # NTAccount caption of the user running the script
        $WhoAmI,

        # FQDN of the computer running the script
        $ThisFqdn,

        # Timer to measure progress and performance
        $StopWatch,

        # Title at the top of the HTML report
        $Title,

        $Permission,
        $LogParams,
        $RecurseDepth,
        $LogFileList,
        $ReportInstanceId,
        [Hashtable]$AceByGUID,
        [Hashtable]$AclByPath,
        [Hashtable]$PrincipalByID,

        <#
        Level of detail to export to file
            0 Item paths $TargetPath
            1 Resolved item paths (server names resolved, DFS targets resolved) $Parents
            2 Expanded resolved item paths (parent paths expanded into children) $AclByPath.Keys
            3 Access rules $AclByPath.Values
            4 Resolved access rules (server names resolved, inheritance flags resolved) $AceByGUID.Values | %{$_} | Sort Path,IdentityReferenceResolved
            5 Accounts with access $PrincipalByID.Values | %{$_} | Sort ResolvedAccountName
            6 Expanded resolved access rules (expanded with account info) $Permissions
            7 Formatted permissions $FormattedPermissions
            8 Best Practice issues $BestPracticeIssues
            9 XML custom sensor output for Paessler PRTG Network Monitor $PrtgXml
            10 Permission Report
        #>

        [int[]]$Detail = @(0..10),

        <#
        Information about the current culture settings.
        This includes information about the current language settings on the system, such as the keyboard layout, and the
        display format of items such as numbers, currency, and dates.
        #>

        [cultureinfo]$Culture = (Get-Culture),

        <#
        How to group the permissions in the output stream and within each exported file
 
            SplitBy GroupBy
            none none $FlatPermissions all in 1 file per $TargetPath
            none account $AccountPermissions all in 1 file per $TargetPath
            none item $ItemPermissions all in 1 file per $TargetPath (default behavior)
 
            item none 1 file per item in $ItemPermissions. In each file, $_.Access | sort account
            item account 1 file per item in $ItemPermissions. In each file, $_.Access | group account | sort name
            item item (same as -SplitBy item -GroupBy none)
 
            account none 1 file per item in $AccountPermissions. In each file, $_.Access | sort path
            account account (same as -SplitBy account -GroupBy none)
            account item 1 file per item in $AccountPermissions. In each file, $_.Access | group item | sort name
        #>

        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        [String]$Split,

        [String]$FileName,

        # Unused. Here so that the @PSBoundParameters hashtable in Out-PermissionReport can be used as a splat for this function.
        $FormattedPermission,

        # Unused. Here so that the @PSBoundParameters hashtable in Out-PermissionReport can be used as a splat for this function.
        $BestPracticeIssue,

        # Unused. Here so that the @PSBoundParameters hashtable in Out-PermissionReport can be used as a splat for this function.
        [string[]]$Parent,

        # Unused. Here so that the @PSBoundParameters hashtable in Out-PermissionReport can be used as a splat for this function.
        [string[]]$FileFormat,

        # Unused. Here so that the @PSBoundParameters hashtable in Out-PermissionReport can be used as a splat for this function.
        [String]$OutputFormat

    )

    Write-LogMsg @LogParams -Text "Get-ReportDescription -RecurseDepth $RecurseDepth"
    $ReportDescription = Get-ReportDescription -RecurseDepth $RecurseDepth

    $NetworkPathTable = Select-ItemTableProperty -InputObject $NetworkPath -Culture $Culture -SkipFilterCheck |
    ConvertTo-Html -Fragment |
    New-BootstrapTable

    $NetworkPathDivHeader = 'Local paths were resolved to UNC paths, and UNC paths were resolved to all DFS folder targets'
    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText '$NetworkPathDivHeader' -Content `$NetworkPathTable"
    $NetworkPathDiv = New-BootstrapDivWithHeading -HeadingText $NetworkPathDivHeader -Content $NetworkPathTable -Class 'h-100 p-1 bg-light border rounded-3 table-responsive' -HeadingLevel 6

    Write-LogMsg @LogParams -Text "Get-SummaryDivHeader -GroupBy $GroupBy"
    $SummaryDivHeader = Get-SummaryDivHeader -GroupBy $GroupBy -Split $Split

    Write-LogMsg @LogParams -Text "Get-SummaryTableHeader -RecurseDepth $RecurseDepth -GroupBy $GroupBy"
    $SummaryTableHeader = Get-SummaryTableHeader -RecurseDepth $RecurseDepth -GroupBy $GroupBy

    Write-LogMsg @LogParams -Text "Get-DetailDivHeader -GroupBy $GroupBy"
    $DetailDivHeader = Get-DetailDivHeader -GroupBy $GroupBy -Split $Split

    Write-LogMsg @LogParams -Text "New-HtmlHeading 'Target Paths' -Level 5"
    $TargetHeading = New-HtmlHeading 'Target Paths' -Level 5

    # Convert the target path(s) to a Bootstrap alert div
    $TargetPathString = $TargetPath -join '<br />'
    Write-LogMsg @LogParams -Text "New-BootstrapAlert -Class Dark -Text '$TargetPathString'"
    $TargetAlert = New-BootstrapAlert -Class Dark -Text $TargetPathString -AdditionalClasses ' small'

    # Add the target path div to the parameter splat for New-BootstrapReport
    $ReportParameters = @{
        Title       = $Title
        Description = "$TargetHeading $TargetAlert $ReportDescription"
    }

    # Build the divs showing the exclusions specified in the report parameters
    $ExcludedNames = ConvertTo-NameExclusionDiv -ExcludeAccount $ExcludeAccount
    $ExcludedClasses = ConvertTo-ClassExclusionDiv -ExcludeClass $ExcludeClass
    $IgnoredDomains = ConvertTo-IgnoredDomainDiv -IgnoreDomain $IgnoreDomain
    $ExcludedMembers = ConvertTo-MemberExclusionDiv -NoMembers:$NoMembers

    # Arrange the exclusion divs into two Bootstrap columns
    Write-LogMsg @LogParams -Text "New-BootstrapColumn -Html '`$ExcludedMembers`$ExcludedClasses',`$IgnoredDomains`$ExcludedNames"
    $ExclusionsDiv = New-BootstrapColumn -Html "$ExcludedMembers$ExcludedClasses", "$IgnoredDomains$ExcludedNames" -Width 6

    # Convert the list of generated log files to a Bootstrap list group
    $HtmlListOfLogs = $LogFileList |
    Split-Path -Leaf | # the output directory will already be shown in a Bootstrap alert above the list, so this row removes the path from the file names
    ConvertTo-HtmlList |
    ConvertTo-BootstrapListGroup

    # Prepare headings for 2 columns listing report and log files generated, respectively
    $HtmlReportsHeading = New-HtmlHeading -Text 'Reports' -Level 6
    $HtmlLogsHeading = New-HtmlHeading -Text 'Logs' -Level 6

    # Convert the output directory path to a Boostrap alert
    $HtmlOutputDir = New-BootstrapAlert -Text $OutputDir -Class 'secondary' -AdditionalClasses ' small'

    # Convert the list of detail levels and file formats to a hashtable of report files that will be generated
    $ReportFileList = ConvertTo-FileList -Detail $Detail -Format $Formats -FileName $FileName

    # Convert the hashtable of generated report files to a Bootstrap list group
    $HtmlReportsDiv = (ConvertTo-FileListDiv -FileList $ReportFileList) -join "`r`n"

    # Arrange the lists of generated files in two Bootstrap columns
    Write-LogMsg @LogParams -Text "New-BootstrapColumn -Html '`$HtmlReportsHeading`$HtmlReportsDiv',`$HtmlLogsHeading`$HtmlListOfLogs"
    $HtmlDivOfFileColumns = New-BootstrapColumn -Html "$HtmlReportsHeading$HtmlReportsDiv", "$HtmlLogsHeading$HtmlListOfLogs" -Width 6

    # Combine the alert and the columns of generated files inside a Bootstrap div
    Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText 'Output Folder:' -Content '`$HtmlOutputDir`$HtmlDivOfFileColumns'"
    $HtmlDivOfFiles = New-BootstrapDivWithHeading -HeadingText "Output Folder:" -Content "$HtmlOutputDir$HtmlDivOfFileColumns" -HeadingLevel 6

    # Generate a footer to include at the bottom of the report
    Write-LogMsg @LogParams -Text "Get-ReportFooter -StopWatch `$StopWatch -ReportInstanceId '$ReportInstanceId' -WhoAmI '$WhoAmI' -ThisFqdn '$ThisFqdn'"
    $FooterParams = @{
        ItemCount        = $AclByPath.Keys.Count
        PermissionCount  = (
            @(
                $Permission.AccountPermissions.Access.Access.Count, #SplitBy Account
                $Permission.ItemPermissions.Access.Access.Count,
                $Permission.TargetPermissions.NetworkPaths.Accounts.Access.Access.Count, # -SplitBy target -GroupBy account
                ($Permission.TargetPermissions.NetworkPaths.Items.Access.Access.Count + $Permission.TargetPermissions.NetworkPaths.Access.Access.Count), # -SplitBy target -GroupBy item
                $Permission.TargetPermissions.NetworkPaths.Access.Count, # -SplitBy target -GroupBy target/none
                $AceByGUID.Keys.Count
            ) |
            Measure-Object -Maximum
        ).Maximum
        PrincipalCount   = $PrincipalByID.Keys.Count
        ReportInstanceId = $ReportInstanceId
        StopWatch        = $StopWatch
        ThisFqdn         = $ThisFqdn
        WhoAmI           = $WhoAmI
        AceByGUID        = $AceByGUID
        AclByPath        = $AclByPath
    }
    $ReportFooter = Get-HtmlReportFooter @FooterParams

    [PSCustomObject]@{
        ReportFooter       = $ReportFooter
        HtmlDivOfFiles     = $HtmlDivOfFiles
        ExclusionsDiv      = $ExclusionsDiv
        ReportParameters   = $ReportParameters
        DetailDivHeader    = $DetailDivHeader
        SummaryTableHeader = $SummaryTableHeader
        SummaryDivHeader   = $SummaryDivHeader
        NetworkPathDiv     = $NetworkPathDiv
    }

}
function Get-HtmlReportFooter {
    param (

        # Stopwatch that was started when report generation began
        [System.Diagnostics.Stopwatch]$StopWatch,

        # NT Account caption (CONTOSO\User) of the account running this function
        [String]$WhoAmI = (whoami.EXE),

        <#
        FQDN of the computer running this function
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        [uint64]$ItemCount,

        [uint64]$TotalBytes,

        [String]$ReportInstanceId,

        [UInt64]$PermissionCount,

        [UInt64]$PrincipalCount,

        [string[]]$UnitsToResolve = @('day', 'hour', 'minute', 'second'),

        [Hashtable]$AceByGUID,

        [Hashtable]$AclByPath
    )

    $null = $StopWatch.Stop()
    $FinishTime = Get-Date
    $StartTime = $FinishTime.AddTicks(-$StopWatch.ElapsedTicks)
    $TimeZoneName = Get-TimeZoneName -Time $FinishTime
    $Duration = Format-TimeSpan -TimeSpan $StopWatch.Elapsed -UnitsToResolve $UnitsToResolve

    if ($TotalBytes) {
        $Size = " ($($TotalBytes / 1TB) TiB"
    }

    $Text = @"
Report generated by $WhoAmI on $ThisFQDN starting at $StartTime and ending at $FinishTime $TimeZoneName<br />
Processed $($AceByGUID.Keys.Count) ACEs with $PermissionCount permissions for $PrincipalCount accounts on $ItemCount items$Size in $Duration<br />
Report instance: $ReportInstanceId
"@


    New-BootstrapAlert -Class Light -Text $Text -AdditionalClasses ' small'

}
<#
$TagetPath.Count parent folders
$ItemCount total folders including children
$FolderPermissions folders with unique permissions
$Permissions.Count access control entries on those folders
$Identities.Count identities in those access control entries
$FormattedSecurityPrincipals principals represented by those identities
$UniqueAccountPermissions.Count unique accounts after filtering out any specified domain names
$ExpandedAccountPermissions.Count effective permissions belonging to those principals and applying to those folders
#>

function Get-ReportDescription {

    param (
        [int]$RecurseDepth
    )

    switch ($RecurseDepth ) {

        0 {
            'Does not include permissions on subfolders (option was declined)'; break
        }
        -1 {
            'Includes all subfolders with unique permissions (including ∞ levels of subfolders)'; break
        }
        default {
            "Includes all subfolders with unique permissions (down to $RecurseDepth levels of subfolders)"; break
        }

    }

}
function Get-SummaryDivHeader {

    param (
        [String]$GroupBy,
        [String]$Split
    )

    if ( $GroupBy -eq $Split ) {

        'Permissions'

    } else {

        switch ($GroupBy) {
            'account' { 'Accounts With Permissions'; break }
            'item' { 'Items in Those Paths with Unique Permissions'; break }
            'target' { 'Target Paths'; break }
            'none' { 'Permissions'; break }
        }

    }

}
function Get-SummaryTableHeader {
    param (
        [int]$RecurseDepth,
        [String]$GroupBy
    )

    switch ($GroupBy) {

        'account' {

            if ($NoMembers) {

                'Includes accounts directly listed in the permissions only (option to include group members was declined)'

            } else {

                'Includes accounts in the permissions, and their group members'

            }
            break

        }

        'item' {

            switch ($RecurseDepth ) {
                0 {
                    'Includes the target folder only (option to report on subfolders was declined)'
                    break
                }
                -1 {
                    'Includes the target folder and all subfolders with unique permissions'
                    break
                }
                default {
                    "Includes the target folder and $RecurseDepth levels of subfolders with unique permissions"
                    break
                }
            }
            break

        }

        'target' {
            break
        }

    }

}
function Group-AccountPermissionReference {

    param (
        [string[]]$ID,
        [ref]$AceGuidByID,
        [ref]$AceByGuid
    )

    $GuidType = [guid]

    ForEach ($Identity in ($ID | Sort-Object)) {

        $ItemPaths = New-PermissionCacheRef -Key ([string]) -Value ([System.Collections.Generic.List[guid]])

        ForEach ($Guid in $AceGuidByID.Value[$Identity]) {

            Add-PermissionCacheItem -Cache $ItemPaths -Key $AceByGuid.Value[$Guid].Path -Value $Guid -Type $GuidType

        }

        [PSCustomObject]@{
            Account = $Identity
            Access  = ForEach ($Item in ($ItemPaths.Keys | Sort-Object)) {

                [PSCustomObject]@{
                    Path     = $Item
                    AceGUIDs = $ItemPaths[$Item]
                }

            }

        }

    }

}
function Group-ItemPermissionReference {

    param (
        $SortedPath,
        [ref]$AceGUIDsByPath,
        [ref]$ACEsByGUID,
        [ref]$PrincipalsByResolvedID,
        [Hashtable]$Property = @{}
    )

    ForEach ($ItemPath in $SortedPath) {

        $Property['Path'] = $ItemPath
        $IDsWithAccess = Find-ResolvedIDsWithAccess -ItemPath $ItemPath -AceGUIDsByPath $AceGUIDsByPath -ACEsByGUID $ACEsByGUID -PrincipalsByResolvedID $PrincipalsByResolvedID

        $Property['Access'] = ForEach ($ID in ($IDsWithAccess.Value.Keys | Sort-Object)) {
            [PSCustomObject]@{
                Account  = $ID
                AceGUIDs = $IDsWithAccess.Value[$ID]
            }
        }

        [PSCustomObject]$Property

    }

}
function Group-TargetPermissionReference {

    # Expand each Access Control Entry with the Security Principal for the resolved IdentityReference.

    param (

        [ref]$TargetPath,
        [Hashtable]$Children,
        [ref]$PrincipalsByResolvedID,
        [ref]$AceGuidByID,
        [ref]$ACEsByGUID,
        [ref]$AceGUIDsByPath,
        [ref]$ACLsByPath,

        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item'

    )

    $CommonParams = @{
        AceGUIDsByPath         = $AceGUIDsByPath
        ACEsByGUID             = $ACEsByGUID
        PrincipalsByResolvedID = $PrincipalsByResolvedID
    }

    switch ($GroupBy) {

        'account' {

            ForEach ($Target in ($TargetPath.Value.Keys | Sort-Object)) {

                $TargetProperties = @{
                    Path = $Target
                }

                $NetworkPaths = $TargetPath.Value[$Target] | Sort-Object

                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $NetworkPaths) {

                    $ItemsForThisNetworkPath = [System.Collections.Generic.List[String]]::new()
                    $ItemsForThisNetworkPath.Add($NetworkPath)
                    $ItemsForThisNetworkPath.AddRange([string[]]$Children[$NetworkPath])
                    $IDsWithAccess = Find-ResolvedIDsWithAccess -ItemPath $ItemsForThisNetworkPath @CommonParams

                    # Prepare a dictionary for quick lookup of ACE GUIDs for this target
                    $AceGuidsForThisNetworkPath = @{}

                    # Enumerate the collection of ACE GUIDs for this target
                    ForEach ($Guid in $AceGUIDsByPath.Value[$ItemsForThisNetworkPath]) {

                        # Check for null (because we send a list into the dictionary for lookup, we receive a null result for paths that do not exist as a key in the dict)
                        if ($Guid) {

                            # The returned dictionary value is a lists of guids, so we need to enumerate the list
                            ForEach ($ListItem in $Guid) {

                                # Add each GUID to the dictionary for quick lookups
                                $AceGuidsForThisNetworkPath[$ListItem] = $true

                            }

                        }

                    }

                    $AceGuidByIDForThisNetworkPath = @{}

                    ForEach ($ID in $IDsWithAccess.Value.Keys) {

                        $GuidsForThisIDAndNetworkPath = [System.Collections.Generic.List[guid]]::new()

                        ForEach ($Guid in $AceGuidByID.Value[$ID]) {

                            $AceContainsThisID = $AceGuidsForThisNetworkPath[$Guid]

                            if ($AceContainsThisID) {
                                $GuidsForThisIDAndNetworkPath.Add($Guid)
                            }

                        }

                        $AceGuidByIDForThisNetworkPath[$ID] = $GuidsForThisIDAndNetworkPath

                    }

                    [PSCustomObject]@{
                        Path     = $NetworkPath
                        Accounts = Group-AccountPermissionReference -ID $IDsWithAccess.Value.Keys -AceGuidByID [ref]$AceGuidByIDForThisNetworkPath -AceByGuid $ACEsByGUID
                    }

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

        'item' {

            ForEach ($Target in ($TargetPath.Value.Keys | Sort-Object)) {

                $TargetProperties = @{
                    Path = $Target
                }

                $NetworkPaths = $TargetPath.Value[$Target] | Sort-Object

                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $NetworkPaths) {

                    $TopLevelItemProperties = @{
                        'Items' = Group-ItemPermissionReference -SortedPath ($Children[$NetworkPath] | Sort-Object) -ACLsByPath $ACLsByPath @CommonParams
                    }

                    Group-ItemPermissionReference -SortedPath $NetworkPath -Property $TopLevelItemProperties -ACLsByPath $ACLsByPath @CommonParams

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

        # 'none' and 'target' behave the same
        default {

            ForEach ($Target in ($TargetPath.Value.Keys | Sort-Object)) {

                $TargetProperties = @{
                    Path = $Target
                }

                $NetworkPaths = $TargetPath.Value[$Target] | Sort-Object

                $TargetProperties['NetworkPaths'] = ForEach ($NetworkPath in $NetworkPaths) {

                    $ItemsForThisNetworkPath = [System.Collections.Generic.List[String]]::new()
                    $ItemsForThisNetworkPath.Add($NetworkPath)
                    $ItemsForThisNetworkPath.AddRange([string[]]$Children[$NetworkPath])

                    [PSCustomObject]@{
                        Path   = $NetworkPath
                        Access = Expand-FlatPermissionReference -SortedPath $ItemsForThisNetworkPath @CommonParams
                    }

                }

                [pscustomobject]$TargetProperties

            }
            break

        }

    }

}
function Memory {
    <#
function SizeOfObj {
    param ([Type]$T, [Object]$thevalue, [System.Runtime.Serialization.ObjectIDGenerator]$gen)
    $type = $T
    [int]$returnval = 0
    if ($type.IsValueType) {
        $nulltype = [Nullable]::GetUnderlyingType($type)
        $returnval = [System.Runtime.InteropServices.Marshal]::SizeOf($nulltype ?? $type)
    } elseif ($null -eq $thevalue) {
        return 0
    } elseif ($thevalue.GetType().Name -eq 'String') {
        $returnval = ([System.Text.Encoding]::Default).GetByteCount([String]$thevalue)
    } elseif (
        $type.IsArray -and
        $type.GetElementType().IsValueType
    ) {
        $returnval = $thevalue.GetLength(0) * [System.Runtime.InteropServices.Marshal]::SizeOf($type.GetElementType())
    } elseif ($thevalue.GetType.Name -eq 'Stream') {
        [System.IO.Stream]$stream = [System.IO.Stream]$thevalue
        $returnval = [int]($stream.Length)
    } elseif ($type.IsSerializable) {
        try {
            [System.IO.MemoryStream]$s = [System.IO.MemoryStream]::new()
            [System.Runtime.Serialization.Formatters.Binary.BinaryFormatter]$formatter = [System.Runtime.Serialization.Formatters.Binary.BinaryFormatter]::new()
            $formatter.Serialize($s, $thevalue)
            $returnval = [int]($s.Length)
        } catch { }
    } elseif ($type.IsClass) {
        $returnval += SizeOfClass -thevalue $thevalue -gen ($gen ?? [System.Runtime.Serialization.ObjectIDGenerator]::new())
    }
    if ($returnval -eq 0) {
        try {
            $returnval = [System.Runtime.InteropServices.Marshal]::SizeOf($thevalue)
        } catch { }
    }
    return $returnval
}
function SizeOf {
    param ($T, $value)
    SizeOfObj -T ($T.GetType()) -thevalue $value -gen $null
}
function SizeOfClass {
    param (
        [Object]$thevalue, [System.Runtime.Serialization.ObjectIDGenerator]$gen
    )
    [bool]$isfirstTime = $null
    $gen.GetId($thevalue, [ref]$isfirstTime)
    if (-not $isfirstTime) { return 0 }
    $fields = $thevalue.GetType().GetFields([System.Reflection.BindingFlags]::NonPublic -bor [System.Reflection.BindingFlags]::Instance)
 
    [int]$returnval = 0
    for ($i = 0; $i -lt $fields.Length; $i++) {
        [Type]$t = $fields[$i].FieldType
        [Object]$v = $fields[$i].GetValue($thevalue)
        $returnval += 4 + (SizeOfObj -T $t -thevalue $v -gen $gen)
    }
    return $returnval
}
 
$Test = @{}
 
$n = 1000000
$i = 0
while ($i -lt $n) {
    $Test[$i] = [pscustomobject]@{prop1 = 'blah'}
    $i++
}
$Size = (SizeOf -t [Hashtable] -value $Test)/1KB
"$Size KiB"
#>

}
function Merge-AceAndPrincipal {

    param (
        $Principal,
        $ACE,
        [ref]$PrincipalByResolvedID
    )

    ForEach ($Member in $Principal.Members) {

        Merge-AceAndPrincipal -ACE $ACE -Principal $PrincipalByResolvedID.Value[$Member] -PrincipalByResolvedID $PrincipalByResolvedID

    }

    $OutputProperties = @{
        PSTypeName  = 'Permission.FlatPermission'
        ItemPath    = $ACE.Path
        AdsiPath    = $Principal.Path
        AccountName = $Principal.ResolvedAccountName
    }

    ForEach ($Prop in $ACE.PSObject.Properties.GetEnumerator().Name) {
        $OutputProperties[$Prop] = $ACE.$Prop
    }

    ForEach ($Prop in $Principal.PSObject.Properties.GetEnumerator().Name) {
        $OutputProperties[$Prop] = $Principal.$Prop
    }

    return [pscustomobject]$OutputProperties

}
function New-PermissionCacheRef {

    param (

        # Type of the keys
        [type]$Key = [System.String],

        # Type of the values
        [type]$Value = [System.Collections.Generic.List[System.Object]]

    )

    $genericTypeDefinition = [System.Collections.Concurrent.ConcurrentDictionary`2]
    $genericType = $genericTypeDefinition.MakeGenericType($Key, $Value)
    return [ref][Activator]::CreateInstance($genericType)

}
function Out-PermissionDetailReport {

    param (
        [int[]]$Detail,
        [Hashtable]$ReportObject,
        [scriptblock[]]$DetailExport,
        [String]$Format,
        [String]$OutputDir,
        [cultureinfo]$Culture,
        [string[]]$DetailString,
        [String]$FileName,
        [String]$FormatToReturn = 'js',
        [int]$LevelToReturn = 10
    )

    switch ($Format) {
        'csv' { $Suffix = '.csv' ; break }
        'html' { $Suffix = "_$FileName.htm" ; break }
        'js' { $Suffix = "_$Format`_$FileName.htm" ; break }
        'json' { $Suffix = "_$FileName.json" ; break }
        'prtgxml' { $Suffix = '.xml' ; break }
        'xml' { $Suffix = '.xml' ; break }
    }

    ForEach ($Level in $Detail) {

        # Get shorter versions of the detail strings to use in file names
        $ShortDetail = $DetailString[$Level] -replace '\([^\)]*\)', ''

        # Convert the shorter strings to Title Case
        $TitleCaseDetail = $Culture.TextInfo.ToTitleCase($ShortDetail)

        # Remove spaces from the shorter strings
        $SpacelessDetail = $TitleCaseDetail -replace '\s', ''

        # Build the file path
        $ThisReportFile = "$OutputDir\$Level`_$SpacelessDetail$Suffix"

        # Generate the report
        $Report = $ReportObject[$Level]

        # Save the report
        $null = Invoke-Command -ScriptBlock $DetailExport[$Level] -ArgumentList $Report, $ThisReportFile

        # Output the name of the report file to the Information stream
        Write-Information $ThisReportFile

        # Return the report file path of the highest level for the Interactive switch of Export-Permission
        if ($Level -eq $LevelToReturn -and $Format -eq $FormatToReturn) {
            $ThisReportFile
        }

    }

}
function Resolve-Ace {

    <#
    .SYNOPSIS
    Use ADSI to lookup info about IdentityReferences from Authorization Rule Collections that came from Discretionary Access Control Lists
    .DESCRIPTION
    Based on the IdentityReference proprety of each Access Control Entry:
    Resolve SID to NT account name and vise-versa
    Resolve well-known SIDs
    Resolve generic defaults like 'NT AUTHORITY' and 'BUILTIN' to the applicable computer or domain name
    Add these properties (IdentityReferenceSID,IdentityReferenceResolved) to the object and return it
    .INPUTS
    [System.Security.AccessControl.AuthorizationRuleCollection]$ACE
    .OUTPUTS
    [PSCustomObject] Original object plus IdentityReferenceSID,IdentityReferenceResolved, and AdsiProvider properties
    .EXAMPLE
    Get-Acl |
    Expand-Acl |
    Resolve-Ace
 
    Use Get-Acl from the Microsoft.PowerShell.Security module as the source of the access list
    This works in either Windows Powershell or in Powershell
    Get-Acl does not support long paths (>256 characters)
    That was why I originally used the .Net Framework method
    .EXAMPLE
    Get-FolderAce -LiteralPath C:\Test -IncludeInherited |
    Resolve-Ace
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner
    $FileSecurity = [System.Security.AccessControl.FileSecurity]::new($DirectoryInfo,$Sections)
    $IncludeExplicitRules = $true
    $IncludeInheritedRules = $true
    $AccountType = [System.Security.Principal.SecurityIdentifier]
    $FileSecurity.GetAccessRules($IncludeExplicitRules,$IncludeInheritedRules,$AccountType) |
    Resolve-Ace
 
    This uses .Net Core as the source of the access list
    It uses the GetAccessRules method on the [System.Security.AccessControl.FileSecurity] class
    The targetType parameter of the method is used to specify that the accounts in the ACL are returned as SIDs
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor
    [System.Security.AccessControl.AccessControlSections]::Owner -bor
    [System.Security.AccessControl.AccessControlSections]::Group
    $DirectorySecurity = [System.Security.AccessControl.DirectorySecurity]::new($DirectoryInfo,$Sections)
    $IncludeExplicitRules = $true
    $IncludeInheritedRules = $true
    $AccountType = [System.Security.Principal.NTAccount]
    $FileSecurity.GetAccessRules($IncludeExplicitRules,$IncludeInheritedRules,$AccountType) |
    Resolve-Ace
 
    This uses .Net Core as the source of the access list
    It uses the GetAccessRules method on the [System.Security.AccessControl.FileSecurity] class
    The targetType parameter of the method is used to specify that the accounts in the ACL are returned as NT account names (DOMAIN\User)
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    [System.Security.AccessControl.DirectorySecurity]$DirectorySecurity = $DirectoryInfo.GetAccessControl('Access')
    [System.Security.AccessControl.AuthorizationRuleCollection]$AuthRules = $DirectorySecurity.Access
    $AuthRules | Resolve-Ace
 
    Use the .Net Framework (or legacy .Net Core up to 2.2) as the source of the access list
    Only works in Windows PowerShell
    Those versions of .Net had a GetAccessControl method on the [System.IO.DirectoryInfo] class
    This method is removed in modern versions of .Net Core
 
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner
    $FileSecurity = [System.IO.FileSystemAclExtensions]::GetAccessControl($DirectoryInfo,$Sections)
 
    The [System.IO.FileSystemAclExtensions] class is a Windows-specific implementation
    It provides no known benefit over the cross-platform equivalent [System.Security.AccessControl.FileSecurity]
 
    .NOTES
    Dependencies:
        Get-DirectoryEntry
        Add-SidInfo
        Get-TrustedDomain
        Find-AdsiProvider
 
    if ($FolderPath.Length -gt 255) {
        $FolderPath = "\\?\$FolderPath"
    }
 
    TODO: add a param to offer DNS instead of or in addition to NetBIOS
    #>


    [OutputType([void])]

    param (

        # Authorization Rule Collection of Access Control Entries from Discretionary Access Control Lists
        [object]$ACE,

        [object]$ItemPath,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        [string[]]$ACEPropertyName = $ACE.PSObject.Properties.GetEnumerator().Name,

        # Will be set as the Source property of the output object.
        # Intended to reflect permissions resulting from Ownership rather than Discretionary Access Lists
        [String]$Source,

        # String translations indexed by value in the [System.Security.AccessControl.InheritanceFlags] enum
        # Parameter default value is on a single line as a workaround to a PlatyPS bug
        [string[]]$InheritanceFlagResolved = @('this folder but not subfolders', 'this folder and subfolders', 'this folder and files, but not subfolders', 'this folder, subfolders, and files'),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache,

        # GUID type (can be provided to avoid repetitive instantiation)
        [type]$Type = [guid]

    )

    #$Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $Cache.Value['LogBuffer'] ; WhoAmI = $WhoAmI }
    $Splat = @{ ThisHostname = $ThisHostname ; Cache = $Cache ; WhoAmI = $WhoAmI ; DebugOutputStream = $DebugOutputStream ; ThisFqdn = $ThisFqdn }
    $GetAdsiServerParams = @{ WellKnownSIDBySID = $WellKnownSIDBySID ; WellKnownSIDByName = $WellKnownSIDByName }

    #Write-LogMsg @Log -Text "Resolve-IdentityReferenceDomainDNS -IdentityReference '$($ACE.IdentityReference)' -ItemPath '$ItemPath'" -Expand $Splat -Suffix " # For ACE IdentityReference '$($ACE.IdentityReference)' # For ItemPath '$ItemPath'"
    $DomainDNS = Resolve-IdentityReferenceDomainDNS -IdentityReference $ACE.IdentityReference -ItemPath $ItemPath @Splat

    #Write-LogMsg @Log -Text "`$AdsiServer = Get-AdsiServer -Fqdn '$DomainDNS'" -Expand $GetAdsiServerParams, $Splat -Suffix " # For ACE IdentityReference '$($ACE.IdentityReference)' # For ItemPath '$ItemPath'"
    $AdsiServer = Get-AdsiServer -Fqdn $DomainDNS @GetAdsiServerParams @Splat

    #Write-LogMsg @Log -Text "Resolve-IdentityReference -IdentityReference '$($ACE.IdentityReference)' -AdsiServer `$AdsiServer" -Expand $Splat -Suffix " # ADSI server '$($AdsiServer.AdsiProvider)://$($AdsiServer.Dns)' # For ACE IdentityReference '$($ACE.IdentityReference)' # For ItemPath '$ItemPath'"
    $ResolvedIdentityReference = Resolve-IdentityReference -IdentityReference $ACE.IdentityReference -AdsiServer $AdsiServer @Splat

    $ObjectProperties = @{
        Access                    = "$($ACE.AccessControlType) $($ACE.FileSystemRights) $($InheritanceFlagResolved[$ACE.InheritanceFlags])"
        AdsiProvider              = $AdsiServer.AdsiProvider
        AdsiServer                = $DomainDNS
        IdentityReferenceSID      = $ResolvedIdentityReference.SIDString
        IdentityReferenceResolved = $ResolvedIdentityReference.IdentityReferenceNetBios
        Path                      = $ItemPath
        SourceOfAccess            = $Source
        PSTypeName                = 'Permission.AccessControlEntry'
    }

    ForEach ($ThisProperty in $ACEPropertyName) {
        $ObjectProperties[$ThisProperty] = $ACE.$ThisProperty
    }

    $OutputObject = [PSCustomObject]$ObjectProperties
    $Guid = [guid]::NewGuid()
    Add-PermissionCacheItem -Cache $Cache.Value['AceByGuid'] -Key $Guid -Value $OutputObject -Type ([object])
    Add-PermissionCacheItem -Cache $Cache.Value['AceGuidById'] -Key $OutputObject.IdentityReferenceResolved -Value $Guid -Type $Type
    Add-PermissionCacheItem -Cache $Cache.Value['AceGuidByPath'] -Key $OutputObject.Path -Value $Guid -Type $Type

}
function Resolve-Acl {
    <#
    .SYNOPSIS
    Use ADSI to lookup info about IdentityReferences from Authorization Rule Collections that came from Discretionary Access Control Lists
    .DESCRIPTION
    Based on the IdentityReference proprety of each Access Control Entry:
    Resolve SID to NT account name and vise-versa
    Resolve well-known SIDs
    Resolve generic defaults like 'NT AUTHORITY' and 'BUILTIN' to the applicable computer or domain name
    Add these properties (IdentityReferenceSID,IdentityReferenceName,IdentityReferenceResolved) to the object and return it
    .INPUTS
    [System.Security.AccessControl.AuthorizationRuleCollection]$ItemPath
    .OUTPUTS
    [PSCustomObject] Original object plus IdentityReferenceSID,IdentityReferenceName,IdentityReferenceResolved, and AdsiProvider properties
    .EXAMPLE
    Get-Acl |
    Expand-Acl |
    Resolve-Ace
 
    Use Get-Acl from the Microsoft.PowerShell.Security module as the source of the access list
    This works in either Windows Powershell or in Powershell
    Get-Acl does not support long paths (>256 characters)
    That was why I originally used the .Net Framework method
    .EXAMPLE
    Get-FolderAce -LiteralPath C:\Test -IncludeInherited |
    Resolve-Ace
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner
    $FileSecurity = [System.Security.AccessControl.FileSecurity]::new($DirectoryInfo,$Sections)
    $IncludeExplicitRules = $true
    $IncludeInheritedRules = $true
    $AccountType = [System.Security.Principal.SecurityIdentifier]
    $FileSecurity.GetAccessRules($IncludeExplicitRules,$IncludeInheritedRules,$AccountType) |
    Resolve-Ace
 
    This uses .Net Core as the source of the access list
    It uses the GetAccessRules method on the [System.Security.AccessControl.FileSecurity] class
    The targetType parameter of the method is used to specify that the accounts in the ACL are returned as SIDs
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor
    [System.Security.AccessControl.AccessControlSections]::Owner -bor
    [System.Security.AccessControl.AccessControlSections]::Group
    $DirectorySecurity = [System.Security.AccessControl.DirectorySecurity]::new($DirectoryInfo,$Sections)
    $IncludeExplicitRules = $true
    $IncludeInheritedRules = $true
    $AccountType = [System.Security.Principal.NTAccount]
    $FileSecurity.GetAccessRules($IncludeExplicitRules,$IncludeInheritedRules,$AccountType) |
    Resolve-Ace
 
    This uses .Net Core as the source of the access list
    It uses the GetAccessRules method on the [System.Security.AccessControl.FileSecurity] class
    The targetType parameter of the method is used to specify that the accounts in the ACL are returned as NT account names (DOMAIN\User)
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    [System.Security.AccessControl.DirectorySecurity]$DirectorySecurity = $DirectoryInfo.GetAccessControl('Access')
    [System.Security.AccessControl.AuthorizationRuleCollection]$AuthRules = $DirectorySecurity.Access
    $AuthRules | Resolve-Ace
 
    Use the .Net Framework (or legacy .Net Core up to 2.2) as the source of the access list
    Only works in Windows PowerShell
    Those versions of .Net had a GetAccessControl method on the [System.IO.DirectoryInfo] class
    This method is removed in modern versions of .Net Core
 
    .EXAMPLE
    [String]$FolderPath = 'C:\Test'
    [System.IO.DirectoryInfo]$DirectoryInfo = Get-Item -LiteralPath $FolderPath
    $Sections = [System.Security.AccessControl.AccessControlSections]::Access -bor [System.Security.AccessControl.AccessControlSections]::Owner
    $FileSecurity = [System.IO.FileSystemAclExtensions]::GetAccessControl($DirectoryInfo,$Sections)
 
    The [System.IO.FileSystemAclExtensions] class is a Windows-specific implementation
    It provides no known benefit over the cross-platform equivalent [System.Security.AccessControl.FileSecurity]
 
    .NOTES
    Dependencies:
        Get-DirectoryEntry
        Add-SidInfo
        Get-TrustedDomain
        Find-AdsiProvider
 
    if ($FolderPath.Length -gt 255) {
        $FolderPath = "\\?\$FolderPath"
    }
#>

    [OutputType([PSCustomObject])]
    param (

        # Authorization Rule Collection of Access Control Entries from Discretionary Access Control Lists
        [object]$ItemPath,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        [string[]]$ACEPropertyName = $ItemPath.PSObject.Properties.GetEnumerator().Name,

        # String translations indexed by value in the [System.Security.AccessControl.InheritanceFlags] enum
        # Parameter default value is on a single line as a workaround to a PlatyPS bug
        [string[]]$InheritanceFlagResolved = @('this folder but not subfolders', 'this folder and subfolders', 'this folder and files, but not subfolders', 'this folder, subfolders, and files'),

        # In-process cache to reduce calls to other processes or to disk
        [Parameter(Mandatory)]
        [ref]$Cache

    )

    #$Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $Cache.Value['LogBuffer'] ; WhoAmI = $WhoAmI }

    $ResolveAceSplat = @{
        Cache = $Cache ; ThisHostName = $ThisHostName ; ThisFqdn = $ThisFqdn ; Type = [guid] ; WhoAmI = $WhoAmI ; ItemPath = $ItemPath ;
        DebugOutputStream = $DebugOutputStream ; ACEPropertyName = $ACEPropertyName ; InheritanceFlagResolved = $InheritanceFlagResolved
    }

    $ACL = $Cache.Value['AclByPath'].Value[$ItemPath]

    if ($ACL.Owner.IdentityReference) {

        #Write-LogMsg @Log -Text "Resolve-Ace -ACE `$ACL.Owner -ACEPropertyName @('$($ACEPropertyName -join "','")') @ResolveAceSplat # For Owner IdentityReference '$($ACL.Owner.IdentityReference)' # For ItemPath '$ItemPath'"
        Resolve-Ace -ACE $ACL.Owner -Source 'Ownership' @ResolveAceSplat

    }

    ForEach ($ACE in $ACL.Access) {

        #Write-LogMsg @Log -Text "Resolve-Ace -ACE `$ACE -ACEPropertyName @('$($ACEPropertyName -join "','")') @ResolveAceSplat # For ACE IdentityReference '$($ACE.IdentityReference)' # For ItemPath '$ItemPath'"
        Resolve-Ace -ACE $ACE -Source 'Discretionary ACL' @ResolveAceSplat

    }

}
function Resolve-Folder {

    # Resolve the provided FolderPath to all of its associated UNC paths, including all DFS folder targets

    param (

        # Path of the folder(s) to resolve to all their associated UNC paths
        [String]$TargetPath,

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $LogBuffer = $Cache.Value['LogBuffer']

    $Log = @{
        Buffer       = $LogBuffer
        ThisHostname = $ThisHostname
        Type         = $DebugOutputstream
        WhoAmI       = $WhoAmI
    }

    $LogThis = @{
        ThisHostname      = $ThisHostname
        DebugOutputStream = $DebugOutputStream
        WhoAmI            = $WhoAmI
    }

    $RegEx = '^(?<DriveLetter>\w):'

    if ($TargetPath -match $RegEx) {

        $GetCimInstanceParams = @{
            Cache       = $Cache
            ClassName   = 'Win32_MappedLogicalDisk'
            KeyProperty = 'DeviceID'
            ThisFqdn    = $ThisFqdn
        }

        Write-LogMsg @Log -Text "Get-CachedCimInstance -ComputerName $ThisHostname -ClassName Win32_MappedLogicalDisk"
        $MappedNetworkDrives = Get-CachedCimInstance -ComputerName $ThisHostname @GetCimInstanceParams @LogThis

        $MatchingNetworkDrive = $MappedNetworkDrives |
        Where-Object -FilterScript { $_.DeviceID -eq "$($Matches.DriveLetter):" }

        if ($MatchingNetworkDrive) {
            # Resolve mapped network drives to their UNC path
            $UNC = $MatchingNetworkDrive.ProviderName
        } else {
            # Resolve local drive letters to their UNC paths using administrative shares
            $UNC = $TargetPath -replace $RegEx, "\\$(hostname)\$($Matches.DriveLetter)$"
        }

        if ($UNC) {
            # Replace hostname with FQDN in the path
            $Server = $UNC.split('\')[2]
            $FQDN = ConvertTo-PermissionFqdn -ComputerName $Server @Cache @LogThis
            $UNC -replace "^\\\\$Server\\", "\\$FQDN\"
        }

    } else {

        ## Workaround in place: Get-NetDfsEnum -Verbose parameter is not used due to errors when it is used with the PsRunspace module for multithreading
        ## https://github.com/IMJLA/Export-Permission/issues/46
        ## https://github.com/IMJLA/PsNtfs/issues/1
        Write-LogMsg @Log -Text "Get-NetDfsEnum -FolderPath '$TargetPath'"
        $AllDfs = Get-NetDfsEnum -FolderPath $TargetPath -ErrorAction SilentlyContinue

        if ($AllDfs) {

            $MatchingDfsEntryPaths = $AllDfs |
            Group-Object -Property DfsEntryPath |
            Where-Object -FilterScript {
                $TargetPath -match [regex]::Escape($_.Name)
            }

            # Filter out the DFS Namespace
            # TODO: I know this is an inefficient n2 algorithm, but my brain is fried...plez...halp...leeloo dallas multipass
            $RemainingDfsEntryPaths = $MatchingDfsEntryPaths |
            Where-Object -FilterScript {
                -not [bool]$(
                    ForEach ($ThisEntryPath in $MatchingDfsEntryPaths) {
                        if ($ThisEntryPath.Name -match "$([regex]::Escape("$($_.Name)")).+") { $true }
                    }
                )
            } |
            Sort-Object -Property Name

            $RemainingDfsEntryPaths |
            Select-Object -Last 1 -ExpandProperty Group |
            ForEach-Object {
                $_.FullOriginalQueryPath -replace [regex]::Escape($_.DfsEntryPath), $_.DfsTarget
            }

        } else {

            $Server = $TargetPath.split('\')[2]
            $FQDN = ConvertTo-PermissionFqdn -ComputerName $Server @Cache @LogThis
            $TargetPath -replace "^\\\\$Server\\", "\\$FQDN\"

        }

    }

}
function Resolve-FormatParameter {
    param (

        # File formats to export
        [ValidateSet('csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [string[]]$FileFormat = @('csv', 'html', 'js', 'json', 'prtgxml', 'xml'),

        # Type of output returned to the output stream
        [ValidateSet('passthru', 'none', 'csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [String]$OutputFormat = 'passthru'

    )

    $AllFormats = @{}

    ForEach ($Format in $FileFormat) {
        $AllFormats[$Format] = $null
    }

    if ($OutputFormat -ne 'passthru' -and $OutputFormat -ne 'none') {
        $AllFormats[$OutputFormat] = $null
    }

    # Sort the results in descending order to ensure json comes before js.
    # This is because the js report uses the json formatted data
    # So, in Format-Permission, the objects in the output hashtable are formatted with json properties rather than js properties even for the js report format
    # However, ConvertTo-PermissionGroup/List are exclusive to js but not json reports so they output nothing for json
    # Having json run first means that the "nothing" results will then be overwritten by the valid json results
    $Sorted = [string[]]$AllFormats.Keys | Sort-Object -Descending

    return $Sorted

}
function Resolve-GroupByParameter {
    param (

        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        [Hashtable]$HowToSplit

    )

    if (
        $GroupBy -eq 'none' -or
        $HowToSplit[$GroupBy]
    ) {

        return @{
            Property = 'Access'
            Script   = [scriptblock]::create("Select-PermissionTableProperty -InputObject `$args[0] -ShortNameById `$args[2] -IncludeFilterContents `$args[3] -ExcludeClassFilterContents `$args[4]")
        }

    } else {

        return @{
            Property = "$GroupBy`s"
            Script   = [scriptblock]::create("Select-$GroupBy`TableProperty -InputObject `$args[0] -Culture `$args[1] -ShortNameById `$args[2]")
        }

    }

}
function Resolve-IdentityReferenceDomainDNS {

    param (

        # An NTAccount caption string, or similarly-formatted SID from an item's ACL.
        [String]$IdentityReference,

        # Network path of the item whose ACL the IdentityReference parameter value is from.
        [object]$ItemPath,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Output from Get-KnownSidHashTable
        [hashtable]$WellKnownSidBySid = (Get-KnownSidHashTable),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Log = @{
        Buffer       = $Cache.Value['LogBuffer']
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    $LogThis = @{
        Cache        = $Cache
        ThisHostname = $ThisHostname
        WhoAmI       = $WhoAmI
    }

    if ($WellKnownSidBySid[$IdentityReference]) {

        # IdentityReference is a well-known SID of a local account.
        # For local accounts, the domain is the computer hosting the network resource.
        # This can be extracted from the network path of the item whose ACL IdentityReference is from.
        $DomainDNS = Find-ServerNameInPath -LiteralPath $ItemPath -ThisFqdn $ThisFqdn
        return $DomainDNS

    }

    if ($IdentityReference.Substring(0, 4) -eq 'S-1-') {

        # IdentityReference should be a SID (Revision 1).
        $IndexOfLastHyphen = $IdentityReference.LastIndexOf('-')
        $DomainSid = $IdentityReference.Substring(0, $IndexOfLastHyphen)

        if ($DomainSid) {

            # IdentityReference appears to be a properly-formatted SID. Its domain SID was able to be parsed.
            $DomainCacheResult = $null

            if ($Cache.Value['DomainBySid'].Value.TryGetValue( $DomainSid, [ref]$DomainCacheResult )) {

                # IdentityReference belongs to a known domain.
                #Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Domain SID '$DomainSid' # Domain SID cache hit"
                return $DomainCacheResult.Dns

            }

            #Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Domain SID '$DomainSid' # Domain SID cache miss"
            $KnownSid = Get-KnownSid -SID $IdentityReference

            if ($KnownSid) {

                #Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Domain SID '$DomainSid' # Known SID pattern match"
                $DomainDNS = Find-ServerNameInPath -LiteralPath $ItemPath -ThisFqdn $ThisFqdn
                return $DomainDNS

            }

            # IdentityReference belongs to an unknown domain.
            $Log['Type'] = 'Warning'
            Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Domain SID '$DomainSid' # Unknown domain (possibly offline). Unable to resolve domain FQDN"
            return $DomainSid

        }

        # IdentityReference is not a properly-formatted SID.
        $Log['Type'] = 'Error'
        Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Bug before Resolve-IdentityReferenceDomainDNS. Unable to resolve a DNS FQDN due to malformed SID"
        return $IdentityReference

    }

    # IdentityReference should be an NTAccount caption
    $DomainNetBIOS = ($IdentityReference.Split('\'))[0]

    if ($DomainNetBIOS) {

        # IdentityReference appears to be a properly-formatted NTAccount caption. Its domain was able to be parsed.

        $KnownLocalDomains = @{
            'NT SERVICE'   = $true
            'BUILTIN'      = $true
            'NT AUTHORITY' = $true
        }

        $DomainCacheResult = $KnownLocalDomains[$DomainNetBIOS]

        if ($DomainCacheResult) {

            # IdentityReference belongs to a well-known local domain.
            # For local accounts, the domain is the computer hosting the network resource.
            # This can be extracted from the network path of the item whose ACL IdentityReference is from.
            $DomainDNS = Find-ServerNameInPath -LiteralPath $ItemPath -ThisFqdn $ThisFqdn
            return $DomainDNS

        }

        $DomainCacheResult = $null

        if ($Cache.Value['DomainByNetbios'].Value.TryGetValue( $DomainNetBIOS, [ref]$DomainCacheResult )) {

            # IdentityReference belongs to a known domain.
            return $DomainCacheResult.Dns

        }

        # IdentityReference belongs to an unnown domain.
        # Attempt live translation to the domain's DistinguishedName then convert that to FQDN.
        $ThisServerDn = ConvertTo-DistinguishedName -Domain $DomainNetBIOS -ThisFqdn $ThisFqdn @LogThis
        $DomainDNS = ConvertTo-Fqdn -DistinguishedName $ThisServerDn -ThisFqdn $ThisFqdn @LogThis
        return $DomainDNS

    }

    $Log['Type'] = 'Error'
    Write-LogMsg @Log -Text " # IdentityReference '$IdentityReference' # Bug before Resolve-IdentityReferenceDomainDNS. Unexpectedly unable to resolve a DNS FQDN due to malformed NTAccount caption"
    return $IdentityReference

}
function Resolve-SplitByParameter {

    param (

        <#
        How to split up the exported files:
            none generate a single file with all permissions
            target generate a file per target
            item generate a file per item
            account generate a file per account
            all generate 1 file per target and 1 file per item and 1 file per account and 1 file with all permissions.
        #>

        [ValidateSet('none', 'all', 'target', 'item', 'account')]
        [string[]]$SplitBy = 'all'

    )

    $result = @{}

    foreach ($Split in $SplitBy) {

        if ($Split -eq 'none') {

            return @{'none' = $true }

        } elseif ($Split -eq 'all') {

            return @{
                'target'  = $true
                'none'    = $true
                'item'    = $true
                'account' = $true
            }

        } else {

            $result[$Split] = $true

        }

    }

    return $result

}
function Select-AccountTableProperty {

    # For the HTML table

    param (
        $InputObject,
        [cultureinfo]$Culture = (Get-Culture), #Unused but exists here for parameter consistency with Select-AccountTableProperty
        [Hashtable]$ShortNameByID = [Hashtable]::Synchronized(@{})
    )

    ForEach ($Object in $InputObject) {

        $AccountName = $ShortNameByID[$Object.Account.ResolvedAccountName]

        if ($AccountName) {

            #$GroupString = $ShortNameByID[$Object.Access.Access.IdentityReferenceResolved]

            #if ($GroupString) {

            # This appears to be what determines the order of columns in the html report
            [PSCustomObject]@{
                Account     = $AccountName
                Name        = $Object.Account.Name
                DisplayName = $Object.Account.DisplayName
                Description = $Object.Account.Description
                Department  = $Object.Account.Department
                Title       = $Object.Account.Title
            }

            #}

        }

    }

}
function Select-ItemTableProperty {

    # For the HTML table

    param (
        $InputObject,
        [cultureinfo]$Culture = (Get-Culture),
        [Hashtable]$ShortNameByID = [Hashtable]::Synchronized(@{}), #Unused but exists here for parameter consistency with Select-AccountTableProperty and Select-PermissionTableProperty
        [switch]$SkipFilterCheck
    )

    ForEach ($Object in $InputObject) {

        if (-not $SkipFilterCheck) {

            $AccountNames = $ShortNameByID[$Object.Access.Account.ResolvedAccountName]
            if (-not $AccountNames) { continue }
            $GroupString = $ShortNameByID[$Object.Access.Access.IdentityReferenceResolved]
            if (-not $GroupString) { continue }

        }

        [PSCustomObject]@{
            Folder      = $Object.Item.Path
            Inheritance = $Culture.TextInfo.ToTitleCase(-not $Object.Item.AreAccessRulesProtected)
        }

    }

}
function Select-PermissionTableProperty {

    # For the HTML table
    param (

        $InputObject,

        [String]$GroupBy,

        # Dictionary of shortened account IDs keyed by full resolved account IDs
        # Populated by Select-PermissionPrincipal
        [Hashtable]$ShortNameByID = @{},

        [Hashtable]$OutputHash = @{},

        [Hashtable]$ExcludeClassFilterContents = @{},

        [Hashtable]$IncludeFilterContents = @{}

    )

    $Type = [PSCustomObject]

    $IncludeFilterCount = $IncludeFilterContents.Keys.Count

    switch ($GroupBy) {

        'account' {

            ForEach ($Object in $InputObject) {

                # Determine whether the account should be included according to inclusion/exclusion parameters
                $AccountName = $ShortNameByID[$Object.Account.ResolvedAccountName]

                if ($AccountName) {

                    ForEach ($AceList in $Object.Access) {

                        ForEach ($ACE in $AceList.Access) {

                            if ($ACE.IdentityReferenceResolved -eq $Object.Account.ResolvedAccountName) {

                                # In this case the ACE's account is directly referenced in the DACL; it is merely a member of a group from the DACL
                                $GroupString = ''

                            } else {

                                # In this case the ACE contains the original IdentityReference representing the group the virtual ACE's account is a member of
                                $GroupString = $ShortNameByID[$ACE.IdentityReferenceResolved]

                                if ( -not $GroupString ) {

                                    if (
                                        $ExcludeClassFilterContents[$ACE.IdentityReferenceResolved] -or
                                        (
                                            $IncludeFilterCount -gt 0 -and -not
                                            $IncludeFilterContents[$Object.Account.ResolvedAccountName]
                                        )
                                    ) {
                                        $GroupString = $ACE.IdentityReferenceResolved #TODO - Apply IgnoreDomain here. Put that .Replace logic into a function.
                                    }

                                }

                            }

                            # Use '$null -ne' to avoid treating an empty string '' as $null
                            if ($null -ne $GroupString) {

                                $Value = [pscustomobject]@{
                                    'Path'                 = $ACE.Path
                                    'Access'               = $ACE.Access
                                    'Due to Membership In' = $GroupString
                                    'Source of Access'     = $ACE.SourceOfAccess
                                }

                                Add-CacheItem -Cache $OutputHash -Key $AccountName -Value $Value -Type $Type

                            }

                        }

                    }

                }

            }
            break

        }

        'item' {

            ForEach ($Object in $InputObject) {

                $Accounts = @{}

                # Apply the -IgnoreDomain parameter
                ForEach ($AceList in $Object.Access) {

                    $AccountName = $ShortNameByID[$AceList.Account.ResolvedAccountName]

                    if ($AccountName) {

                        ForEach ($ACE in $AceList.Access) {
                            Add-CacheItem -Cache $Accounts -Key $AccountName -Value $ACE -Type $Type
                        }

                    }

                }

                $OutputHash[$Object.Item.Path] = ForEach ($AccountName in $Accounts.Keys) {

                    ForEach ($AceList in $Accounts[$AccountName]) {

                        ForEach ($ACE in $AceList) {

                            if ($ACE.IdentityReferenceResolved -eq $AccountName) {

                                # In this case the ACE's account is directly referenced in the DACL
                                $GroupString = ''

                            } else {

                                # In this case the account is merely a member of a group from the DACL

                                # Exclude the ACEs whose account names match the regular expressions specified in the -ExcludeAccount parameter
                                # Include the ACEs whose account names match the regular expressions specified in the -IncludeAccount parameter
                                # Exclude the ACEs whose account classes were included in the -ExcludeClass parameter

                                # Each ACE contains the original IdentityReference representing the group the Object is a member of
                                $GroupString = $ShortNameByID[$ACE.IdentityReferenceResolved]

                                if ( -not $GroupString ) {

                                    if (
                                        $ExcludeClassFilterContents[$ACE.IdentityReferenceResolved] -or
                                        (
                                            $IncludeFilterCount -gt 0 -and -not
                                            $IncludeFilterContents[$AccountName]
                                        )
                                    ) {
                                        $GroupString = $ACE.IdentityReferenceResolved #TODO - Apply IgnoreDomain here. Put that .Replace logic into a function.
                                    }

                                }

                            }

                            # Exclude the virtual ACEs for members of groups whose group names match the regular expressions specified in the -ExcludeAccount parameter
                            # Include the virtual ACEs for members of groups whose group names match the regular expressions specified in the -IncludeAccount parameter
                            # Exclude the virtual ACEs for members of groups whose group classes were included in the -ExcludeClass parameter
                            # Use '$null -ne' to avoid treating an empty string '' as $null
                            if ($null -ne $GroupString) {

                                [pscustomobject]@{
                                    'Account'              = $AccountName
                                    'Access'               = $ACE.Access #($ACE.Access.Access | Sort-Object -Unique) -join ' ; '
                                    'Due to Membership In' = $GroupString
                                    'Source of Access'     = $ACE.SourceOfAccess #($ACE.Access.SourceOfAccess | Sort-Object -Unique) -join ' ; '
                                    'Name'                 = $AceList.Account.Name
                                    'Department'           = $AceList.Account.Department
                                    'Title'                = $AceList.Account.Title
                                }

                            }

                        }

                    }

                }

            }
            break

        }

        # 'none' and 'target' behave the same
        default {

            $i = 0

            ForEach ($Object in $InputObject) {

                $OutputHash[$i] = ForEach ($ACE in $Object) {

                    $AccountName = $ShortNameByID[$ACE.ResolvedAccountName]

                    # Exclude the ACEs whose account names match the regular expressions specified in the -ExcludeAccount parameter
                    # Include the ACEs whose account names match the regular expressions specified in the -IncludeAccount parameter
                    # Exclude the ACEs whose account classes were included in the -ExcludeClass parameter
                    if ($AccountName) {

                        if ($ACE.IdentityReferenceResolved -eq $ACE.ResolvedAccountName) {
                            $GroupString = ''
                        } else {

                            # Each ACE contains the original IdentityReference representing the group the Object is a member of
                            $GroupString = $ShortNameByID[$ACE.IdentityReferenceResolved]

                            if ( -not $GroupString ) {

                                if (
                                    $ExcludeClassFilterContents[$ACE.IdentityReferenceResolved] -or
                                    (
                                        $IncludeFilterCount -gt 0 -and -not
                                        $IncludeFilterContents[$ACE.ResolvedAccountName]
                                    )
                                ) {
                                    $GroupString = $ACE.IdentityReferenceResolved #TODO - Apply IgnoreDomain here. Put that .Replace logic into a function.
                                }

                            }

                        }

                        # Exclude the virtual ACEs for members of groups whose group names match the regular expressions specified in the -ExcludeAccount parameter
                        # Include the virtual ACEs for members of groups whose group names match the regular expressions specified in the -IncludeAccount parameter
                        # Exclude the virtual ACEs for members of groups whose group classes were included in the -ExcludeClass parameter
                        # Use '$null -ne' to avoid treating an empty string '' as $null
                        if ($null -ne $GroupString) {

                            [pscustomobject]@{
                                'Item'                 = $Object.ItemPath
                                'Account'              = $AccountName
                                'Access'               = $ACE.Access
                                'Due to Membership In' = $GroupString
                                'Source of Access'     = $ACE.SourceOfAccess
                                'Name'                 = $ACE.Name
                                'Department'           = $ACE.Department
                                'Title'                = $ACE.Title
                            }

                        }

                    }

                }

                $i = $i + 1

            }
            break

        }

    }

    return $OutputHash

}
function Add-CachedCimInstance {

    param (

        # CIM Instance(s) to add to the cache
        [Parameter(ValueFromPipeline)]
        $InputObject,

        # Name of the computer to query via CIM
        [String]$ComputerName,

        # Name of the CIM class whose instances to return
        [String]$ClassName,

        # CIM query to run. Overrides ClassName if used (but not efficiently, so don't use both)
        [String]$Query,

        # Cache of CIM sessions and instances to reduce connections and queries
        [Hashtable]$CimCache = ([Hashtable]::Synchronized(@{})),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Log messages which have not yet been written to disk
        [Parameter(Mandatory)]
        [ref]$LogBuffer,

        # Properties by which to key the cache
        [string[]]$CacheByProperty

    )

    begin {

        $Log = @{
            Buffer       = $LogBuffer
            ThisHostname = $ThisHostname
            Type         = $DebugOutputStream
            WhoAmI       = $WhoAmI
        }

        $ComputerCache = $CimCache[$ComputerName]

        if (-not $ComputerCache) {
            #Write-LogMsg @Log -Text " # CIM server cache miss for '$ComputerName'"
            $ComputerCache = [Hashtable]::Synchronized(@{})
        }

    }

    process {

        ForEach ($Prop in $CacheByProperty) {

            if ($PSBoundParameters.ContainsKey('ClassName')) {
                $InstanceCacheKey = "$ClassName`By$Prop"
            } else {

                if ($PSBoundParameters.ContainsKey('Query')) {
                    $InstanceCacheKey = "$Query`By$Prop"
                } else {

                    $ClassName = @($InputObject)[0].CimClass.CimClassName
                    $InstanceCacheKey = "$ClassName`By$Prop"

                }

            }

            #Write-LogMsg @Log -Text " # CIM server cache hit for '$ComputerName'"
            $InstanceCache = $ComputerCache[$InstanceCacheKey]

            if (-not $InstanceCache) {

                #Write-LogMsg @Log -Text " # CIM instance cache miss for '$InstanceCacheKey' on '$ComputerName'"
                $InstanceCache = [Hashtable]::Synchronized(@{})

            }

            ForEach ($Instance in $InputObject) {

                $InstancePropertyValue = $Instance.$Prop
                Write-LogMsg @Log -Text " # Add '$InstancePropertyValue' to the '$InstanceCacheKey' cache for '$ComputerName'"
                $InstanceCache[$InstancePropertyValue] = $Instance

            }

            $ComputerCache[$InstanceCacheKey] = $InstanceCache

        }

    }

    end {
        $CimCache[$ComputerName] = $ComputerCache
    }


}
function Add-CacheItem {

    # Use a key to get a generic list from a hashtable
    # If it does not exist, create an empty list
    # Add the new item

    param (

        [Parameter(Mandatory)]
        [Hashtable]$Cache,

        [Parameter(Mandatory)]
        $Key,

        $Value,

        [type]$Type = [System.Object]

    )

    if ($key.Count -gt 1) { Pause }

    # Older, less efficient method
    $CacheResult = $Cache[$Key]

    if ($CacheResult) {
        $List = $CacheResult
    } else {
        $Command = "`$List = [System.Collections.Generic.List[$($Type.ToString())]]::new()"
        Invoke-Expression $Command
    }

    $List.Add($Value)
    $Cache[$Key] = $List

    <#
    # More efficient method requires switch to ConcurrentDictionary instead of SynchronizedHashtable
 
    $List = $null
 
    if ( -not $Cache.TryGetValue( $Key, [ref]$List ) ) {
        $Command = "`$List = [System.Collections.Generic.List[$($Type.ToString())]]::new()"
        Invoke-Expression $Command
        $Cache.Add($Key, $List)
    }
 
    $List.Add($Value)
    #>


}
function Add-PermissionCacheItem {

    # Use a key to get a generic list from a hashtable
    # If it does not exist, create an empty list
    # Add the new item

    param (

        # Must be a Dictionary or ConcurrentDictionary
        [Parameter(Mandatory)]
        [ref]$Cache,

        [Parameter(Mandatory)]
        $Key,

        $Value,

        [type]$Type = [System.Object]

    )

    $List = $null
    $AddOrUpdateScriptblock = { param($key, $val) $val }

    if ( -not $Cache.Value.TryGetValue( $Key, [ref]$List ) ) {

        $genericTypeDefinition = [System.Collections.Generic.List`1]
        $genericType = $genericTypeDefinition.MakeGenericType($Type)
        $List = [Activator]::CreateInstance($genericType)
        $null = $Cache.Value.AddOrUpdate($Key, $List, $AddOrUpdateScriptblock)

    }

    $List.Add($Value)

}
function ConvertTo-ItemBlock {

    param (

        $ItemPermissions

    )

    $Culture = Get-Culture

    Write-LogMsg @LogParams -Text "`$ObjectsForTable = Select-ItemTableProperty -InputObject `$ItemPermissions -Culture '$Culture'"
    $ObjectsForTable = Select-ItemTableProperty -InputObject $ItemPermissions -Culture $Culture

    Write-LogMsg @LogParams -Text "`$ObjectsForTable | ConvertTo-Html -Fragment | New-BootstrapTable"
    $HtmlTable = $ObjectsForTable |
    ConvertTo-Html -Fragment |
    New-BootstrapTable

    $JsonData = $ObjectsForTable |
    ConvertTo-Json -Compress

    Write-LogMsg @LogParams -Text "Get-ColumnJson -InputObject `$ObjectsForTable"
    $JsonColumns = Get-ColumnJson -InputObject $ObjectsForTable

    Write-LogMsg @LogParams -Text "ConvertTo-BootstrapJavaScriptTable -Id 'Folders' -InputObject `$ObjectsForTable -DataFilterControl -SearchableColumn 'Folder' -DropdownColumn 'Inheritance'"
    $JsonTable = ConvertTo-BootstrapJavaScriptTable -Id 'Folders' -InputObject $ObjectsForTable -DataFilterControl -SearchableColumn 'Folder' -DropdownColumn 'Inheritance'

    return [pscustomobject]@{
        HtmlDiv     = $HtmlTable
        JsonDiv     = $JsonTable
        JsonData    = $JsonData
        JsonColumns = $JsonColumns
    }

}
function ConvertTo-PermissionFqdn {

    param (

        [string]$ComputerName,

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    ConvertTo-DnsFqdn -ComputerName $ComputerName -ThisHostName $ThisHostname -WhoAmI $WhoAmI -LogBuffer $Cache.Value['LogBuffer']

}
function Expand-Permission {

    # TODO: If SplitBy account or item, each file needs to include inherited permissions (with default $SplitBy = 'none', the parent folder's inherited permissions are already included)

    param (
        $SplitBy,
        $GroupBy,
        [Hashtable]$Children,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # In-process cache to reduce calls to other processes or to disk
        [Parameter(Mandatory)]
        [ref]$Cache

    )

    $Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $Cache.Value['LogBuffer'] ; WhoAmI = $WhoAmI }

    $Progress = @{
        Activity = 'Expand-Permission'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $Progress['Id'] = $ProgressParentId + 1
    } else {
        $Progress['Id'] = 0
    }

    Write-Progress @Progress -Status '0% : Group permission references, then expand them into objects' -CurrentOperation 'Resolve-SplitByParameter' -PercentComplete 0
    Write-LogMsg @Log -Text "Resolve-SplitByParameter -SplitBy $SplitBy"
    $HowToSplit = Resolve-SplitByParameter -SplitBy $SplitBy
    Write-LogMsg @Log -Text "`$SortedPaths = `$AceGuidByPath.Keys | Sort-Object"
    $SortedPaths = $Cache.Value['AceGuidByPath'].Value.Keys | Sort-Object
    $AceGuidByPath = $Cache.Value['AceGuidByPath']
    $AceGuidByID = $Cache.Value['AceGuidByID']
    $ACEsByGUID = $Cache.Value['AceByGUID']
    $PrincipalsByResolvedID = $Cache.Value['PrincipalByID']
    $ACLsByPath = $Cache.Value['AclByPath']
    $TargetPath = $Cache.Value['ParentByTargetPath']

    $CommonParams = @{
        ACEsByGUID             = $ACEsByGUID
        PrincipalsByResolvedID = $PrincipalsByResolvedID
    }

    if (
        $HowToSplit['account']
    ) {

        # Group reference GUIDs by the name of their associated account.
        Write-LogMsg @Log -Text '$AccountPermissionReferences = Group-AccountPermissionReference -ID $PrincipalsByResolvedID.Keys -AceGuidByID $AceGuidByID -AceByGuid $ACEsByGUID'
        $AccountPermissionReferences = Group-AccountPermissionReference -ID $PrincipalsByResolvedID.Value.Keys -AceGuidByID $AceGuidByID -AceByGuid $ACEsByGUID

        # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
        Write-LogMsg @Log -Text '$AccountPermissions = Expand-AccountPermissionReference -Reference $AccountPermissionReferences @CommonParams'
        $AccountPermissions = Expand-AccountPermissionReference -Reference $AccountPermissionReferences @CommonParams

    }

    if (
        $HowToSplit['item']
    ) {

        # Group reference GUIDs by the path to their associated item.
        Write-LogMsg @Log -Text '$ItemPermissionReferences = Group-ItemPermissionReference @CommonParams -SortedPath $SortedPaths -AceGUIDsByPath $AceGuidByPath -ACLsByPath $ACLsByPath'
        $ItemPermissionReferences = Group-ItemPermissionReference -SortedPath $SortedPaths -AceGUIDsByPath $AceGuidByPath -ACLsByPath $ACLsByPath @CommonParams

        # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
        Write-LogMsg @Log -Text '$ItemPermissions = Expand-ItemPermissionReference -Reference $ItemPermissionReferences -ACLsByPath $ACLsByPath @CommonParams'
        $ItemPermissions = Expand-ItemPermissionReference -Reference $ItemPermissionReferences -ACLsByPath $ACLsByPath @CommonParams

    }

    if (
        $HowToSplit['none']
    ) {

        # Expand each Access Control Entry with the Security Principal for the resolved IdentityReference.
        Write-LogMsg @Log -Text '$FlatPermissions = Expand-FlatPermissionReference -SortedPath $SortedPaths -AceGUIDsByPath $AceGuidByPath @CommonParams'
        $FlatPermissions = Expand-FlatPermissionReference -SortedPath $SortedPaths -AceGUIDsByPath $AceGuidByPath @CommonParams

    }

    if (
        $HowToSplit['target']
    ) {

        # Group reference GUIDs by their associated TargetPath.
        Write-LogMsg @Log -Text '$TargetPermissionReferences = Group-TargetPermissionReference -TargetPath $TargetPath -Children $Children -AceGUIDsByPath $AceGuidByPath -ACLsByPath $ACLsByPath -GroupBy $GroupBy -AceGuidByID $AceGuidByID @CommonParams'
        $TargetPermissionReferences = Group-TargetPermissionReference -TargetPath $TargetPath -Children $Children -AceGUIDsByPath $AceGuidByPath -ACLsByPath $ACLsByPath -GroupBy $GroupBy -AceGuidByID $AceGuidByID @CommonParams

        # Expand reference GUIDs into their associated Access Control Entries and Security Principals.
        Write-LogMsg @Log -Text '$TargetPermissions = Expand-TargetPermissionReference -Reference $TargetPermissionReferences -GroupBy $GroupBy -ACLsByPath $ACLsByPath @CommonParams'
        $TargetPermissions = Expand-TargetPermissionReference -Reference $TargetPermissionReferences -GroupBy $GroupBy -ACLsByPath $ACLsByPath -AceGuidByPath $AceGuidByPath @CommonParams

    }

    Write-Progress @Progress -Completed

    return [PSCustomObject]@{
        AccountPermissions = $AccountPermissions
        FlatPermissions    = $FlatPermissions
        ItemPermissions    = $ItemPermissions
        TargetPermissions  = $TargetPermissions
        SplitBy            = $HowToSplit
    }

}
function Expand-PermissionTarget {

    # Expand a folder path into the paths of its subfolders

    param (

        <#
        How many levels of subfolder to enumerate
 
            Set to 0 to ignore all subfolders
 
            Set to -1 (default) to recurse infinitely
 
            Set to any whole number to enumerate that many levels
        #>

        [int]$RecurseDepth,

        # Number of asynchronous threads to use
        [uint16]$ThreadCount = ((Get-CimInstance -ClassName CIM_Processor | Measure-Object -Sum -Property NumberOfLogicalProcessors).Sum),

        # Will be sent to the Type parameter of Write-LogMsg in the PsLogMessage module
        [String]$DebugOutputStream = 'Silent',

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Progress = @{
        Activity = 'Expand-PermissionTarget'
    }

    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {

        $Progress['ParentId'] = $ProgressParentId
        $Progress['Id'] = $ProgressParentId + 1

    } else {
        $Progress['Id'] = 0
    }

    $Targets = ForEach ($Target in $Cache.Value['ParentByTargetPath'].Value.Values ) {
        $Target
    }

    $TargetCount = $Targets.Count
    Write-Progress @Progress -Status "0% (item 0 of $TargetCount)" -CurrentOperation 'Initializing...' -PercentComplete 0
    $LogBuffer = $Cache.Value['LogBuffer']

    $Log = @{
        Buffer       = $LogBuffer
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    [Hashtable]$Output = [Hashtable]::Synchronized(@{})

    $GetSubfolderParams = @{
        LogBuffer         = $LogBuffer
        ThisHostname      = $ThisHostname
        DebugOutputStream = $DebugOutputStream
        WhoAmI            = $WhoAmI
        Output            = $Output
        RecurseDepth      = $RecurseDepth
        ErrorAction       = 'Continue'
    }

    if ($ThreadCount -eq 1 -or $TargetCount -eq 1) {

        [int]$ProgressInterval = [math]::max(($TargetCount / 100), 1)
        $IntervalCounter = 0
        $i = 0

        ForEach ($ThisFolder in $Targets) {

            $IntervalCounter++

            if ($IntervalCounter -eq $ProgressInterval) {
                [int]$PercentComplete = $i / $TargetCount * 100
                Write-Progress @Progress -Status "$PercentComplete% (item $($i + 1) of $TargetCount))" -CurrentOperation "Get-Subfolder '$($ThisFolder)'" -PercentComplete $PercentComplete
                $IntervalCounter = 0
            }

            $i++ # increment $i after the progress to show progress conservatively rather than optimistically
            Write-LogMsg @Log -Text "Get-Subfolder -TargetPath '$ThisFolder' -RecurseDepth $RecurseDepth"
            Get-Subfolder -TargetPath $ThisFolder @GetSubfolderParams

        }

    } else {

        $SplitThreadParams = @{
            Command           = 'Get-Subfolder'
            InputObject       = $Targets
            InputParameter    = 'TargetPath'
            DebugOutputStream = $DebugOutputStream
            TodaysHostname    = $ThisHostname
            WhoAmI            = $WhoAmI
            LogBuffer         = $LogBuffer
            Threads           = $ThreadCount
            ProgressParentId  = $Progress['Id']
            AddParam          = $GetSubfolderParams
        }

        Split-Thread @SplitThreadParams

    }

    Write-Progress @Progress -Completed
    return $Output

}
function Find-CachedCimInstance {

    param (
        [string]$ComputerName,
        [string]$Key,
        [hashtable]$CimCache,
        [hashtable]$Log,
        [string[]]$CacheToSearch = ($CimCache[$ComputerName].Keys | Sort-Object -Descending)
    )

    $CimServer = $CimCache[$ComputerName]

    if ($CimServer) {

        ForEach ($Cache in $CacheToSearch) {

            $InstanceCache = $CimServer[$Cache]

            if ($InstanceCache) {

                $CachedCimInstance = $InstanceCache[$Key]

                if ($CachedCimInstance) {

                    return $CachedCimInstance

                } else {
                    Write-LogMsg @Log -Text " # CIM Instance cache miss in the '$Cache' cache on '$ComputerName' for '$Key'"
                }

            } else {
                Write-LogMsg @Log -Text " # CIM Class/Query cache miss for '$Cache' on '$ComputerName' # for '$Key'"
            }

        }

    } else {
        Write-LogMsg @Log -Text " # CIM Server cache miss for '$ComputerName' # for '$Key'"
    }

}
function Find-ResolvedIDsWithAccess {

    param (
        $ItemPath,
        [ref]$AceGUIDsByPath,
        [ref]$ACEsByGUID,
        [ref]$PrincipalsByResolvedID
    )

    $GuidType = [guid]
    $IDsWithAccess = New-PermissionCacheRef -Key ([string]) -Value ([System.Collections.Generic.List[guid]])

    ForEach ($Item in $ItemPath) {

        $Guids = $AceGUIDsByPath.Value[$Item]

        # Not all Paths have ACEs in the cache, so we need to test for null results
        if ($Guids) {

            ForEach ($Guid in $Guids) {

                ForEach ($Ace in $ACEsByGUID.Value[$Guid]) {

                    Add-PermissionCacheItem -Cache $IDsWithAccess -Key $Ace.IdentityReferenceResolved -Value $Guid -Type $GuidType

                    ForEach ($Member in $PrincipalsByResolvedID.Value[$Ace.IdentityReferenceResolved].Members) {

                        Add-PermissionCacheItem -Cache $IDsWithAccess -Key $Member -Value $Guid -Type $GuidType

                    }

                }

            }

        }

    }

    return $IDsWithAccess

}

# Build a list of known ADSI server names to use to populate the caches
# Include the FQDN of the current computer and the known trusted domains
function Find-ServerFqdn {

    param (

        # Known server FQDNs to include in the output
        [string[]]$Known,

        # File paths whose server FQDNs to include in the output
        [Hashtable]$TargetPath,

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId

    )

    $Progress = @{
        Activity = 'Find-ServerFqdn'
    }

    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {

        $Progress['ParentId'] = $ProgressParentId
        $ProgressId = $ProgressParentId + 1

    } else {
        $ProgressId = 0
    }

    $Progress['Id'] = $ProgressId
    $Count = $TargetPath.Keys.Count
    Write-Progress @Progress -Status "0% (path 0 of $Count)" -CurrentOperation 'Initializing' -PercentComplete 0

    $UniqueValues = @{
        $ThisFqdn = $null
    }

    ForEach ($Value in $Known) {
        $UniqueValues[$Value] = $null
    }

    # Add server names from the ACL paths

    $ProgressStopWatch = [System.Diagnostics.Stopwatch]::new()
    $ProgressStopWatch.Start()
    $LastRemainder = [int]::MaxValue
    $i = 0

    ForEach ($ThisPath in $TargetPath.Keys) {

        $NewRemainder = $ProgressStopWatch.ElapsedTicks % 5000

        if ($NewRemainder -lt $LastRemainder) {

            $LastRemainder = $NewRemainder
            [int]$PercentComplete = $i / $Count * 100
            Write-Progress @Progress -Status "$PercentComplete% (path $($i + 1) of $Count)" -CurrentOperation "Find-ServerNameInPath '$ThisPath'" -PercentComplete $PercentComplete

        }

        $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically
        $UniqueValues[(Find-ServerNameInPath -LiteralPath $ThisPath -ThisFqdn $ThisFqdn)] = $null

    }

    Write-Progress @Progress -Completed
    return $UniqueValues.Keys

}
function Format-Permission {

    param (

        # Permission object from Expand-Permission
        [PSCustomObject]$Permission,

        <#
        Domain(s) to ignore (they will be removed from the username)
 
        Can be used:
          to ensure accounts only appear once on the report when they have matching SamAccountNames in multiple domains.
          when the domain is often the same and doesn't need to be displayed
        #>

        [string[]]$IgnoreDomain,

        # How to group the permissions in the output stream and within each exported file
        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        # File formats to export
        [ValidateSet('csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [string[]]$FileFormat = @('csv', 'html', 'js', 'json', 'prtgxml', 'xml'),

        # Type of output returned to the output stream
        [ValidateSet('passthru', 'none', 'csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [String]$OutputFormat = 'passthru',

        [cultureinfo]$Culture = (Get-Culture),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Progress = @{
        Activity = 'Format-Permission'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $ProgressId = $ProgressParentId + 1
    } else {
        $ProgressId = 0
    }

    $Progress['Id'] = $ProgressId

    $FormattedResults = @{}
    $Formats = Resolve-FormatParameter -FileFormat $FileFormat -OutputFormat $OutputFormat
    $Grouping = Resolve-GroupByParameter -GroupBy $GroupBy -HowToSplit $Permission.SplitBy
    $ShortNameByID = $Cache.Value['ShortNameByID']
    $ExcludeClassFilterContents = $Cache.Value['ExcludeClassFilterContents']
    $IncludeFilterContents = $Cache.Value['IncludeAccountFilterContents']

    if ($Permission.SplitBy['account']) {

        $i = 0
        $Count = $Permission.AccountPermissions.Count

        $FormattedResults['SplitByAccount'] = ForEach ($Account in $Permission.AccountPermissions) {

            [int]$Percent = $i / $Count * 100
            Write-Progress -Status "$Percent% (Account $($i + 1) of $Count)" -CurrentOperation $Account.Account.ResolvedAccountName -PercentComplete $Percent @Progress
            $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically
            $Selection = $Account
            $PermissionGroupingsWithChosenProperties = Invoke-Command -ScriptBlock $Grouping['Script'] -ArgumentList $Selection, $Culture, $IgnoreDomain, $IncludeFilterContents, $ExcludeClassFilterContents
            $PermissionsWithChosenProperties = Select-PermissionTableProperty -InputObject $Selection -GroupBy $GroupBy -ShortNameById $ShortNameByID -IncludeFilterContents $IncludeFilterContents -ExcludeClassFilterContents $ExcludeClassFilterContents

            $OutputProperties = @{
                Account      = $Account.Account
                Path         = $Permission.TargetPermissions.Path.FullName
                NetworkPaths = $Permission.TargetPermissions.NetworkPaths.Item
                #passthru = [PSCustomObject]@{
                # 'Data' = ForEach ($Value in $PermissionsWithChosenProperties.Values) { $Value }
                #}
            }

            ForEach ($Format in $Formats) {

                $OutputProperties["$Format`Group"] = ConvertTo-PermissionGroup -Format $Format -Permission $PermissionGroupingsWithChosenProperties -GroupBy $GroupBy
                $OutputProperties[$Format] = ConvertTo-PermissionList -Format $Format -Permission $PermissionsWithChosenProperties -PermissionGrouping $Selection -ShortestPath @($Permission.TargetPermissions.NetworkPaths.Item.Path)[0] -GroupBy $GroupBy -HowToSplit $Permission.SplitBy

            }

            [PSCustomObject]$OutputProperties

        }

    }

    if ($Permission.SplitBy['item']) {

        $i = 0
        $Count = $Permission.ItemPermissions.Count

        $FormattedResults['SplitByItem'] = ForEach ($Item in $Permission.ItemPermissions) {

            [int]$Percent = $i / $Count * 100
            Write-Progress -Status "$Percent% (Account $($i + 1) of $Count)" -CurrentOperation $Item.Path -PercentComplete $Percent @Progress
            $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically

            $Selection = $Item.Access
            $PermissionGroupingsWithChosenProperties = Invoke-Command -ScriptBlock $Grouping['Script'] -ArgumentList $Selection, $Culture, $IgnoreDomain, $IncludeFilterContents, $ExcludeClassFilterContents
            $PermissionsWithChosenProperties = Select-PermissionTableProperty -InputObject $Selection -GroupBy $GroupBy -ShortNameById $ShortNameByID -IncludeFilterContents $IncludeFilterContents -ExcludeClassFilterContents $ExcludeClassFilterContents

            $OutputProperties = @{
                Item         = $Item.Item
                TargetPaths  = $Permission.TargetPermissions.Path.FullName
                NetworkPaths = $Permission.TargetPermissions.NetworkPaths.Item
                #passthru = [PSCustomObject]@{
                # 'Data' = ForEach ($Value in $PermissionsWithChosenProperties.Values) { $Value }
                #}
            }

            ForEach ($Format in $Formats) {

                $OutputProperties["$Format`Group"] = ConvertTo-PermissionGroup -Format $Format -Permission $PermissionGroupingsWithChosenProperties -GroupBy $GroupBy
                $OutputProperties[$Format] = ConvertTo-PermissionList -Format $Format -Permission $PermissionsWithChosenProperties -PermissionGrouping $Selection -ShortestPath @($Permission.TargetPermissions.NetworkPaths.Item.Path)[0] -GroupBy $GroupBy -HowToSplit $Permission.SplitBy

            }

            [PSCustomObject]$OutputProperties

        }

    }

    if ($Permission.SplitBy['target']) {

        $i = 0
        $Count = $Permission.TargetPermissions.Count

        $FormattedResults['SplitByTarget'] = ForEach ($Target in $Permission.TargetPermissions) {

            [int]$Percent = $i / $Count * 100
            Write-Progress -Status "$Percent% (Account $($i + 1) of $Count)" -CurrentOperation $Target.Path -PercentComplete $Percent @Progress
            $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically

            [PSCustomObject]@{
                PSTypeName   = 'Permission.TargetPermission'
                Path         = $Target.Path
                NetworkPaths = ForEach ($NetworkPath in $Target.NetworkPaths) {

                    $Prop = $Grouping['Property']

                    if ($Prop -eq 'items') {

                        $Selection = [System.Collections.Generic.List[PSCustomObject]]::new()

                        # Add the network path itself
                        $Selection.Add([PSCustomObject]@{
                                PSTypeName = 'Permission.ItemPermission'
                                Item       = $NetworkPath.Item
                                Access     = $NetworkPath.Access
                            })

                        # Add child items
                        $ChildItems = [PSCustomObject[]]$NetworkPath.$Prop
                        if ($ChildItems) {
                            $Selection.AddRange($ChildItems)
                        }

                    } else {
                        $Selection = $NetworkPath.$Prop
                    }

                    $PermissionGroupingsWithChosenProperties = Invoke-Command -ScriptBlock $Grouping['Script'] -ArgumentList $Selection, $Culture, $ShortNameByID, $IncludeFilterContents, $ExcludeClassFilterContents
                    $PermissionsWithChosenProperties = Select-PermissionTableProperty -InputObject $Selection -GroupBy $GroupBy -ShortNameById $ShortNameByID -IncludeFilterContents $IncludeFilterContents -ExcludeClassFilterContents $ExcludeClassFilterContents

                    $OutputProperties = @{
                        PSTypeName = "Permission.Parent$($Culture.TextInfo.ToTitleCase($GroupBy))Permission"
                        Item       = $NetworkPath.Item
                    }

                    ForEach ($Format in $Formats) {

                        $FormatString = $Format
                        if ($Format -eq 'js') {
                            $FormatString = 'json'
                        }

                        $OutputProperties["$FormatString`Group"] = ConvertTo-PermissionGroup -Format $Format -Permission $PermissionGroupingsWithChosenProperties -GroupBy $GroupBy -HowToSplit $Permission.SplitBy
                        $OutputProperties[$FormatString] = ConvertTo-PermissionList -Format $Format -Permission $PermissionsWithChosenProperties -PermissionGrouping $Selection -ShortestPath $NetworkPath.Item.Path -GroupBy $GroupBy -HowToSplit $Permission.SplitBy -NetworkPath $NetworkPath.Item.Path -Analysis $Analysis

                    }

                    [PSCustomObject]$OutputProperties

                }

            }

        }

    }

    return $FormattedResults

}
function Format-TimeSpan {
    param (
        [timespan]$TimeSpan,
        [string[]]$UnitsToResolve = @('day', 'hour', 'minute', 'second', 'millisecond')
    )
    $StringBuilder = [System.Text.StringBuilder]::new()
    $aUnitWithAValueHasBeenFound = $false
    foreach ($Unit in $UnitsToResolve) {
        if ($TimeSpan."$Unit`s") {
            if ($aUnitWithAValueHasBeenFound) {
                $null = $StringBuilder.Append(", ")
            }
            $aUnitWithAValueHasBeenFound = $true

            if ($TimeSpan."$Unit`s" -eq 1) {
                $null = $StringBuilder.Append("$($TimeSpan."$Unit`s") $Unit")
            } else {
                $null = $StringBuilder.Append("$($TimeSpan."$Unit`s") $Unit`s")
            }
        }
    }
    $StringBuilder.ToString()
}
function Get-AccessControlList {

    # Get folder access control lists
    # Returns an object representing each effective permission on a folder
    # This includes each Access Control Entry in the Discretionary Access List, as well as the folder's Owner

    [CmdletBinding()]

    param (

        # Path to the item whose permissions to export (inherited ACEs will be included)
        [Hashtable]$TargetPath,

        # Number of asynchronous threads to use
        [uint16]$ThreadCount = ((Get-CimInstance -ClassName CIM_Processor | Measure-Object -Sum -Property NumberOfLogicalProcessors).Sum),

        # Will be sent to the Type parameter of Write-LogMsg in the PsLogMessage module
        [String]$DebugOutputStream = 'Debug',

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # Hashtable of warning messages to allow a summarized count in the Warning stream with detail in the Verbose stream
        [hashtable]$WarningCache = [Hashtable]::Synchronized(@{}),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $LogBuffer = $Cache.Value['LogBuffer']
    $AclByPath = $Cache.Value['AclByPath']

    $Log = @{
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        Buffer       = $LogBuffer
        WhoAmI       = $WhoAmI
    }

    $Progress = @{
        Activity = 'Get-AccessControlList'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $ProgressId = $ProgressParentId + 1
    } else {
        $ProgressId = 0
    }
    $Progress['Id'] = $ProgressId
    $ChildProgress = @{
        Activity = 'Get access control lists for parent and child items'
        Id       = $ProgressId + 1
        ParentId = $ProgressId
    }
    $GrandChildProgress = @{
        Activity = 'Get access control lists'
        Id       = $ProgressId + 2
        ParentId = $ProgressId + 1
    }

    Write-Progress @Progress -Status '0% (step 1 of 2) Get access control lists for parent and child items' -CurrentOperation 'Get access control lists for parent and child items' -PercentComplete 0

    $GetDirectorySecurity = @{
        LogBuffer         = $LogBuffer
        ThisHostname      = $ThisHostname
        DebugOutputStream = $DebugOutputStream
        WhoAmI            = $WhoAmI
        AclByPath         = $AclByPath
        WarningCache      = $WarningCache
    }

    $TargetIndex = 0
    $ParentCount = $TargetPath.Keys.Count

    if ($ThreadCount -eq 1) {

        ForEach ($Parent in $TargetPath.Keys) {

            [int]$PercentComplete = $TargetIndex / $ParentCount * 100
            $TargetIndex++
            Write-Progress @ChildProgress -Status "$PercentComplete% (parent $TargetIndex of $ParentCount) Get access control lists" -CurrentOperation $Parent -PercentComplete $PercentComplete
            Write-Progress @GrandChildProgress -Status '0% (parent) Get-DirectorySecurity -IncludeInherited' -CurrentOperation $Parent -PercentComplete 0
            Get-DirectorySecurity -LiteralPath $Parent -IncludeInherited @GetDirectorySecurity
            $Children = $TargetPath[$Parent]
            $ChildCount = $Children.Count
            [int]$ProgressInterval = [math]::max(($ChildCount / 100), 1)
            $IntervalCounter = 0
            $ChildIndex = 0

            ForEach ($Child in $Children) {

                $IntervalCounter++

                if ($IntervalCounter -eq $ProgressInterval -or $ChildIndex -eq 0) {

                    [int]$PercentComplete = $ChildIndex / $ChildCount * 100
                    Write-Progress @GrandChildProgress -Status "$PercentComplete% (child $($ChildIndex + 1) of $ChildCount) Get-DirectorySecurity" -CurrentOperation $Child -PercentComplete $PercentComplete
                    $IntervalCounter = 0

                }

                $ChildIndex++ # increment $ChildIndex after the progress to show progress conservatively rather than optimistically
                Get-DirectorySecurity -LiteralPath $Child @GetDirectorySecurity

            }

            Write-Progress @GrandChildProgress -Completed

        }

        Write-Progress @ChildProgress -Completed

    } else {

        ForEach ($Parent in $TargetPath.Keys) {

            [int]$PercentComplete = $TargetIndex / $ParentCount * 100
            $TargetIndex++
            Write-Progress @ChildProgress -Status "$PercentComplete% (parent $TargetIndex of $ParentCount) Get access control lists" -CurrentOperation $Parent -PercentComplete $PercentComplete
            Get-DirectorySecurity -LiteralPath $Parent -IncludeInherited @GetDirectorySecurity
            $Children = $TargetPath[$Parent]

            $SplitThread = @{
                Command           = 'Get-DirectorySecurity'
                InputObject       = $Children
                InputParameter    = 'LiteralPath'
                DebugOutputStream = $DebugOutputStream
                ThisHostname      = $ThisHostname
                WhoAmI            = $WhoAmI
                LogBuffer         = $LogBuffer
                Threads           = $ThreadCount
                ProgressParentId  = $ChildProgress['Id']
                AddParam          = $GetDirectorySecurity
            }

            Split-Thread @SplitThread

        }

        Write-Progress @ChildProgress -Completed

    }

    if ($WarningCache.Keys.Count -ge 1) {

        $Log['Type'] = 'Warning' # PS 5.1 will not allow you to override the Splat by manually calling the param, so we must update the splat
        Write-LogMsg @Log -Text " # Errors on $($WarningCache.Keys.Count) items while getting access control lists. See verbose log for details."

    }

    Write-Progress @Progress -Status '50% (step 2 of 2) Find non-inherited owners for parent and child items' -CurrentOperation 'Find non-inherited owners for parent and child items' -PercentComplete 50
    $ChildProgress['Activity'] = 'Get ACL owners'
    $GrandChildProgress['Activity'] = 'Get ACL owners'

    $GetOwnerAce = @{
        AclByPath = $AclByPath
    }

    $ParentIndex = 0

    # Then return the owners of any items that differ from their parents' owners
    if ($ThreadCount -eq 1) {

        # Update the cache with ACEs for the item owners (if they do not match the owner of the item's parent folder)
        # First return the owner of the parent item

        ForEach ($Parent in $TargetPath.Keys) {

            [int]$PercentComplete = $ParentIndex / $ParentCount * 100
            $ParentIndex++
            Write-Progress @ChildProgress -Status "$PercentComplete% (parent $ParentIndex of $ParentCount) Find non-inherited ACL Owners" -CurrentOperation $Parent -PercentComplete $PercentComplete
            Write-Progress @GrandChildProgress -Status '0% (parent) Get-OwnerAce' -CurrentOperation $Parent -PercentComplete $PercentComplete
            Get-OwnerAce -Item $Parent @GetOwnerAce
            $Children = $TargetPath[$Parent]
            $ChildCount = $Children.Count
            [int]$ProgressInterval = [math]::max(($ChildCount / 100), 1)
            $IntervalCounter = 0
            $ChildIndex = 0

            ForEach ($Child in $Children) {

                $IntervalCounter++

                if ($IntervalCounter -eq $ProgressInterval -or $ChildIndex -eq 0) {

                    [int]$PercentComplete = $ChildIndex / $ChildCount * 100
                    Write-Progress @GrandChildProgress -Status "$PercentComplete% (child $($ChildIndex + 1) of $ChildCount) Get-OwnerAce" -CurrentOperation $Child -PercentComplete $PercentComplete
                    $IntervalCounter = 0

                }

                $ChildIndex++
                Get-OwnerAce -Item $Child @GetOwnerAce

            }

            Write-Progress @GrandChildProgress -Completed

        }

        Write-Progress @ChildProgress -Completed

    } else {

        ForEach ($Parent in $TargetPath.Keys) {

            [int]$PercentComplete = $ParentIndex / $ParentCount * 100
            $ParentIndex++
            Write-Progress @ChildProgress -Status "$PercentComplete% (parent $ParentIndex of $ParentCount) Find non-inherited ACL Owners" -CurrentOperation $Parent -PercentComplete $PercentComplete
            Get-OwnerAce -Item $Parent @GetOwnerAce
            $Children = $TargetPath[$Parent]

            $SplitThread = @{
                Command           = 'Get-OwnerAce'
                InputObject       = $Children
                InputParameter    = 'Item'
                DebugOutputStream = $DebugOutputStream
                ThisHostname      = $ThisHostname
                WhoAmI            = $WhoAmI
                LogBuffer         = $LogBuffer
                Threads           = $ThreadCount
                ProgressParentId  = $ChildProgress['Id']
                AddParam          = $GetOwnerAce
            }

            Split-Thread @SplitThread

        }

    }

    Write-Progress @Progress -Completed

    if ($AclByPath.Value.Keys.Count -eq 0) {

        $Log['Type'] = 'Error' # PS 5.1 will not allow you to override the Splat by manually calling the param, so we must update the splat
        Write-LogMsg @Log -Text ' # 0 access control lists could be retrieved. Exiting script.'

    }

}
function Get-CachedCimInstance {

    param (

        # Name of the computer to query via CIM
        [String]$ComputerName,

        # Name of the CIM class whose instances to return
        [String]$ClassName,

        # Name of the CIM namespace containing the class
        [String]$Namespace,

        # CIM query to run. Overrides ClassName if used (but not efficiently, so don't use both)
        [String]$Query,

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        [Parameter(Mandatory)]
        [String]$KeyProperty,

        [string[]]$CacheByProperty = $KeyProperty,

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Log = @{
        Buffer       = $Cache.Value['LogBuffer']
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    if ($PSBoundParameters.ContainsKey('ClassName')) {
        $InstanceCacheKey = "$ClassName`By$KeyProperty"
    } else {
        $InstanceCacheKey = "$Query`By$KeyProperty"
    }

    $CimServer = $null
    $AddOrUpdateScriptblock = { param($key, $val) $val }
    $CimCache = $Cache.Value['CimCache']
    $String = [type]'String'

    if ( $CimCache.Value.TryGetValue( $ComputerName , [ref]$CimServer ) ) {

        #Write-LogMsg @Log -Text " # CIM server cache hit for '$ComputerName'"
        $InstanceCache = $null

        if ( $CimServer.Value.TryGetValue( $InstanceCacheKey , [ref]$InstanceCache ) ) {

            #Write-LogMsg @Log -Text " # CIM instance cache hit for '$InstanceCacheKey' on '$ComputerName'"
            return $InstanceCache.Value.Values

        } else {
            Write-LogMsg @Log -Text " # CIM instance cache miss for '$InstanceCacheKey' on '$ComputerName'"
        }

    } else {

        Write-LogMsg @Log -Text " # CIM server cache miss for '$ComputerName'"
        $CimServer = New-PermissionCacheRef -Key $String -Value ([type]'System.Management.Automation.PSReference')
        $null = $CimCache.Value.AddOrUpdate( $ComputerName , $CimServer, $AddOrUpdateScriptblock )

    }

    $GetCimSessionParams = @{
        Cache             = $Cache
        DebugOutputStream = $DebugOutputStream
        ThisHostname      = $ThisHostname
        ThisFqdn          = $ThisFqdn
        WhoAmI            = $WhoAmI
    }

    $CimSession = Get-CachedCimSession -ComputerName $ComputerName @GetCimSessionParams

    if ($CimSession) {

        $GetCimInstanceParams = @{
            CimSession  = $CimSession
            ErrorAction = 'SilentlyContinue'
        }

        if ($Namespace) {
            $GetCimInstanceParams['Namespace'] = $Namespace
        }

        if ($PSBoundParameters.ContainsKey('ClassName')) {

            Write-LogMsg @Log -Text "Get-CimInstance -ClassName $ClassName -CimSession `$CimSession"
            $CimInstance = Get-CimInstance -ClassName $ClassName @GetCimInstanceParams

        }

        if ($PSBoundParameters.ContainsKey('Query')) {

            Write-LogMsg @Log -Text "Get-CimInstance -Query '$Query' -CimSession `$CimSession"
            $CimInstance = Get-CimInstance -Query $Query @GetCimInstanceParams

        }

        if ($CimInstance) {

            $CimInstanceType = [System.Collections.Generic.List[CimInstance]]

            ForEach ($Prop in $CacheByProperty) {

                $InstanceCache = New-PermissionCacheRef -Key $String -Value $CimInstanceType

                if ($PSBoundParameters.ContainsKey('ClassName')) {
                    $InstanceCacheKey = "$ClassName`By$Prop"
                } else {
                    $InstanceCacheKey = "$Query`By$Prop"
                }

                #Write-LogMsg @Log -Text " # Create the '$InstanceCacheKey' cache for '$ComputerName'"
                $null = $CimServer.Value.AddOrUpdate( $InstanceCacheKey , $InstanceCache, $AddOrUpdateScriptblock  )

                ForEach ($Instance in $CimInstance) {

                    $InstancePropertyValue = $Instance.$Prop
                    Write-LogMsg @Log -Text " # Add '$InstancePropertyValue' to the '$InstanceCacheKey' cache for '$ComputerName'"
                    $null = $InstanceCache.Value.AddOrUpdate( $InstancePropertyValue , $Instance , $AddOrUpdateScriptblock  )

                }

            }

            return $CimInstance

        } else {
            #Write-LogMsg @Log -Text " # No CIM instance returned # for $ClassName$Query on $ComputerName"
        }

    } else {
        #Write-LogMsg @Log -Text " # No CIM session returned # for $ComputerName"
    }

}
function Get-CachedCimSession {

    param (

        # Name of the computer to query via CIM
        [String]$ComputerName,

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Log = @{
        Buffer       = $Cache.Value['LogBuffer']
        ThisHostname = $ThisHostname
        Type         = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    $AddOrUpdateScriptblock = { param($key, $val) $val }
    $CimCache = $Cache.Value['CimCache']
    $CimServer = $null
    $String = [type]'String'

    if ( $CimCache.Value.TryGetValue( $ComputerName , [ref]$CimServer ) ) {

        Write-LogMsg @Log -Text " # CIM server cache hit for '$ComputerName'"
        $CimSession = $null

        if ( $CimServer.Value.TryGetValue( 'CimSession' , [ref]$CimSession ) ) {

            Write-LogMsg @Log -Text " # CIM session cache hit for '$ComputerName'"
            return $CimSession.Value

        } else {

            Write-LogMsg @Log -Text " # CIM session cache miss for '$ComputerName'"

        }

    } else {

        Write-LogMsg @Log -Text " # CIM server cache miss for '$ComputerName'"
        $CimServer = New-PermissionCacheRef -Key $String -Value ([type]'System.Management.Automation.PSReference')
        $null = $CimCache.Value.AddOrUpdate( $ComputerName , $CimServer, $AddOrUpdateScriptblock )

    }

    if (
        $ComputerName -eq $ThisHostname -or
        $ComputerName -eq "$ThisHostname." -or
        $ComputerName -eq $ThisFqdn -or
        $ComputerName -eq "$ThisFqdn." -or
        $ComputerName -eq 'localhost' -or
        $ComputerName -eq '127.0.0.1' -or
        [String]::IsNullOrEmpty($ComputerName)
    ) {

        Write-LogMsg @Log -Text '$CimSession = New-CimSession'
        $CimSession = New-CimSession

    } else {

        # If an Active Directory domain is targeted there are no local accounts and CIM connectivity is not expected
        # Suppress errors and return nothing in that case
        Write-LogMsg @Log -Text "`$CimSession = New-CimSession -ComputerName $ComputerName"
        $CimSession = New-CimSession -ComputerName $ComputerName -ErrorAction SilentlyContinue

    }

    if ($CimSession) {

        $null = $CimServer.Value.AddOrUpdate( 'CimSession' , $CimSession , $AddOrUpdateScriptblock )
        return $CimSession

    } else {
        #Write-LogMsg @Log -Text " # No CIM session returned # for $ComputerName"
    }

}
function Get-PermissionPrincipal {

    param (

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # Maximum number of concurrent threads to allow
        [int]$ThreadCount = (Get-CimInstance -ClassName CIM_Processor | Measure-Object -Sum -Property NumberOfLogicalProcessors).Sum,

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        <#
        Do not get group members (only report the groups themselves)
 
        Note: By default, the -ExcludeClass parameter will exclude groups from the report.
          If using -NoGroupMembers, you most likely want to modify the value of -ExcludeClass.
          Remove the 'group' class from ExcludeClass in order to see groups on the report.
        #>

        [switch]$NoGroupMembers,

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # In-process cache to reduce calls to other processes or to disk
        [Parameter(Mandatory)]
        [ref]$Cache,

        # The current domain
        # Can be passed as a parameter to reduce calls to Get-CurrentDomain
        [string]$CurrentDomain = (Get-CurrentDomain -Cache $Cache)

    )

    $Progress = @{
        Activity = 'Get-PermissionPrincipal'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $Progress['Id'] = $ProgressParentId + 1
    } else {
        $Progress['Id'] = 0
    }

    [string[]]$IDs = $Cache.Value['AceGuidByID'].Value.Keys
    $Count = $IDs.Count
    Write-Progress @Progress -Status "0% (identity 0 of $Count) ConvertFrom-IdentityReferenceResolved" -CurrentOperation 'Initialize' -PercentComplete 0
    $LogBuffer = $Cache.Value['LogBuffer']
    $Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $LogBuffer ; WhoAmI = $WhoAmI }

    $ADSIConversionParams = @{
        ThisHostName      = $ThisHostName
        ThisFqdn          = $ThisFqdn
        WhoAmI            = $WhoAmI
        DebugOutputStream = $DebugOutputStream
        CurrentDomain     = $CurrentDomain
        Cache             = $Cache
    }

    if ($ThreadCount -eq 1) {

        if ($NoGroupMembers) {
            $ADSIConversionParams['NoGroupMembers'] = $true
        }

        [int]$ProgressInterval = [math]::max(($Count / 100), 1)
        $IntervalCounter = 0
        $i = 0

        ForEach ($ThisID in $IDs) {

            $IntervalCounter++

            if ($IntervalCounter -eq $ProgressInterval) {

                [int]$PercentComplete = $i / $Count * 100
                Write-Progress @Progress -Status "$PercentComplete% (identity $($i + 1) of $Count) ConvertFrom-IdentityReferenceResolved" -CurrentOperation $ThisID -PercentComplete $PercentComplete
                $IntervalCounter = 0

            }

            $i++
            Write-LogMsg @Log -Text "ConvertFrom-IdentityReferenceResolved -IdentityReference '$ThisID'" -Expand $ADSIConversionParams
            ConvertFrom-IdentityReferenceResolved -IdentityReference $ThisID @ADSIConversionParams

        }

    } else {

        if ($NoGroupMembers) {
            $ADSIConversionParams['AddSwitch'] = 'NoGroupMembers'
        }

        $SplitThreadParams = @{
            Command              = 'ConvertFrom-IdentityReferenceResolved'
            InputObject          = $IDs
            InputParameter       = 'IdentityReference'
            ObjectStringProperty = 'Name'
            TodaysHostname       = $ThisHostname
            WhoAmI               = $WhoAmI
            LogBuffer            = $LogBuffer
            Threads              = $ThreadCount
            ProgressParentId     = $Progress['Id']
            AddParam             = $ADSIConversionParams
        }

        Write-LogMsg @Log -Text 'Split-Thread' -Expand $SplitThreadParams
        Split-Thread @SplitThreadParams

    }

    Write-Progress @Progress -Completed

}
function Get-PermissionTrustedDomain {

    param (

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    Get-TrustedDomain -ThisHostname $ThisHostname -WhoAmI $WhoAmI -LogBuffer $Cache.Value['LogBuffer']

}
function Get-PermissionWhoAmI {

    param (

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    Get-CurrentWhoAmI -ThisHostName $ThisHostname -LogBuffer $Cache.Value['LogBuffer']

}
function Get-TimeZoneName {
    param (
        [datetime]$Time,
        [Microsoft.Management.Infrastructure.CimInstance]$TimeZone = (Get-CimInstance -ClassName Win32_TimeZone)
    )
    if ($Time.IsDaylightSavingTime()) {
        return $TimeZone.DaylightName
    } else {
        return $TimeZone.StandardName
    }
}
function Initialize-Cache {

    <#
    Pre-populate caches in memory to avoid redundant ADSI and CIM queries
    Use known ADSI and CIM server FQDNs to populate six caches:
       Three caches of known ADSI directory servers
         The first cache is keyed on domain SID (e.g. S-1-5-2)
         The second cache is keyed on domain FQDN (e.g. ad.contoso.com)
         The first cache is keyed on domain NetBIOS name (e.g. CONTOSO)
       Two caches of known Win32_Account instances
         The first cache is keyed on SID (e.g. S-1-5-2)
         The second cache is keyed on the Caption (NT Account name e.g. CONTOSO\user1)
       Also populate a cache of DirectoryEntry objects for any domains that have them
     This prevents threads that start near the same time from finding the cache empty and attempting costly operations to populate it
     This prevents repetitive queries to the same directory servers
    #>


    param (

        # FQDNs of the ADSI servers to use to populate the cache
        [string[]]$Fqdn,

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # Maximum number of concurrent threads to allow
        [int]$ThreadCount = (Get-CimInstance -ClassName CIM_Processor | Measure-Object -Sum -Property NumberOfLogicalProcessors).Sum,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # Output from Get-KnownSidHashTable
        [hashtable]$WellKnownSIDBySID = (Get-KnownSidHashTable),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Progress = @{
        Activity = 'Initialize-Cache'
    }

    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {

        $Progress['ParentId'] = $ProgressParentId
        $ProgressId = $ProgressParentId + 1

    } else {
        $ProgressId = 0
    }

    $Progress['Id'] = $ProgressId
    $Count = $Fqdn.Count
    $LogBuffer = $Cache.Value['LogBuffer']
    $Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $LogBuffer ; WhoAmI = $WhoAmI }
    $WellKnownSIDByName = @{}

    ForEach ($KnownSID in $WellKnownSIDBySID.Keys) {

        $Known = $WellKnownSIDBySID[$KnownSID]
        $WellKnownSIDByName[$Known.Name] = $Known

    }

    $GetAdsiServer = @{
        Cache              = $Cache
        DebugOutputStream  = $DebugOutputStream
        ThisHostName       = $ThisHostName
        ThisFqdn           = $ThisFqdn
        WhoAmI             = $WhoAmI
        WellKnownSIDBySID  = $WellKnownSIDBySID
        WellKnownSIDByName = $WellKnownSIDByName
    }

    if ($ThreadCount -eq 1) {

        $i = 0

        ForEach ($ThisServerName in $Fqdn) {

            [int]$PercentComplete = $i / $Count * 100
            Write-Progress -Status "$PercentComplete% (FQDN $($i + 1) of $Count) Get-AdsiServer" -CurrentOperation "Get-AdsiServer '$ThisServerName'" -PercentComplete $PercentComplete @Progress
            $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically
            Write-LogMsg @Log -Text "Get-AdsiServer -Fqdn '$ThisServerName'"
            $null = Get-AdsiServer -Fqdn $ThisServerName @GetAdsiServer

        }

    } else {

        $SplitThread = @{
            Command          = 'Get-AdsiServer'
            InputObject      = $Fqdn
            InputParameter   = 'Fqdn'
            TodaysHostname   = $ThisHostname
            WhoAmI           = $WhoAmI
            LogBuffer        = $LogBuffer
            Timeout          = 600
            Threads          = $ThreadCount
            ProgressParentId = $ProgressParentId
            AddParam         = $GetAdsiServer
        }

        Write-LogMsg @Log -Text "Split-Thread -Command 'Get-AdsiServer' -InputParameter AdsiServer -InputObject @('$($Fqdn -join "',")')"
        $null = Split-Thread @SplitThread

    }

    Write-Progress @Progress -Completed

}
function Invoke-PermissionAnalyzer {

    param (

        # Each key is a string representing the path of an item allowed to have permissions inheritance disabled. Values are irrelevant.
        [hashtable]$AllowDisabledInheritance,

        <#
        Valid accounts that are allowed to appear in ACEs
 
        Specify as a ScriptBlock meant for the FilterScript parameter of Where-Object
 
        By default, this is a ScriptBlock that always evaluates to $true so it doesn't evaluate any account convention compliance
 
        In the ScriptBlock, any account properties are available for evaluation:
 
        e.g. {$_.DomainNetbios -eq 'CONTOSO'} # Accounts used in ACEs should be in the CONTOSO domain
        e.g. {$_.Name -eq 'Group23'} # Accounts used in ACEs should be named Group23
        e.g. {$_.ResolvedAccountName -like 'CONTOSO\Group1*' -or $_.ResolvedAccountName -eq 'CONTOSO\Group23'}
 
        The format of the ResolvedAccountName property is CONTOSO\Group1
        where
            CONTOSO is the NetBIOS name of the domain (the computer name for local accounts)
            and
            Group1 is the samAccountName of the account
        #>

        [scriptblock]$AccountConvention = { $true },

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    <#
    Issue:
        Items with permissions inheritance disabled
    Explanation: Disabling inheritance adds complexity to management of permissions, leading to unexpected behavior.
        Example: 1st ticket - Grant access to folder1 and all of its contents
                    2nd ticket - Unable to access folder1\subfolder1 (oops, nobody knew subfolder1 had inheritance disabled)
    Recommended: Enable inheritance. To achieve the desired behavior, consider these alternatives in order of preference:
        1. Modify ACE inheritance and propagation flags (instead of ACL inheritance).
        2. Move folders up higher in the folder structure if they require access not achievable with inheritance and propagation flags.
        Example: Ticket - Grant access to folder1 but none of its subfolders
                    Not Recommended - Disabling inheritance of all ACEs on all subfolders of folder1
                    Recommended - The ACE on folder1 which grants access should have the flags set to TODO VERIFY CORRECT CONFIG, BELOW ALL SETTINGS ARE LISTED INSTEAD:
                      Propagation flags:
                        0 (None) No inheritance flags are set.
                        1 (NoPropagateInherit) ACE is not propagated to child objects.
                        2 (InheritOnly) ACE is propagated only to child objects. This includes both container and leaf child objects.
                      Inheritance flags:
                        0 (None) ACE is inherited by child container objects.
                        1 (ContainerInherit) ACE is inherited by child container objects.
                        2 (ObjectInherit) ACE is inherited by child leaf objects.
 
                        This ensures the new access will not propagate to any subfolders of folder 1, without disrupting ACL inheritance.
    #>

    $AclByPath = $Cache.Value['AclByPath']
    $AceByGUID = $Cache.Value['AceByGUID']
    $AceByGUID = $Cache.Value['AceByGUID']
    $PrincipalByID = $Cache.Value['PrincipalByID']
    $ItemsWithBrokenInheritance = $AclByPath.Value.Keys |
    Where-Object -FilterScript {
        $AclByPath.Value[$_].AreAccessRulesProtected -and
        -not $AllowDisabledInheritance[$_]
    }

    # Groups that were used in ACEs but do not match the specified naming convention
    # Invert the naming convention scriptblock (because we actually want to identify groups that do NOT follow the convention)
    $ViolatesAccountConvention = [scriptblock]::Create("!($AccountConvention)")
    $NonCompliantAccounts = $PrincipalByID.Value.Values |
    Where-Object -FilterScript { $_.SchemaClassName -eq 'Group' } |
    Where-Object -FilterScript $ViolatesAccountConvention
    if ($NonCompliantAccounts) {
        $AceGUIDsWithNonCompliantAccounts = $Cache.Value['AceGuidByID'].Value[$NonCompliantAccounts]
    }
    if ($AceGUIDsWithNonCompliantAccounts) {
        $ACEsWithNonCompliantAccounts = $AceByGUID.Value[$AceGUIDsWithNonCompliantAccounts]
    }

    $ACEsWithUsers = [System.Collections.Generic.List[PSCustomObject]]::new()
    $ACEsWithUnresolvedSIDs = [System.Collections.Generic.List[PSCustomObject]]::new()
    $ACEsWithCreatorOwner = [System.Collections.Generic.List[PSCustomObject]]::new()

    ForEach ($ACE in $AceByGUID.Value.Values) {

        # ACEs for users (recommend replacing with group-based access on any folder that is not a home folder)
        if (
            $PrincipalByID.Value[$ACE.IdentityReferenceResolved].SchemaClassName -eq 'User' -and
            $_.IdentityReferenceSID -ne 'S-1-5-18' -and # The 'NT AUTHORITY\SYSTEM' account is part of default Windows file permissions and is out of scope
            $_.SourceOfAccess -ne 'Ownership' # Currently Ownership is out of scope. Should it be?
        ) {
            $ACEsWithUsers.Add($ACE)
        }

        # ACEs for unresolvable SIDs (recommend removing these ACEs)
        if ( $ACE.IdentityReferenceResolved -like "*$($ACE.IdentityReferenceSID)*" ) {
            $ACEsWithUnresolvedSIDs.Add($ACE)
        }

        # CREATOR OWNER access (recommend replacing with group-based access, or with explicit user access for a home folder.)
        if ( $ACE.IdentityReferenceResolved -match 'CREATOR OWNER' ) {
            $ACEsWithCreatorOwner.Add($ACE)
        }

    }

    return [PSCustomObject]@{
        ACEsWithCreatorOwner         = $ACEsWithCreatorOwner
        ACEsWithNonCompliantAccounts = $ACEsWithNonCompliantAccounts
        ACEsWithUsers                = $ACEsWithUsers
        ACEsWithUnresolvedSIDs       = $ACEsWithUnresolvedSIDs
        ItemsWithBrokenInheritance   = $ItemsWithBrokenInheritance
        NonCompliantAccounts         = $NonCompliantAccounts
    }

}
function Invoke-PermissionCommand {

    param (

        [String]$Command

    )

    $Steps = [System.Collections.Specialized.OrderedDictionary]::New()
    $Steps.Add(
        'Get the NTAccount caption of the user running the script, with the correct capitalization',
        { HOSTNAME.EXE }
    )
    $Steps.Add(
        'Get the hostname of the computer running the script',
        { Get-CurrentWhoAmI -LogBuffer $LogBuffer -ThisHostName $ThisHostname }
    )

    $LogParams = @{
        Buffer       = $LogBuffer
        ThisHostname = $ThisHostname
        #Type = $DebugOutputStream
        WhoAmI       = $WhoAmI
    }

    $StepCount = $Steps.Count
    Write-LogMsg @LogParams -Type Verbose -Text $Command
    $ScriptBlock = $Steps[$Command]
    Write-LogMsg @LogParams -Type Debug -Text $ScriptBlock
    Invoke-Command -ScriptBlock $ScriptBlock

}
function New-PermissionCache {

    $Boolean = [type]'String'
    $String = [type]'String'
    $GuidList = [type]'System.Collections.Generic.List[Guid]'
    $StringArray = [type]'String[]'
    $StringList = [type]'System.Collections.Generic.List[String]'
    $Object = [type]'Object'
    $PSCustomObject = [type]'PSCustomObject'
    $DirectoryInfo = [type]'System.IO.DirectoryInfo'
    $PSReference = [type]'ref'

    <#
    $CimCache
        Key is a String
        Value is a dict
            Key is a String
            Value varies (CimSession or dict)
#>

    return [hashtable]::Synchronized(@{
            AceByGUID                    = New-PermissionCacheRef -Key $String -Value $Object #hashtable Initialize a cache of access control entries keyed by GUID generated in Resolve-ACE.
            AceGuidByID                  = New-PermissionCacheRef -Key $String -Value $GuidList #hashtable Initialize a cache of access control entry GUIDs keyed by their resolved NTAccount captions.
            AceGuidByPath                = New-PermissionCacheRef -Key $String -Value $GuidList #hashtable Initialize a cache of access control entry GUIDs keyed by their paths.
            AclByPath                    = New-PermissionCacheRef -Key $String -Value $PSCustomObject #hashtable Initialize a cache of access control lists keyed by their paths.
            CimCache                     = New-PermissionCacheRef -Key $String -Value $PSReference #hashtable Initialize a cache of CIM sessions, instances, and query results.
            DirectoryEntryByPath         = New-PermissionCacheRef -Key $String -Value $Object #DirectoryEntryCache Initialize a cache of ADSI directory entry keyed by their Path to minimize ADSI queries.
            DomainBySID                  = New-PermissionCacheRef -Key $String -Value $Object #DomainsBySID Initialize a cache of directory domains keyed by domain SID to minimize CIM and ADSI queries.
            DomainByNetbios              = New-PermissionCacheRef -Key $String -Value $Object #DomainsByNetbios Initialize a cache of directory domains keyed by domain NetBIOS to minimize CIM and ADSI queries.
            DomainByFqdn                 = New-PermissionCacheRef -Key $String -Value $Object #DomainsByFqdn Initialize a cache of directory domains keyed by domain DNS FQDN to minimize CIM and ADSI queries.
            ExcludeAccountFilterContents = New-PermissionCacheRef -Key $String -Value $Boolean #hashtable Initialize a cache of accounts filtered by the ExcludeAccount parameter.
            ExcludeClassFilterContents   = New-PermissionCacheRef -Key $String -Value $Boolean #hashtable Initialize a cache of accounts filtered by the ExcludeClass parameter.
            IdByShortName                = New-PermissionCacheRef -Key $String -Value $StringList #hashtable Initialize a cache of resolved NTAccount captions keyed by their short names (results of the IgnoreDomain parameter).
            IncludeAccountFilterContents = New-PermissionCacheRef -Key $String -Value $Boolean #hashtable Initialize a cache of accounts filtered by the IncludeAccount parameter.
            LogBuffer                    = New-PermissionCacheRef -Key $String -Value $Object # Initialize a cache of log messages in memory to minimize random disk access.
            ParentByTargetPath           = New-PermissionCacheRef -Key $DirectoryInfo -Value $StringArray #ParentByTargetPath hashtable Initialize a cache of resolved parent item paths keyed by their unresolved target paths.
            PrincipalByID                = New-PermissionCacheRef -Key $String -Value $PSCustomObject #hashtable Initialize a cache of ADSI security principals keyed by their resolved NTAccount caption.
            ShortNameByID                = New-PermissionCacheRef -Key $String -Value $StringArray  #hashtable Initialize a cache of short names (results of the IgnoreDomain parameter) keyed by their resolved NTAccount captions.
        })

}
function Out-Permission {

    param (

        # Type of output returned to the output stream
        [ValidateSet('passthru', 'none', 'csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [string]$OutputFormat = 'passthru',

        <#
        How to group the permissions in the output stream and within each exported file
 
            SplitBy GroupBy
            none none $FlatPermissions all in 1 file
            none account $AccountPermissions all in 1 file
            none item $ItemPermissions all in 1 file
 
            account none 1 file per item in $AccountPermissions. In each file, $_.Access | sort path
            account account (same as -SplitBy account -GroupBy none)
            account item 1 file per item in $AccountPermissions. In each file, $_.Access | group item | sort name
 
            item none 1 file per item in $ItemPermissions. In each file, $_.Access | sort account
            item account 1 file per item in $ItemPermissions. In each file, $_.Access | group account | sort name
            item item (same as -SplitBy item -GroupBy none)
 
            target none 1 file per $TargetPath. In each file, sort ACEs by item path then account name
            target account 1 file per $TargetPath. In each file, group ACEs by account and sort by account name
            target item 1 file per $TargetPath. In each file, group ACEs by item and sort by item path
            target target (same as -SplitBy target -GroupBy none)
        #>

        [ValidateSet('account', 'item', 'none', 'target')]
        [string]$GroupBy = 'item',

        [hashtable]$FormattedPermission

    )

    ForEach ($Split in 'target', 'item', 'account') {
        $ThisFormat = $FormattedPermission["SplitBy$Split"]
        if ($ThisFormat) {
            $ThisFormat
        }
    }

    <#
    switch ($GroupBy) {
        'None' {
            $TypeData = @{
                TypeName = 'Permission.PassThruPermission'
                DefaultDisplayPropertySet = 'Folder', 'Account', 'Access'
                ErrorAction = 'SilentlyContinue'
            }
            Update-TypeData @TypeData
            return $FolderPermissions
        }
        'Item' {
            Update-TypeData -MemberName Folder -Value { $This.Name } -TypeName 'Permission.FolderPermission' -MemberType ScriptProperty -ErrorAction SilentlyContinue
            Update-TypeData -MemberName Access -TypeName 'Permission.FolderPermission' -MemberType ScriptProperty -ErrorAction SilentlyContinue -Value {
                $Access = ForEach ($Permission in $This.Access) {
                    [pscustomobject]@{
                        Account = $Permission.Account
                        Access = $Permission.Access
                    }
                }
                $Access
            }
            Update-TypeData -DefaultDisplayPropertySet ('Path', 'Access') -TypeName 'Permission.FolderPermission' -ErrorAction SilentlyContinue
            return $GroupedPermissions
        }
        'Account' {
            Update-TypeData -MemberName Account -Value { $This.Name } -TypeName 'Permission.AccountPermission' -MemberType ScriptProperty -ErrorAction SilentlyContinue
            Update-TypeData -MemberName Access -TypeName 'Permission.AccountPermission' -MemberType ScriptProperty -ErrorAction SilentlyContinue -Value {
                $Access = ForEach ($Permission in $This.Group) {
                    [pscustomobject]@{
                        Folder = $Permission.Folder
                        Access = $Permission.Access
                    }
                }
                $Access
            }
            Update-TypeData -DefaultDisplayPropertySet ('Account', 'Access') -TypeName 'Permission.AccountPermission' -ErrorAction SilentlyContinue
 
            #Group-Permission -InputObject $FolderPermissions -Property Account |
            #Sort-Object -Property Name
            return
        }
        Default { return }
    }
    #>

    <#
    # Output the result to the pipeline
    ForEach ($Key in $FormattedPermission.Keys) {
 
        ForEach ($Target in $FormattedPermission[$Key]) {
 
            ForEach ($NetworkPath in $Target.NetworkPaths) {
 
                [PSCustomObject]@{
                    PSTypeName = 'Permission.ParentItemPermission'
                    Item = $NetworkPath.Item
                    Items = ForEach ($Permission in $NetworkPath.$OutputFormat) {
 
                        [PSCustomObject]@{
                            Path = $Permission.Grouping
                            Access = $Permission.$OutputFormat
                            PSTypeName = 'Permission.Item'
                        }
 
                    }
 
                }
 
            }
 
        }
 
    }
 
    #>

    <#
    if ($OutputFormat -eq 'PrtgXml') {
        # Output the XML so the script can be directly used as a PRTG sensor
        # Caution: This use may be a problem for a PRTG probe because of how long the script can run on large folders/domains
        # Recommendation: Specify the appropriate parameters to run this as a PRTG push sensor instead
        return $XMLOutput
    }
    #>


}
function Out-PermissionFile {

    # missing

    param (

        # Regular expressions matching names of security principals to exclude from the HTML report
        [string[]]$ExcludeAccount,

        # Accounts whose objectClass property is in this list are excluded from the HTML report
        [string[]]$ExcludeClass = @('group', 'computer'),

        <#
        Domain(s) to ignore (they will be removed from the username)
 
        Intended when a user has matching SamAccountNames in multiple domains but you only want them to appear once on the report.
 
        Can also be used to remove all domains simply for brevity in the report.
        #>

        $IgnoreDomain,

        # Path to the NTFS folder whose permissions are being exported
        [string[]]$TargetPath,

        # Group members are not being exported (only the groups themselves)
        [switch]$NoMembers,

        # Path to the folder to save the logs and reports generated by this script
        $OutputDir,

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        # Expects an NTAccount Name (e.g. DOMAIN\user)
        [String]$WhoAmI = (whoami.EXE),

        # FQDN of the computer running the script
        $ThisFqdn,

        # Timer to measure progress and performance
        $StopWatch,

        # Title at the top of the HTML report
        $Title,

        $Permission,
        $FormattedPermission,
        $LogParams,
        $RecurseDepth,
        $LogFileList,
        $ReportInstanceId,
        [Hashtable]$AceByGUID,
        [Hashtable]$AclByPath,
        [Hashtable]$PrincipalByID,
        [Hashtable]$Parent,

        <#
        Level of detail to export to file
            0 Item paths $TargetPath
            1 Resolved item paths (server names resolved, DFS targets resolved) $Parents
            2 Expanded resolved item paths (parent paths expanded into children) $ACLsByPath.Keys
            3 Access rules $ACLsByPath.Values
            4 Resolved access rules (server names resolved, inheritance flags resolved) $AceByGUID.Values | %{$_} | Sort Path,IdentityReferenceResolved
            5 Accounts with access $PrincipalsByResolvedID.Values | %{$_} | Sort ResolvedAccountName
            6 Expanded resolved access rules (expanded with account info) $Permissions
            7 Formatted permissions $FormattedPermissions
            8 Best Practice issues $BestPracticeEval
            9 XML custom sensor output for Paessler PRTG Network Monitor $PrtgXml
            10 Permission Report
        #>

        [int[]]$Detail = @(0..10),

        <#
        Information about the current culture settings.
        This includes information about the current language settings on the system, such as the keyboard layout, and the
        display format of items such as numbers, currency, and dates.
        #>

        [cultureinfo]$Culture = (Get-Culture),

        # File format(s) to export
        [ValidateSet('csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [string[]]$FileFormat = @('csv', 'html', 'js', 'json', 'prtgxml', 'xml'),

        # Type of output returned to the output stream
        [ValidateSet('passthru', 'none', 'csv', 'html', 'js', 'json', 'prtgxml', 'xml')]
        [String]$OutputFormat = 'passthru',

        <#
        How to group the permissions in the output stream and within each exported file
 
            SplitBy GroupBy
            none none $FlatPermissions all in 1 file per $TargetPath
            none account $AccountPermissions all in 1 file per $TargetPath
            none item $ItemPermissions all in 1 file per $TargetPath (default behavior)
 
            item none 1 file per item in $ItemPermissions. In each file, $_.Access | sort account
            item account 1 file per item in $ItemPermissions. In each file, $_.Access | group account | sort name
            item item (same as -SplitBy item -GroupBy none)
 
            account none 1 file per item in $AccountPermissions. In each file, $_.Access | sort path
            account account (same as -SplitBy account -GroupBy none)
            account item 1 file per item in $AccountPermissions. In each file, $_.Access | group item | sort name
        #>

        [ValidateSet('account', 'item', 'none', 'target')]
        [String]$GroupBy = 'item',

        <#
        How to split up the exported files:
            none generate 1 file with all permissions
            target generate 1 file per target
            item generate 1 file per item
            account generate 1 file per account
            all generate 1 file per target and 1 file per item and 1 file per account and 1 file with all permissions.
        #>

        [ValidateSet('none', 'all', 'target', 'item', 'account')]
        [string[]]$SplitBy = 'target',

        [PSCustomObject]$BestPracticeEval

    )

    # Determine all formats specified by the parameters
    $Formats = Resolve-FormatParameter -FileFormat $FileFormat -OutputFormat $OutputFormat

    # String translations indexed by value in the $Detail parameter
    # TODO: Move to i18n
    $DetailStrings = @(
        'Item paths',
        'Resolved item paths (server names and DFS targets resolved)',
        'Expanded resolved item paths (resolved target paths expanded into their children)',
        'Access lists',
        'Access rules (resolved identity references and inheritance flags)',
        'Accounts with access',
        'Expanded access rules (expanded with account info)', # #ToDo: Expand DirectoryEntry objects in the DirectoryEntry and Members properties
        'Formatted permissions',
        'Best Practice issues',
        'Custom sensor output for Paessler PRTG Network Monitor',
        'Permission report'
    )

    $UnsplitDetail = $Detail | Where-Object -FilterScript { $_ -le 5 -or $_ -in 8, 9 }
    $SplitDetail = $Detail | Where-Object -FilterScript { $_ -gt 5 -and $_ -notin 8, 9 }

    $DetailScripts = @(
        { $TargetPath },
        { ForEach ($Key in $Parent.Keys) {
                [PSCustomObject]@{
                    OriginalTargetPath  = $Key
                    ResolvedNetworkPath = $Parent[$Key]
                }
            }
        },
        { $ACLsByPath.Keys },
        { $ACLsByPath.Values },
        { ForEach ($val in $AceByGUID.Values) { $val } },
        { ForEach ($val in $PrincipalsByResolvedID.Values) { $val } },
        {

            switch ($SplitBy) {
                'account' { $Permission.AccountPermissions ; break }
                'none' { $Permission.FlatPermissions ; break }
                'item' { $Permission.ItemPermissions ; break }
                'target' { $Permission.TargetPermissions ; break }
            }

        },
        { $Permissions.Data },
        { $BestPracticeEval },
        { ConvertTo-PermissionPrtgXml -Analysis $Analysis },
        {}
    )

    ForEach ($Split in $Permission.SplitBy.Keys) {

        switch ($Split) {

            'account' {
                $Subproperty = ''
                $FileNameProperty = $Split
                $FileNameSubproperty = 'ResolvedAccountName'
                $ReportFiles = $FormattedPermission["SplitBy$Split"]
                break
            }

            'item' {
                $Subproperty = ''
                $FileNameProperty = $Split
                $FileNameSubproperty = 'Path'
                $ReportFiles = $FormattedPermission["SplitBy$Split"]
                break
            }

            'none' {
                $Subproperty = 'NetworkPaths'
                $FileNameProperty = ''
                $FileNameSubproperty = 'Path'
                $ReportFiles = [PSCustomObject]@{
                    NetworkPaths = $FormattedPermission['SplitByTarget'].NetworkPaths
                    Path         = $FormattedPermission['SplitByTarget'].Path.FullName
                }
                break
            }

            'target' {
                $Subproperty = 'NetworkPaths'
                $FileNameProperty = ''
                $FileNameSubproperty = 'Path'
                $ReportFiles = $FormattedPermission["SplitBy$Split"]
                break
            }

        }

        ForEach ($Format in $Formats) {

            $FormatString = $Format
            $FormatDir = "$OutputDir\$Format"
            $null = New-Item -Path $FormatDir -ItemType Directory -ErrorAction SilentlyContinue

            switch ($Format) {

                'csv' {

                    $DetailExports = @(
                        { $args[0] | Out-File -LiteralPath $args[1] },
                        { $args[0] | Export-Csv -NoTypeInformation -LiteralPath $args[1] },
                        { $args[0] | Out-File -LiteralPath $args[1] },
                        { $args[0] | Export-Csv -NoTypeInformation -LiteralPath $args[1] },
                        { $args[0] | Export-Csv -NoTypeInformation -LiteralPath $args[1] },
                        { $args[0] | Export-Csv -NoTypeInformation -LiteralPath $args[1] },
                        { $args[0] | Export-Csv -NoTypeInformation -LiteralPath $args[1] },
                        { $args[0] | Out-File -LiteralPath $args[1] },
                        { },
                        { },
                        { }
                    )

                    $DetailScripts[10] = { }
                    break

                }

                'html' {

                    $DetailExports = @(
                        { $args[0] | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Html -Fragment | Out-File -LiteralPath $args[1] },
                        { $args[0] -join "<br />`r`n" | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Html -Fragment | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Html -Fragment | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Html -Fragment | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Html -Fragment | Out-File -LiteralPath $args[1] },
                        { $args[0] | Out-File -LiteralPath $args[1] },
                        { },
                        { },
                        { $null = Set-Content -LiteralPath $args[1] -Value $args[0] }
                    )

                    $DetailScripts[10] = {

                        if (
                            $GroupBy -eq 'none' -or
                            $GroupBy -eq $Split
                        ) {

                            # Combine all the elements into a single string which will be the innerHtml of the <body> element of the report
                            Write-LogMsg @LogParams -Text "Get-HtmlBody -HtmlFolderPermissions `$FormattedPermission.$Format.Div"
                            $Body = Get-HtmlBody @BodyParams

                            # Apply the report template to the generated HTML report body and description
                            $ReportParameters = $HtmlElements.ReportParameters

                            Write-LogMsg @LogParams -Text "New-BootstrapReport @ReportParameters"
                            New-BootstrapReport -Body $Body @ReportParameters

                        } else {

                            # Combine the header and table inside a Bootstrap div
                            Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText '$HtmlElements.SummaryTableHeader' -Content `$FormattedPermission.$Format`Group.Table"
                            $TableOfContents = New-BootstrapDivWithHeading -HeadingText $HtmlElements.SummaryTableHeader -Content $PermissionGroupings.Table -Class 'h-100 p-1 bg-light border rounded-3 table-responsive' -HeadingLevel 6

                            # Combine all the elements into a single string which will be the innerHtml of the <body> element of the report
                            Write-LogMsg @LogParams -Text "Get-HtmlBody -TableOfContents `$TableOfContents -HtmlFolderPermissions `$FormattedPermission.$Format.Div"
                            $Body = Get-HtmlBody -TableOfContents $TableOfContents @BodyParams

                        }

                        $ReportParameters = $HtmlElements.ReportParameters

                        # Apply the report template to the generated HTML report body and description
                        Write-LogMsg @LogParams -Text "New-BootstrapReport @$HtmlElements.ReportParameters"
                        New-BootstrapReport -Body $Body @ReportParameters

                    }
                    break

                }

                'js' {

                    $DetailExports = @(
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { },
                        { },
                        { $null = Set-Content -LiteralPath $args[1] -Value $args[0] }
                    )

                    $DetailScripts[10] = {

                        #if ($Permission.FlatPermissions) {
                        if (
                            $GroupBy -eq 'none' -or
                            $GroupBy -eq $Split
                        ) {

                            # Combine all the elements into a single string which will be the innerHtml of the <body> element of the report
                            Write-LogMsg @LogParams -Text "Get-HtmlBody -HtmlFolderPermissions `$FormattedPermission.$Format.Div"
                            $Body = Get-HtmlBody @BodyParams

                        } else {

                            # Combine the header and table inside a Bootstrap div
                            Write-LogMsg @LogParams -Text "New-BootstrapDivWithHeading -HeadingText '$HtmlElements.SummaryTableHeader' -Content `$FormattedPermission.$Format`Group.Table"
                            $TableOfContents = New-BootstrapDivWithHeading -HeadingText $HtmlElements.SummaryTableHeader -Content $PermissionGroupings.Table -Class 'h-100 p-1 bg-light border rounded-3 table-responsive' -HeadingLevel 6

                            # Combine all the elements into a single string which will be the innerHtml of the <body> element of the report
                            Write-LogMsg @LogParams -Text "Get-HtmlBody -TableOfContents `$TableOfContents -HtmlFolderPermissions `$FormattedPermission.$Format.Div"
                            $Body = Get-HtmlBody -TableOfContents $TableOfContents @BodyParams

                        }

                        # Build the JavaScript scripts
                        Write-LogMsg @LogParams -Text "ConvertTo-ScriptHtml -Permission `$Permissions -PermissionGrouping `$PermissionGroupings"
                        $ScriptHtml = ConvertTo-ScriptHtml -Permission $Permissions -PermissionGrouping $PermissionGroupings -GroupBy $GroupBy -Split $Split
                        $ReportParameters = $HtmlElements.ReportParameters

                        # Apply the report template to the generated HTML report body and description
                        Write-LogMsg @LogParams -Text "New-BootstrapReport -JavaScript @$HtmlElements.ReportParameters"
                        New-BootstrapReport -JavaScript -AdditionalScriptHtml $ScriptHtml -Body $Body @ReportParameters

                    }

                    $FormatString = 'json'
                    break

                }

                'json' {

                    $DetailExports = @(
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { $args[0] | ConvertTo-Json -Compress -WarningAction SilentlyContinue | Out-File -LiteralPath $args[1] },
                        { },
                        { },
                        { }
                    )

                    $DetailScripts[10] = { }
                    break

                }

                'prtgxml' {

                    $DetailExports = @( { }, { }, { }, { }, { }, { }, { }, { }, { }, { $args[0] | Out-File -LiteralPath $args[1] } )
                    break

                }

                'xml' {

                    $DetailExports = @(
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { ($args[0] | ConvertTo-Xml).InnerXml | Out-File -LiteralPath $args[1] },
                        { }, { }, { }
                    )

                    $DetailScripts[10] = { }
                    break

                }

            }

            $ReportObjects = @{}

            ForEach ($Level in $UnsplitDetail) {

                # Save the report
                $ReportObjects[$Level] = Invoke-Command -ScriptBlock $DetailScripts[$Level]

                Out-PermissionDetailReport -Detail $Level -ReportObject $ReportObjects -DetailExport $DetailExports -Format $Format -OutputDir $FormatDir -Culture $Culture -DetailString $DetailStrings

            }

            ForEach ($File in $ReportFiles) {

                if ($Subproperty -eq '') {
                    $Subfile = $File
                } else {
                    $Subfile = $File.$Subproperty
                }

                if ($FileNameProperty -eq '') {
                    $FileName = $File.$FileNameSubproperty
                } else {
                    $FileName = $File.$FileNameProperty.$FileNameSubproperty
                }

                $FileName = $FileName -replace '\\\\', '' -replace '\\', '_' -replace '\:', ''

                # Convert the list of permission groupings list to an HTML table
                $PermissionGroupings = $Subfile."$FormatString`Group"
                $Permissions = $Subfile.$FormatString

                $ReportObjects = @{}

                [Hashtable]$Params = $PSBoundParameters
                $Params['TargetPath'] = $File.Path
                $Params['NetworkPath'] = $File.NetworkPaths
                $Params['Split'] = $Split
                $Params['FileName'] = $FileName
                $HtmlElements = Get-HtmlReportElements @Params

                $BodyParams = @{
                    HtmlFolderPermissions = $Permissions.Div
                    HtmlExclusions        = $HtmlElements.ExclusionsDiv
                    HtmlFileList          = $HtmlElements.HtmlDivOfFiles
                    ReportFooter          = $HtmlElements.ReportFooter
                    SummaryDivHeader      = $HtmlElements.SummaryDivHeader
                    DetailDivHeader       = $HtmlElements.DetailDivHeader
                    NetworkPathDiv        = $HtmlElements.NetworkPathDiv
                }

                ForEach ($Level in $SplitDetail) {

                    # Save the report
                    $ReportObjects[$Level] = Invoke-Command -ScriptBlock $DetailScripts[$Level]

                }

                switch ($Format) {

                    'csv' {

                        Out-PermissionDetailReport -Detail $SplitDetail -ReportObject $ReportObjects -DetailExport $DetailExports -Format $Format -OutputDir $FormatDir -Culture $Culture -DetailString $DetailStrings
                        break

                    }

                    'html' {

                        Out-PermissionDetailReport -Detail $SplitDetail -ReportObject $ReportObjects -DetailExport $DetailExports -Format $Format -OutputDir $FormatDir -FileName $FileName -Culture $Culture -DetailString $DetailStrings
                        break

                    }

                    'js' {

                        Out-PermissionDetailReport -Detail $SplitDetail -ReportObject $ReportObjects -DetailExport $DetailExports -Format $Format -OutputDir $FormatDir -FileName $FileName -Culture $Culture -DetailString $DetailStrings
                        break

                    }

                    # Nothing for 'prtgxml' because it is an Unsplit detail level only

                    'xml' {

                        Out-PermissionDetailReport -Detail $SplitDetail -ReportObject $ReportObjects -DetailExport $DetailExports -Format $Format -OutputDir $FormatDir -Culture $Culture -DetailString $DetailStrings
                        break

                    }

                }

            }

        }

    }

}
function Remove-CachedCimSession {

    param (

        # Cache of CIM sessions and instances to reduce connections and queries
        [Hashtable]$CimCache = ([Hashtable]::Synchronized(@{}))

    )

    ForEach ($CacheResult in $CimCache.Values) {

        if ($CacheResult) {

            $CimSession = $CacheResult['CimSession']

            if ($CimSession) {
                $null = Remove-CimSession -CimSession $CimSession
            }

        }

    }

}
function Resolve-AccessControlList {

    # Wrapper to multithread Resolve-Acl
    # Resolve identities in access control lists to their SIDs and NTAccount names

    param (

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # Maximum number of concurrent threads to allow
        [int]$ThreadCount = (Get-CimInstance -ClassName CIM_Processor | Measure-Object -Sum -Property NumberOfLogicalProcessors).Sum,

        <#
        Hostname of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE
        #>

        [String]$ThisHostName = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # String translations indexed by value in the [System.Security.AccessControl.InheritanceFlags] enum
        # Parameter default value is on a single line as a workaround to a PlatyPS bug
        [string[]]$InheritanceFlagResolved = @('this folder but not subfolders', 'this folder and subfolders', 'this folder and files, but not subfolders', 'this folder, subfolders, and files'),

        # In-process cache to reduce calls to other processes or to disk
        [Parameter(Mandatory)]
        [ref]$Cache

    )

    $Log = @{ ThisHostname = $ThisHostname ; Type = $DebugOutputStream ; Buffer = $Cache.Value['LogBuffer'] ; WhoAmI = $WhoAmI }

    $Progress = @{
        Activity = 'Resolve-AccessControlList'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $Progress['Id'] = $ProgressParentId + 1
    } else {
        $Progress['Id'] = 0
    }

    $ACLsByPath = $Cache.Value['AclByPath']
    $Paths = $ACLsByPath.Value.Keys
    $Count = $Paths.Count
    Write-Progress @Progress -Status "0% (ACL 0 of $Count)" -CurrentOperation 'Initializing' -PercentComplete 0
    $ACEPropertyName = $ACLsByPath.Value.Values.Access[0].PSObject.Properties.GetEnumerator().Name

    $ResolveAclParams = @{
        ACEPropertyName         = $ACEPropertyName
        Cache                   = $Cache
        InheritanceFlagResolved = $InheritanceFlagResolved
        ThisHostName            = $ThisHostName
        ThisFqdn                = $ThisFqdn
        WhoAmI                  = $WhoAmI
    }

    if ($ThreadCount -eq 1) {

        [int]$ProgressInterval = [math]::max(($Count / 100), 1)
        $IntervalCounter = 0
        $i = 0
        Write-LogMsg @Log -Text "`$Cache.Value['AclByPath'].Value.Keys | %{ Resolve-Acl -ItemPath '`$_'" -Expand $ResolveAclParams -Suffix ' }'

        ForEach ($ThisPath in $Paths) {

            $IntervalCounter++

            if ($IntervalCounter -eq $ProgressInterval) {

                [int]$PercentComplete = $i / $Count * 100
                Write-Progress @Progress -Status "$PercentComplete% (ACL $($i + 1) of $Count) Resolve-Acl" -CurrentOperation $ThisPath -PercentComplete $PercentComplete
                $IntervalCounter = 0

            }

            $i++ # increment $i after Write-Progress to show progress conservatively rather than optimistically
            #Write-LogMsg @Log -Text "Resolve-Acl -ItemPath '$ThisPath'" -Expand $ResolveAclParams
            Resolve-Acl -ItemPath $ThisPath @ResolveAclParams

        }

    } else {

        $SplitThreadParams = @{
            Command          = 'Resolve-Acl'
            InputObject      = $Paths
            InputParameter   = 'ItemPath'
            TodaysHostname   = $ThisHostname
            WhoAmI           = $WhoAmI
            LogBuffer        = $Cache.Value['LogBuffer']
            Threads          = $ThreadCount
            ProgressParentId = $Progress['Id']
            AddParam         = $ResolveAclParams
            #DebugOutputStream = 'Debug'
        }

        Write-LogMsg @Log -Text 'Split-Thread' -Expand $SplitThreadParams
        Split-Thread @SplitThreadParams

    }

    Write-Progress @Progress -Completed

}
function Resolve-PermissionTarget {

    # Resolve each target path to all of its associated UNC paths (including all DFS folder targets)

    param (

        # Path to the NTFS folder whose permissions to export
        [System.IO.DirectoryInfo[]]$TargetPath,

        # Output stream to send the log messages to
        [ValidateSet('Silent', 'Quiet', 'Success', 'Debug', 'Verbose', 'Output', 'Host', 'Warning', 'Error', 'Information', $null)]
        [String]$DebugOutputStream = 'Debug',

        # Hostname to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$ThisHostname = (HOSTNAME.EXE),

        <#
        FQDN of the computer running this function.
 
        Can be provided as a string to avoid calls to HOSTNAME.EXE and [System.Net.Dns]::GetHostByName()
        #>

        [String]$ThisFqdn = ([System.Net.Dns]::GetHostByName((HOSTNAME.EXE)).HostName),

        # Username to record in log messages (can be passed to Write-LogMsg as a parameter to avoid calling an external process)
        [String]$WhoAmI = (whoami.EXE),

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache,

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId

    )

    $Log = @{
        Buffer       = $Cache.Value['LogBuffer']
        ThisHostname = $ThisHostname
        Type         = $DebugOutputstream
        WhoAmI       = $WhoAmI
    }

    $ResolveFolderSplat = @{
        ThisFqdn          = $ThisFqdn
        Cache             = $Cache
        ThisHostname      = $ThisHostname
        DebugOutputStream = $DebugOutputStream
        WhoAmI            = $WhoAmI
    }

    $Parents = $Cache.Value['ParentByTargetPath']

    ForEach ($ThisTargetPath in $TargetPath) {

        Write-LogMsg @Log -Text "Resolve-Folder -TargetPath '$ThisTargetPath'" -Expand $ResolveFolderSplat -ExpandKeyMap @{ Cache = '$Cache' }
        $Parents.Value[$ThisTargetPath] = Resolve-Folder -TargetPath $ThisTargetPath @ResolveFolderSplat

    }

}
function Select-PermissionPrincipal {

    param (

        # Regular expressions matching names of Users or Groups to exclude from the Html report
        [string[]]$ExcludeAccount,

        # Regular expressions matching names of Users or Groups to exclude from the Html report
        [string[]]$IncludeAccount,

        <#
        Domain(s) to ignore (they will be removed from the username)
 
        Intended when a user has matching SamAccountNames in multiple domains but you only want them to appear once on the report.
 
        Can also be used to remove all domains simply for brevity in the report.
        #>

        [string[]]$IgnoreDomain,

        # ID of the parent progress bar under which to show progress
        [int]$ProgressParentId,

        # Unused parameter
        [String]$ThisHostName,

        # Unused parameter
        [String]$WhoAmI,

        # In-process cache to reduce calls to other processes or to disk
        [ref]$Cache

    )

    $Progress = @{
        Activity = 'Select-PermissionPrincipal'
    }
    if ($PSBoundParameters.ContainsKey('ProgressParentId')) {
        $Progress['ParentId'] = $ProgressParentId
        $Progress['Id'] = $ProgressParentId + 1
    } else {
        $Progress['Id'] = 0
    }

    $PrincipalByID = $Cache.Value['PrincipalByID']
    $IDs = $PrincipalByID.Value.Keys
    $Count = $IDs.Count
    Write-Progress @Progress -Status "0% (principal 0 of $Count) Select principals as specified in parameters" -CurrentOperation 'Ignore domains, and include/exclude principals based on name or class' -PercentComplete 0
    $Type = [string]

    ForEach ($ThisID in $IDs) {

        if (

            # Exclude the objects whose names match the regular expressions specified in the parameters
            [bool]$(
                ForEach ($ClassToExclude in $ExcludeClass) {

                    $Principal = $PrincipalByID.Value[$ThisID]

                    if ($Principal.SchemaClassName -eq $ClassToExclude) {
                        $Cache.Value['ExcludeClassFilterContents'].Value[$ThisID] = $true
                        $true
                    }
                }
            ) -or

            # Exclude the objects whose names match the regular expressions specified in the parameters
            [bool]$(
                ForEach ($RegEx in $ExcludeAccount) {
                    if ($ThisID -match $RegEx) {
                        $Cache.Value['ExcludeAccountFilterContents'].Value[$ThisID] = $true
                        $true
                    }
                }
            ) -or

            # Include the objects whose names match the regular expressions specified in the -IncludeAccount parameter
            -not [bool]$(

                if ($IncludeAccount.Count -eq 0) {
                    # If no regular expressions were specified, then return $true here
                    # This will be reversed into a $false by the -not operator above
                    # Resulting in the 'continue' statement not being reached, therefore this principal not being filtered out
                    $true
                } else {
                    ForEach ($RegEx in $IncludeAccount) {
                        if ($ThisID -match $RegEx) {
                            # If the account name matches one of the regular expressions, then return $true here
                            # This will be reversed into a $false by the -not operator above
                            # Resulting in the 'continue' statement not being reached, therefore this principal not being filtered out
                            $true
                        } else {
                            $Cache.Value['IncludeAccountFilterContents'].Value[$ThisID] = $true
                        }
                    }
                }

            )
        ) { continue }

        $ShortName = $ThisID

        ForEach ($IgnoreThisDomain in $IgnoreDomain) {
            $ShortName = $ShortName -replace "^$IgnoreThisDomain\\", ''
        }

        Add-PermissionCacheItem -Cache $Cache.Value['IdByShortName'] -Key $ShortName -Value $ThisID -Type $Type
        $Cache.Value['ShortNameByID'].Value[$ThisID] = $ShortName

    }

    Write-Progress @Progress -Completed

}

# Add any custom C# classes as usable (exported) types
$CSharpFiles = Get-ChildItem -Path "$PSScriptRoot\*.cs"
ForEach ($ThisFile in $CSharpFiles) {
    Add-Type -Path $ThisFile.FullName -ErrorAction Stop
}

Export-ModuleMember -Function @('Add-CachedCimInstance','Add-CacheItem','Add-PermissionCacheItem','ConvertTo-ItemBlock','ConvertTo-PermissionFqdn','Expand-Permission','Expand-PermissionTarget','Find-CachedCimInstance','Find-ResolvedIDsWithAccess','Find-ServerFqdn','Format-Permission','Format-TimeSpan','Get-AccessControlList','Get-CachedCimInstance','Get-CachedCimSession','Get-PermissionPrincipal','Get-PermissionTrustedDomain','Get-PermissionWhoAmI','Get-TimeZoneName','Initialize-Cache','Invoke-PermissionAnalyzer','Invoke-PermissionCommand','New-PermissionCache','Out-Permission','Out-PermissionFile','Remove-CachedCimSession','Resolve-AccessControlList','Resolve-PermissionTarget','Select-PermissionPrincipal')