EnterpriseInfo.ps1

function Script:Get-EnterpriseNodeAndDescendantIds {
    param([object]$enterpriseData, [long]$rootId)
    if ($rootId -le 0) { return $null }
    $subnodes = @{}
    foreach ($n in $enterpriseData.Nodes) {
        $id = $n.Id
        if ($n.ParentNodeId -gt 0) {
            if (-not $subnodes[$n.ParentNodeId]) { $subnodes[$n.ParentNodeId] = [System.Collections.Generic.List[long]]::new() }
            $subnodes[$n.ParentNodeId].Add($id) | Out-Null
        }
    }
    $set = [System.Collections.Generic.HashSet[long]]::new()
    $queue = [System.Collections.Generic.Queue[long]]::new()
    $queue.Enqueue($rootId) | Out-Null
    while ($queue.Count -gt 0) {
        $nid = $queue.Dequeue()
        [void]$set.Add($nid)
        if ($subnodes[$nid]) { foreach ($c in $subnodes[$nid]) { $queue.Enqueue($c) | Out-Null } }
    }
    return $set
}

function Get-KeeperEnterpriseInfoTree {
    <#
    .SYNOPSIS
    Display a tree structure of the enterprise (nodes with users, roles, teams).
    .DESCRIPTION
    Outputs a tree view of the enterprise hierarchy (nodes with users, roles, and teams). Output format is always tree.
    .PARAMETER Node
    Limit output to this node and its descendants (node name or ID).
    .PARAMETER Detailed
    Include node IDs and list individual users/roles/teams by name.
    .PARAMETER Output
    If supplied, write output to this file path.
    .EXAMPLE
    Get-KeeperEnterpriseInfoTree
    Get-KeeperEnterpriseInfoTree -Node "Sales" -Detailed -Output tree.txt
    #>

    [CmdletBinding()]
    Param (
        [Parameter()][string] $Node,
        [Parameter()][switch] $Detailed,
        [Parameter()][string] $Output
    )
    $enterprise = getEnterprise
    $ed = $enterprise.enterpriseData
    $rd = $enterprise.roleData

    $subnodes = @{}
    foreach ($n in $ed.Nodes) {
        $id = $n.Id
        if (-not $subnodes.ContainsKey($id)) { $subnodes[$id] = [System.Collections.Generic.List[long]]::new() }
        if ($n.ParentNodeId -gt 0) {
            if (-not $subnodes.ContainsKey($n.ParentNodeId)) { $subnodes[$n.ParentNodeId] = [System.Collections.Generic.List[long]]::new() }
            $subnodes[$n.ParentNodeId].Add($id) | Out-Null
        }
    }

    $rootId = $ed.RootNode.Id
    if ($Node) {
        $resolved = resolveSingleNode $Node
        if (-not $resolved) { Write-Error "Node '$Node' not found"; return }
        $rootId = $resolved.Id
    }

    $usersByNode = @{}
    foreach ($u in $ed.Users) {
        $nid = $u.ParentNodeId
        if (-not $usersByNode.ContainsKey($nid)) { $usersByNode[$nid] = [System.Collections.Generic.List[object]]::new() }
        $usersByNode[$nid].Add($u) | Out-Null
    }
    $rolesByNode = @{}
    foreach ($r in $rd.Roles) {
        $nid = $r.ParentNodeId
        if (-not $rolesByNode.ContainsKey($nid)) { $rolesByNode[$nid] = [System.Collections.Generic.List[object]]::new() }
        $rolesByNode[$nid].Add($r) | Out-Null
    }
    $teamsByNode = @{}
    foreach ($t in $ed.Teams) {
        $nid = $t.ParentNodeId
        if (-not $teamsByNode.ContainsKey($nid)) { $teamsByNode[$nid] = [System.Collections.Generic.List[object]]::new() }
        $teamsByNode[$nid].Add($t) | Out-Null
    }

    $lines = [System.Collections.Generic.List[string]]::new()
    function writeTreeNode {
        param([long]$nodeId, [string]$prefix, [bool]$isLastSibling = $true)
        $n = $null
        if (-not $ed.TryGetNode($nodeId, [ref]$n)) { return }
        $name = $n.DisplayName
        if ([string]::IsNullOrEmpty($name)) { $name = $enterprise.loader.EnterpriseName }
        if ($Detailed) { $name += " ($nodeId)" }
        if ($n.RestrictVisibility) { $name += " |Isolated|" }
        if ($prefix -eq '') {
            $lines.Add($name) | Out-Null
        } else {
            $lines.Add("$prefix+-- $name") | Out-Null
        }
        $us = $usersByNode[$nodeId]; $ro = $rolesByNode[$nodeId]; $te = $teamsByNode[$nodeId]
        $childIds = if ($subnodes[$nodeId]) { @($subnodes[$nodeId]) } else { @() }
        $sortedChildIds = if ($childIds.Count -gt 0) { @($childIds | Sort-Object { $nn = $null; if ($ed.TryGetNode($_, [ref]$nn)) { $nn.DisplayName } else { '' } }) } else { @() }
        $contentItems = [System.Collections.Generic.List[object]]::new()
        foreach ($cid in $sortedChildIds) { $contentItems.Add([PSCustomObject]@{ NodeId = $cid }) | Out-Null }
        if ($us -and $us.Count -gt 0) {
            if ($Detailed) { foreach ($u in ($us | Sort-Object { $_.Email })) { $contentItems.Add($($u.Email) + " ($($u.Id))") | Out-Null } }
            else { $contentItems.Add("$($us.Count) user(s)") | Out-Null }
        }
        if ($ro -and $ro.Count -gt 0) {
            if ($Detailed) {
                $i = 0; foreach ($r in ($ro | Sort-Object { $_.DisplayName })) {
                    if ($i -ge 50) { $contentItems.Add("$($ro.Count - 50) more role(s)"); break }
                    $contentItems.Add("$($r.DisplayName) ($($r.Id))") | Out-Null; $i++
                }
            } else { $contentItems.Add("$($ro.Count) role(s)") | Out-Null }
        }
        if ($te -and $te.Count -gt 0) {
            if ($Detailed) {
                $i = 0; foreach ($t in ($te | Sort-Object { $_.Name })) {
                    if ($i -ge 50) { $contentItems.Add("$($te.Count - 50) more team(s)"); break }
                    $contentItems.Add("$($t.Name) ($($t.Uid))") | Out-Null; $i++
                }
            } else { $contentItems.Add("$($te.Count) team(s)") | Out-Null }
        }
        $total = $contentItems.Count
        for ($i = 0; $i -lt $total; $i++) {
            $isLast = ($i -eq $total - 1)
            $branch = if ($isLastSibling -and $isLast) { ' ' } else { ' | ' }
            $connector = if ($prefix -eq '') { ' ' } else { $prefix + $branch }
            $item = $contentItems[$i]
            if ($item -is [string]) {
                $lines.Add("$connector+-- $item") | Out-Null
            } else {
                writeTreeNode -nodeId $item.NodeId -prefix $connector -isLastSibling $isLast
            }
        }
    }
    writeTreeNode -nodeId $rootId -prefix "" -isLastSibling $true
    $out = $lines -join "`n"
    if ($Output) {
        Set-Content -Path $Output -Value $out -Encoding utf8
    } else {
        $out
    }
}

function Get-KeeperEnterpriseInfoNode {
    <#
    .SYNOPSIS
    Display node information as a table.
    .DESCRIPTION
    Outputs nodes with parent path, user/team/role counts, and optionally user/team/role lists and provisioning.
    .PARAMETER Pattern
    Optional search pattern to filter nodes.
    .PARAMETER Columns
    Comma-separated columns: parent_node, user_count, users, team_count, teams, role_count, roles, provisioning. Default: parent_node, user_count, team_count, role_count.
    .PARAMETER Node
    Filter by node name or ID: only nodes that are this node or its descendants.
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .PARAMETER Offset
    Number of rows to skip (for pagination). Default 0.
    .PARAMETER Limit
    Maximum number of rows to return (0 = no limit). Use with Offset for range/pagination.
    .EXAMPLE
    Get-KeeperEnterpriseInfoNode
    Get-KeeperEnterpriseInfoNode -Columns "parent_node,user_count,users" -Pattern "Sales" -Node "Sales" -Format json -Output nodes.json -Offset 0 -Limit 50
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)][string] $Pattern,
        [Parameter()][string] $Columns,
        [Parameter()][string] $Node,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output,
        [Parameter()][int] $Offset = 0,
        [Parameter()][int] $Limit = 0
    )
    $enterprise = getEnterprise
    $ed = $enterprise.enterpriseData
    $rd = $enterprise.roleData
    $userCount = @{}; $teamCount = @{}; $roleCount = @{}
    $userList = @{}; $teamList = @{}; $roleList = @{}
    foreach ($u in $ed.Users) {
        $userCount[$u.ParentNodeId] = ((if ($null -ne $userCount[$u.ParentNodeId]) { $userCount[$u.ParentNodeId] } else { 0 }) + 1)
        if (-not $userList[$u.ParentNodeId]) { $userList[$u.ParentNodeId] = [System.Collections.Generic.List[string]]::new() }
        $userList[$u.ParentNodeId].Add($u.Email) | Out-Null
    }
    foreach ($t in $ed.Teams) {
        $nid = if ($t.ParentNodeId -eq 0) { $ed.RootNode.Id } else { $t.ParentNodeId }
        $teamCount[$nid] = ((if ($null -ne $teamCount[$nid]) { $teamCount[$nid] } else { 0 }) + 1)
        if (-not $teamList[$nid]) { $teamList[$nid] = [System.Collections.Generic.List[string]]::new() }
        $teamList[$nid].Add($t.Name) | Out-Null
    }
    foreach ($r in $rd.Roles) {
        $roleCount[$r.ParentNodeId] = ((if ($null -ne $roleCount[$r.ParentNodeId]) { $roleCount[$r.ParentNodeId] } else { 0 }) + 1)
        if (-not $roleList[$r.ParentNodeId]) { $roleList[$r.ParentNodeId] = [System.Collections.Generic.List[string]]::new() }
        $roleList[$r.ParentNodeId].Add($r.DisplayName) | Out-Null
    }
    $nodeFilterIds = $null
    if ($Node) {
        $resolved = resolveSingleNode $Node
        if (-not $resolved) { Write-Error "Node '$Node' not found"; return }
        $nodeFilterIds = Get-EnterpriseNodeAndDescendantIds $ed $resolved.Id
    }
    $colSet = @('parent_node', 'user_count', 'team_count', 'role_count')
    if ($Columns) {
        $colSet = @($Columns -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^(parent_node|user_count|users|team_count|teams|role_count|roles|provisioning)$' })
        if ($colSet.Count -eq 0) { $colSet = @('parent_node', 'user_count', 'team_count', 'role_count') }
    }
    $patternLower = if ($Pattern) { $Pattern.Trim().ToLower() } else { '' }
    $out = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($n in ($ed.Nodes | Sort-Object { $_.DisplayName })) {
        if ($nodeFilterIds -and -not $nodeFilterIds.Contains($n.Id)) { continue }
        $row = [ordered]@{ NodeId = $n.Id; Name = $n.DisplayName }
        foreach ($c in $colSet) {
            switch ($c) {
                'parent_node'   { $row['ParentNode'] = if ($n.ParentNodeId -le 0) { '' } else { Get-KeeperNodePath -NodeId $n.ParentNodeId } }
                'user_count'    { $row['UserCount'] = (if ($null -ne $userCount[$n.Id]) { $userCount[$n.Id] } else { 0 }) }
                'users'         { $row['Users'] = ($userList[$n.Id] | Sort-Object) -join ', ' }
                'team_count'    { $row['TeamCount'] = (if ($null -ne $teamCount[$n.Id]) { $teamCount[$n.Id] } else { 0 }) }
                'teams'         { $row['Teams'] = ($teamList[$n.Id] | Sort-Object) -join ', ' }
                'role_count'   { $row['RoleCount'] = (if ($null -ne $roleCount[$n.Id]) { $roleCount[$n.Id] } else { 0 }) }
                'roles'         { $row['Roles'] = ($roleList[$n.Id] | Sort-Object) -join ', ' }
                'provisioning'  { $parts = @(); if ($n.BridgeId -gt 0) { $parts += 'Bridge' }; if ($n.ScimId -gt 0) { $parts += 'SCIM' }; if ($n.SsoServiceProviderIds -and $n.SsoServiceProviderIds.Length -gt 0) { $parts += 'SSO' }; $row['Provisioning'] = ($parts -join ', ') }
            }
        }
        if ($patternLower) {
            $text = ($row.Values | ForEach-Object { $_ }) -join ' '
            if ($text -notmatch [regex]::Escape($patternLower)) { continue }
        }
        $out.Add([PSCustomObject]$row) | Out-Null
    }
    $result = @($out | Sort-Object { $_.Name })
    if ($Offset -gt 0) { $result = @($result | Select-Object -Skip $Offset) }
    if ($Limit -gt 0) { $result = @($result | Select-Object -First $Limit) }
    if ($Format -eq 'table') { $disp = $result | Format-Table -AutoSize } else { $disp = $result }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 5) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table -AutoSize | Out-String | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $disp } else { $disp }
    }
}

function Get-KeeperEnterpriseInfoUser {
    <#
    .SYNOPSIS
    Display user information as a table.
    .DESCRIPTION
    Outputs users with status, node, roles, teams, and optional columns.
    .PARAMETER Pattern
    Optional search pattern to filter users.
    .PARAMETER Columns
    Comma-separated columns: name, status, transfer_status, node, role_count, roles, team_count, teams, queued_team_count, queued_teams, alias, 2fa_enabled. Default: name, status, transfer_status, node.
    .PARAMETER Node
    Filter by node name or ID: only users in this node or its descendants.
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .PARAMETER Offset
    Number of rows to skip (for pagination). Default 0.
    .PARAMETER Limit
    Maximum number of rows to return (0 = no limit). Use with Offset for range/pagination.
    .EXAMPLE
    Get-KeeperEnterpriseInfoUser
    Get-KeeperEnterpriseInfoUser -Columns "name,status,node,roles" -Pattern "admin" -Node "Sales" -Format json -Output users.json -Offset 0 -Limit 100
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)][string] $Pattern,
        [Parameter()][string] $Columns,
        [Parameter()][string] $Node,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output,
        [Parameter()][int] $Offset = 0,
        [Parameter()][int] $Limit = 0
    )
    $enterprise = getEnterprise
    $ed = $enterprise.enterpriseData
    $rd = $enterprise.roleData
    $roleUsers = @{}
    foreach ($r in $rd.Roles) {
        foreach ($uid in @($rd.GetUsersForRole($r.Id))) {
            if (-not $roleUsers[$uid]) { $roleUsers[$uid] = [System.Collections.Generic.List[long]]::new() }
            $roleUsers[$uid].Add($r.Id) | Out-Null
        }
    }
    $teamUsers = @{}
    foreach ($t in $ed.Teams) {
        foreach ($uid in @($ed.GetUsersForTeam($t.Uid))) {
            if (-not $teamUsers[$uid]) { $teamUsers[$uid] = [System.Collections.Generic.List[string]]::new() }
            $teamUsers[$uid].Add($t.Name) | Out-Null
        }
    }
    $colSet = @('name', 'status', 'transfer_status', 'node')
    if ($Columns) {
        $colSet = @($Columns -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^(name|status|transfer_status|node|role_count|roles|team_count|teams|queued_team_count|queued_teams|alias|2fa_enabled)$' })
        if ($colSet.Count -eq 0) { $colSet = @('name', 'status', 'transfer_status', 'node') }
    }
    $nodeFilterIds = $null
    if ($Node) {
        $resolved = resolveSingleNode $Node
        $nodeFilterIds = Get-EnterpriseNodeAndDescendantIds $ed $resolved.Id
    }
    $statusText = { param($s) switch ($s) { 'Active' { 'Active' } 'Inactive' { 'Invited' } 'Locked' { 'Locked' } 'Blocked' { 'Blocked' } 'Disabled' { 'Disabled' } default { $s } } }
    $transferText = { param($s) switch ([int]$s) { 0 { 'Undefined' } 1 { 'Not required' } 2 { 'Pending transfer' } 3 { 'Partially accepted' } 4 { 'Transfer accepted' } default { $s } } }
    $patternLower = if ($Pattern) { $Pattern.Trim().ToLower() } else { '' }
    $out = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($u in ($ed.Users | Sort-Object { $_.Email })) {
        $nid = if ($u.ParentNodeId -le 0) { $ed.RootNode.Id } else { $u.ParentNodeId }
        if ($nodeFilterIds -and -not $nodeFilterIds.Contains($nid)) { continue }
        $row = [ordered]@{ UserId = $u.Id; Email = $u.Email }
        foreach ($c in $colSet) {
            switch ($c) {
                'name'             { $row['Name'] = $u.DisplayName }
                'status'           { $row['Status'] = & $statusText $u.UserStatus }
                'transfer_status'  { $row['TransferStatus'] = & $transferText $u.TransferAcceptanceStatus }
                'node'             { $row['Node'] = Get-KeeperNodePath -NodeId $u.ParentNodeId -OmitRoot }
                'role_count'       { $row['RoleCount'] = (if ($null -ne $roleUsers[$u.Id]) { $roleUsers[$u.Id].Count } else { 0 }) }
                'roles'            { $rnames = @($roleUsers[$u.Id] | ForEach-Object { $rr = $null; if ($rd.TryGetRole($_, [ref]$rr)) { $rr.DisplayName } } | Sort-Object); $row['Roles'] = ($rnames -join ', ') }
                'team_count'       { $row['TeamCount'] = (if ($null -ne $teamUsers[$u.Id]) { $teamUsers[$u.Id].Count } else { 0 }) }
                'teams'            { $row['Teams'] = (($teamUsers[$u.Id] | Sort-Object) -join ', ') }
                'queued_team_count' { $row['QueuedTeamCount'] = 0 }
                'queued_teams'      { $row['QueuedTeams'] = '' }
                'alias'            { $row['Alias'] = '' }
                '2fa_enabled'      { $row['2FAEnabled'] = $u.TwoFactorEnabled }
            }
        }
        if ($patternLower) {
            $text = ($row.Values | ForEach-Object { $_ }) -join ' '
            if ($text -notmatch [regex]::Escape($patternLower)) { continue }
        }
        $out.Add([PSCustomObject]$row) | Out-Null
    }
    $result = @($out | Sort-Object { $_.Email })
    if ($Offset -gt 0) { $result = @($result | Select-Object -Skip $Offset) }
    if ($Limit -gt 0) { $result = @($result | Select-Object -First $Limit) }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 5) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table -AutoSize | Out-String | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $result | Format-Table -AutoSize } else { $result }
    }
}

function Get-KeeperEnterpriseInfoTeam {
    <#
    .SYNOPSIS
    Display team information as a table.
    .DESCRIPTION
    Outputs teams with restricts (Read/Write/Share), node, user/role counts, and optional user/role lists.
    .PARAMETER Pattern
    Optional search pattern to filter teams.
    .PARAMETER Columns
    Comma-separated columns: restricts, node, user_count, users, queued_user_count, queued_users, role_count, roles. Default: restricts, node, user_count.
    .PARAMETER Node
    Filter by node name or ID: only teams in this node or its descendants.
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .PARAMETER Offset
    Number of rows to skip (for pagination). Default 0.
    .PARAMETER Limit
    Maximum number of rows to return (0 = no limit). Use with Offset for range/pagination.
    .EXAMPLE
    Get-KeeperEnterpriseInfoTeam
    Get-KeeperEnterpriseInfoTeam -Columns "restricts,node,user_count,users" -Pattern "Eng" -Node "Engineering" -Format json -Output teams.json -Offset 0 -Limit 50
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)][string] $Pattern,
        [Parameter()][string] $Columns,
        [Parameter()][string] $Node,
        [Parameter()][switch] $ExactNode,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output,
        [Parameter()][int] $Offset = 0,
        [Parameter()][int] $Limit = 0
    )
    $enterprise = getEnterprise
    $ed = $enterprise.enterpriseData
    $rd = $enterprise.roleData
    $userCount = @{}; $roleCount = @{}
    $userList = @{}; $roleList = @{}
    foreach ($t in $ed.Teams) {
        $uids = @($ed.GetUsersForTeam($t.Uid))
        $userCount[$t.Uid] = $uids.Count
        $userList[$t.Uid] = @($uids | ForEach-Object { $uu = $null; if ($ed.TryGetUserById($_, [ref]$uu)) { $uu.Email } } | Sort-Object)
    }
    foreach ($t in $ed.Teams) {
        foreach ($rid in @($rd.GetRolesForTeam($t.Uid))) {
            if (-not $roleList[$t.Uid]) { $roleList[$t.Uid] = [System.Collections.Generic.List[string]]::new() }
            $rr = $null; if ($rd.TryGetRole($rid, [ref]$rr)) { $roleList[$t.Uid].Add($rr.DisplayName) | Out-Null }
        }
    }
    $nodeFilterIds = $null
    if ($Node) {
        $resolved = resolveSingleNode $Node
        if ($ExactNode) {
            $nodeFilterIds = [System.Collections.Generic.HashSet[long]]::new()
            [void]$nodeFilterIds.Add($resolved.Id)
        } else {
            $nodeFilterIds = Get-EnterpriseNodeAndDescendantIds $ed $resolved.Id
        }
    }
    $colSet = @('restricts', 'node', 'user_count')
    if ($Columns) {
        $colSet = @($Columns -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^(restricts|node|user_count|users|queued_user_count|queued_users|role_count|roles)$' })
        if ($colSet.Count -eq 0) { $colSet = @('restricts', 'node', 'user_count') }
    }
    $patternLower = if ($Pattern) { $Pattern.Trim().ToLower() } else { '' }
    $out = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($t in ($ed.Teams | Sort-Object { $_.Name })) {
        $nid = if ($t.ParentNodeId -eq 0) { $ed.RootNode.Id } else { $t.ParentNodeId }
        if ($nodeFilterIds -and -not $nodeFilterIds.Contains($nid)) { continue }
        $restrictParts = @()
        if ($t.RestrictView) { $restrictParts += 'Read' }
        if ($t.RestrictEdit) { $restrictParts += 'Write' }
        if ($t.RestrictSharing) { $restrictParts += 'Share' }
        $restricts = $restrictParts -join ', '
        $row = [ordered]@{ TeamUid = $t.Uid; Name = $t.Name }
        foreach ($c in $colSet) {
            switch ($c) {
                'restricts'        { $row['Restricts'] = $restricts }
                'node'             { $row['Node'] = Get-KeeperNodePath -NodeId $t.ParentNodeId -OmitRoot }
                'user_count'       { $row['UserCount'] = (if ($null -ne $userCount[$t.Uid]) { $userCount[$t.Uid] } else { 0 }) }
                'users'            { $row['Users'] = ($userList[$t.Uid] -join ', ') }
                'queued_user_count'{ $row['QueuedUserCount'] = 0 }
                'queued_users'     { $row['QueuedUsers'] = '' }
                'role_count'       { $row['RoleCount'] = (if ($null -ne $roleList[$t.Uid]) { $roleList[$t.Uid].Count } else { 0 }) }
                'roles'            { $row['Roles'] = (($roleList[$t.Uid] | Sort-Object) -join ', ') }
            }
        }
        if ($patternLower) {
            $text = ($row.Values | ForEach-Object { $_ }) -join ' '
            if ($text -notmatch [regex]::Escape($patternLower)) { continue }
        }
        $out.Add([PSCustomObject]$row) | Out-Null
    }
    $result = @($out | Sort-Object { $_.Name })
    if ($Offset -gt 0) { $result = @($result | Select-Object -Skip $Offset) }
    if ($Limit -gt 0) { $result = @($result | Select-Object -First $Limit) }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 5) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table -AutoSize | Out-String | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $result | Format-Table -AutoSize } else { $result }
    }
}

function Get-KeeperEnterpriseInfoRole {
    <#
    .SYNOPSIS
    Display role information as a table.
    .DESCRIPTION
    Outputs roles with node, user/team counts, admin flag, and optional user/team lists.
    .PARAMETER Pattern
    Optional search pattern to filter roles.
    .PARAMETER Columns
    Comma-separated columns: visible_below, default_role, admin, node, user_count, users, team_count, teams. Default: default_role, admin, node, user_count.
    .PARAMETER Node
    Filter by node name or ID: only roles in this node or its descendants.
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .PARAMETER Offset
    Number of rows to skip (for pagination). Default 0.
    .PARAMETER Limit
    Maximum number of rows to return (0 = no limit). Use with Offset for range/pagination.
    .EXAMPLE
    Get-KeeperEnterpriseInfoRole
    Get-KeeperEnterpriseInfoRole -Columns "visible_below,node,user_count,users" -Pattern "Admin" -Node "Sales" -Format json -Output roles.json -Offset 0 -Limit 50
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)][string] $Pattern,
        [Parameter()][string] $Columns,
        [Parameter()][string] $Node,
        [Parameter()][switch] $ExactNode,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output,
        [Parameter()][int] $Offset = 0,
        [Parameter()][int] $Limit = 0
    )
    $enterprise = getEnterprise
    $ed = $enterprise.enterpriseData
    $rd = $enterprise.roleData
    $userCount = @{}; $teamCount = @{}
    $userList = @{}; $teamList = @{}
    foreach ($r in $rd.Roles) {
        $uids = @($rd.GetUsersForRole($r.Id))
        $userCount[$r.Id] = $uids.Count
        $userList[$r.Id] = @($uids | ForEach-Object { $uu = $null; if ($ed.TryGetUserById($_, [ref]$uu)) { $uu.Email } } | Sort-Object)
    }
    foreach ($r in $rd.Roles) {
        foreach ($tuid in @($rd.GetTeamsForRole($r.Id))) {
            if (-not $teamList[$r.Id]) { $teamList[$r.Id] = [System.Collections.Generic.List[string]]::new() }
            $tt = $null; if ($ed.TryGetTeam($tuid, [ref]$tt)) { $teamList[$r.Id].Add($tt.Name) | Out-Null }
        }
    }
    $nodeFilterIds = $null
    if ($Node) {
        $resolved = resolveSingleNode $Node
        if ($ExactNode) {
            $nodeFilterIds = [System.Collections.Generic.HashSet[long]]::new()
            [void]$nodeFilterIds.Add($resolved.Id)
        } else {
            $nodeFilterIds = Get-EnterpriseNodeAndDescendantIds $ed $resolved.Id
        }
    }
    $managedNodes = @($rd.GetManagedNodes())
    $adminRoleIds = [System.Collections.Generic.HashSet[long]]::new()
    foreach ($mn in $managedNodes) { [void]$adminRoleIds.Add($mn.RoleId) }
    $colSet = @('visible_below', 'default_role', 'admin', 'node', 'user_count')
    if ($Columns) {
        $colSet = @($Columns -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^(visible_below|default_role|admin|node|user_count|users|team_count|teams)$' })
        if ($colSet.Count -eq 0) { $colSet = @('default_role', 'admin', 'node', 'user_count') }
    }
    $patternLower = if ($Pattern) { $Pattern.Trim().ToLower() } else { '' }
    $out = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($r in ($rd.Roles | Sort-Object { $_.DisplayName })) {
        $nid = if ($r.ParentNodeId -le 0) { $ed.RootNode.Id } else { $r.ParentNodeId }
        if ($nodeFilterIds -and -not $nodeFilterIds.Contains($nid)) { continue }
        $row = [ordered]@{ RoleId = $r.Id; Name = $r.DisplayName }
        foreach ($c in $colSet) {
            switch ($c) {
                'visible_below'  { $row['VisibleBelow'] = $r.VisibleBelow }
                'default_role'   { $row['DefaultRole'] = $r.NewUserInherit }
                'admin'         { $row['Admin'] = $adminRoleIds.Contains($r.Id) }
                'node'          { $row['Node'] = Get-KeeperNodePath -NodeId $r.ParentNodeId -OmitRoot }
                'user_count'    { $row['UserCount'] = (if ($null -ne $userCount[$r.Id]) { $userCount[$r.Id] } else { 0 }) }
                'users'         { $row['Users'] = ($userList[$r.Id] -join ', ') }
                'team_count'    { $row['TeamCount'] = (if ($null -ne $teamList[$r.Id]) { $teamList[$r.Id].Count } else { 0 }) }
                'teams'         { $row['Teams'] = (($teamList[$r.Id] | Sort-Object) -join ', ') }
            }
        }
        if ($patternLower) {
            $text = ($row.Values | ForEach-Object { $_ }) -join ' '
            if ($text -notmatch [regex]::Escape($patternLower)) { continue }
        }
        $out.Add([PSCustomObject]$row) | Out-Null
    }
    $result = @($out | Sort-Object { $_.Name })
    if ($Offset -gt 0) { $result = @($result | Select-Object -Skip $Offset) }
    if ($Limit -gt 0) { $result = @($result | Select-Object -First $Limit) }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 5) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table -AutoSize | Out-String | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $result | Format-Table -AutoSize } else { $result }
    }
}

function Get-KeeperEnterpriseInfoManagedCompany {
    <#
    .SYNOPSIS
    Display managed company information (MSP only).
    .DESCRIPTION
    Outputs managed company information. Available when logged in as MSP.
    .PARAMETER Pattern
    Optional search pattern to filter companies.
    .PARAMETER Node
    Filter by node name or ID: only managed companies in this node or its descendants.
    .PARAMETER ExactNode
    If set, -Node filters to that node only (exclude descendants).
    .PARAMETER Format
    Output format: table (default), json, csv.
    .PARAMETER Output
    If supplied, write output to this file path.
    .PARAMETER Offset
    Number of rows to skip (for pagination). Default 0.
    .PARAMETER Limit
    Maximum number of rows to return (0 = no limit). Use with Offset for range/pagination.
    .EXAMPLE
    Get-KeeperEnterpriseInfoManagedCompany
    Get-KeeperEnterpriseInfoManagedCompany -Format json -Output mcs.json -Offset 0 -Limit 20
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Position = 0)][string] $Pattern,
        [Parameter()][string] $Node,
        [Parameter()][switch] $ExactNode,
        [Parameter()][ValidateSet('table', 'json', 'csv')][string] $Format = 'table',
        [Parameter()][string] $Output,
        [Parameter()][int] $Offset = 0,
        [Parameter()][int] $Limit = 0
    )
    $enterprise = getMspEnterprise
    $ed = $enterprise.enterpriseData
    $mcs = $enterprise.mspData.ManagedCompanies
    if (-not $mcs) { return @() }
    $nodeFilterIds = $null
    if ($Node) {
        $resolved = resolveSingleNode $Node
        if ($ExactNode) {
            $nodeFilterIds = [System.Collections.Generic.HashSet[long]]::new()
            [void]$nodeFilterIds.Add($resolved.Id)
        } else {
            $nodeFilterIds = Get-EnterpriseNodeAndDescendantIds $ed $resolved.Id
        }
    }
    $planName = { param($planId) switch ($planId) { 'enterprise' { 'Enterprise' } 'enterprise_plus' { 'Enterprise Plus' } 'business' { 'Business' } 'businessPlus' { 'Business Plus' } default { $planId } } }
    $patternLower = if ($Pattern) { $Pattern.Trim().ToLower() } else { '' }
    $out = [System.Collections.Generic.List[PSCustomObject]]::new()
    foreach ($mc in ($mcs | Sort-Object { $_.EnterpriseName })) {
        $nid = if ($mc.ParentNodeId -le 0) { $ed.RootNode.Id } else { $mc.ParentNodeId }
        if ($nodeFilterIds -and -not $nodeFilterIds.Contains($nid)) { continue }
        $storage = if ($mc.FilePlanType) { $mc.FilePlanType } else { '' }
        $addons = if ($mc.AddOns) { $mc.AddOns.Count } else { 0 }
        $allocated = $mc.NumberOfSeats; if ($allocated -eq 2147483647) { $allocated = $null }
        $nodePath = Get-KeeperNodePath -NodeId $mc.ParentNodeId -OmitRoot
        $row = [PSCustomObject]@{
            CompanyId    = $mc.EnterpriseId
            CompanyName  = $mc.EnterpriseName
            Node        = $nodePath
            Plan        = & $planName $mc.ProductId
            Storage     = $storage
            Addons      = $addons
            Allocated   = $allocated
            Active      = $mc.NumberOfUsers
        }
        if ($patternLower) {
            $text = ($row.PSObject.Properties.Value | ForEach-Object { $_ }) -join ' '
            if ($text -notmatch [regex]::Escape($patternLower)) { continue }
        }
        $out.Add($row) | Out-Null
    }
    $result = @($out | Sort-Object { $_.CompanyName })
    if ($Offset -gt 0) { $result = @($result | Select-Object -Skip $Offset) }
    if ($Limit -gt 0) { $result = @($result | Select-Object -First $Limit) }
    if ($Output) {
        if ($Format -eq 'json') { Set-Content -Path $Output -Value ($result | ConvertTo-Json -Depth 5) -Encoding utf8 }
        elseif ($Format -eq 'csv') { Set-Content -Path $Output -Value ($result | ConvertTo-Csv -NoTypeInformation) -Encoding utf8 }
        else { $result | Format-Table -AutoSize | Out-String | Set-Content -Path $Output -Encoding utf8 }
    } else {
        if ($Format -eq 'table') { $result | Format-Table -AutoSize } else { $result }
    }
}

New-Alias -Name keitree -Value Get-KeeperEnterpriseInfoTree -ErrorAction SilentlyContinue
New-Alias -Name kein -Value Get-KeeperEnterpriseInfoNode -ErrorAction SilentlyContinue
New-Alias -Name keiu -Value Get-KeeperEnterpriseInfoUser -ErrorAction SilentlyContinue
New-Alias -Name keit -Value Get-KeeperEnterpriseInfoTeam -ErrorAction SilentlyContinue
New-Alias -Name keir -Value Get-KeeperEnterpriseInfoRole -ErrorAction SilentlyContinue
New-Alias -Name keimc -Value Get-KeeperEnterpriseInfoManagedCompany -ErrorAction SilentlyContinue

# SIG # Begin signature block
# MIInvgYJKoZIhvcNAQcCoIInrzCCJ6sCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAiTzaDFjf0M6uS
# IB/zoydx214l/sv6xNep6LMdKEAkr6CCITswggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0GCSqG
# SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQg
# MjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C0Cit
# eLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce2vnS
# 1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0daE6ZM
# swEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6TSXBC
# Mo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoAFdE3
# /hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7OhD26j
# q22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM1bL5
# OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z8ujo
# 7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05huzU
# tw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNYmtwm
# KwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP/2NP
# TLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATANBgkq
# hkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95RysQDK
# r2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HLIvda
# qpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5BtfQ/g+
# lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnhOE7a
# brs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIhdXNS
# y0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV9zeK
# iwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/jwVYb
# KyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYHKi8Q
# xAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmCXBVm
# zGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l/aCn
# HwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZWeE4w
# gga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqGSIb3DQEBCwUAMGIx
# CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3
# dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH
# NDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkxCzAJBgNVBAYTAlVT
# MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1
# c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphBcr48RsAcrHXbo0Zo
# dLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6pvF4uGjwjqNjfEvUi
# 6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHeHYNnQxqXmRinvuNg
# xVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEdgkFiDNYiOTx4OtiF
# cMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjUjsZvkgFkriK9tUKJ
# m/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bRVFLeGkuAhHiGPMvS
# GmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeSLsJygoLPp66bkDX1
# ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIVNSaz7BX8VtYGqLt9
# MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL6s36czwzsucuoKs7
# Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2ZdSoQbU2rMkpLiQ6bG
# RinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFUeEY0qVjPKOWug/G6
# X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAd
# BgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0jBBgwFoAU7NfjgtJx
# XWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUF
# BwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGln
# aWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJo
# dHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy
# bDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQEL
# BQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/T8ObXAZz8OjuhUxj
# aaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQE7jU/kXjjytJgnn0
# hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9rEVKChHyfpzee5kH0
# F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y1IsA0QF8dTXqvcnT
# mpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gxdEkMx1NKU4uHQcKf
# ZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3ty9qIijanrUR3anzE
# wlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcytL5TTLL4ZaoBdqbh
# OhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEBYTptMSbhdhGQDpOX
# gpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud/v4+7RWsWCiKi9EO
# LLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiSuEtQvLsNz3Qbp7wG
# WqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZPubdcMIIG7TCCBNWg
# AwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQG
# EwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0
# IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUgQ0Ex
# MB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzELMAkGA1UEBhMCVVMx
# FzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBTSEEy
# NTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUgMTCCAiIwDQYJKoZI
# hvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMrV7pvUf+GcAoB38o3
# zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8dE2/pPvOx/Vj8Tch
# TySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7MrxVyfQO9sMx6ZAWj
# FDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZZREr4h/GI6Dxb2Uo
# yrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFOnHoRh6+86Ltc5zjP
# KHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+nigNJFmt6LAHvH3KS
# uNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeItK/DhKbPxTTuGoX7w
# JNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1zBp+xUIZkpSFA8vW
# doUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk8iyyizNDIXj//cOg
# rY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsWeupWs7NpChUk555K
# 096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAkprxMiXAJQ1XCmnCf
# gPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTkO/zy
# Me39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQGfHrK4pBW9i/USezL
# TjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwgZUGCCsG
# AQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5j
# b20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdp
# Q2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNy
# dDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln
# aUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hBMjU2MjAyNUNBMS5j
# cmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEB
# CwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWLpQq1b4URGnwWBdEZ
# D9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgjg8K8elC4+oWCqnU/
# ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3QYIUP2S3HQvHG1FDu
# +WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5bdrPbF6MRYs03h4o
# bEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUGtMTaiLR9wjxUxu2h
# ECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNEsuEB7O7/cuvTQasn
# M9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6UArb+BOVAkg2oOvol
# /DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG0LIhp6GvReQGgMgY
# xQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWVFjF7mcr4C34Mj3oc
# CVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5t2nGj/ULLi49xTcB
# ZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjsarfNZzCCB0kwggUx
# oAMCAQICEAe0P3SLJmcoVNrErUyxTt0wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UE
# BhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2Vy
# dCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENB
# MTAeFw0yNTEyMzEwMDAwMDBaFw0yOTAxMDIyMzU5NTlaMIHRMRMwEQYLKwYBBAGC
# NzwCAQMTAlVTMRkwFwYLKwYBBAGCNzwCAQITCERlbGF3YXJlMR0wGwYDVQQPDBRQ
# cml2YXRlIE9yZ2FuaXphdGlvbjEQMA4GA1UEBRMHMzQwNzk4NTELMAkGA1UEBhMC
# VVMxETAPBgNVBAgTCElsbGlub2lzMRAwDgYDVQQHEwdDaGljYWdvMR0wGwYDVQQK
# ExRLZWVwZXIgU2VjdXJpdHkgSW5jLjEdMBsGA1UEAxMUS2VlcGVyIFNlY3VyaXR5
# IEluYy4wggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCUcNMoSVmxAi0a
# vG+StFJMNFFTUIOo3HdBZ+0gqA1XpNgUx11vB1vCZrvFsD9m5oA58tdp4gZN3LmQ
# aMvCl2ANUT7MilI02Hf1RWlygBzon6iE0GpU3lgRrwrk1dhtLpGsR6dbMKUUHprc
# vKpXk90/VN+vhzY1uik1tCTxkDCPu/AYJg7m9+tR2KqvMuYMaMLhii66eWUAGsBC
# h/uZxjkGoJF6qZ0DgFd7rW7VYljbfYSNPeZNGTDgB0J/wOsKl0mn612DTseIvAKt
# 4vra/FLFukyEyStnfQ8lWYDcLLCMCjNVrzGipmT5E2iyx7Y1RZCIpNwVogp3Ixbk
# Gbq5A/41YNOLLd4cFewyB2F037RevBCRsUODZEt1qBf7Jbu3DiYo1G+zTj9E0R1s
# FzyijcfdsTm6X5ble+yCJeGkX5XgsyPnZpyz/FX9Fr0N9pMPGWwW2PKyHEnSytXm
# 0Dxdq2P4mA4CBUxq7YoV26L2PF6QEh9BQdXTPcnLysUv7SI/a0ECAwEAAaOCAgIw
# ggH+MB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB0GA1UdDgQWBBRG
# 4H6CH8pvNX632bsdnrda4MtJLDA9BgNVHSAENjA0MDIGBWeBDAEDMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC
# B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p
# bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp
# Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI
# QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT
# QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA
# A4ICAQA1Wlq0WzJa3N6DgjgBU7nagIJBab1prPARXZreX1MOv9VjnS5o0CrfQLr6
# z3bmWHw7xT8dt6bcSwRixqvPJtv4q8Rvo80O3eUMvMxQzqmi7z1zf+HG+/3G4F+2
# IYegvPc8Ui151XCV9rjA8tvFWRLRMX0ZRxY1zfT027HMw0iYL20z44+Cky//FAnL
# iRwoNDGiRkZiHbB9YOftPAYNMG3gm1z3zOW5RdfKPrqvMuijE+dfyLIAA6Immpzu
# FMH+Wgn8NnSlot9b4YKycaqqdjd7wXDjPub/oQ7VShuCSBWj+UNOTVh0vcZGackc
# H1DLVgwp2dcKlxJiQKtkHT/T6LloY6LTe6+8wkVkr8EAv1W+q/+M1a4Ao+ykFbIA
# 2LBEmA9qdgoLtenAYIiEg+48SjMPgyBbVPE3bhL1vIqjEIxYCfdmi6wx33oYX7HB
# +bJ7zitHw4GgtpfPV8y8QRZImKmeDOKyXjQPDmQM/Eglm/Ns0GzBkVXM8h6UI34b
# WZrHz9sbLSE20m5Svmxftvw5zju+I3WsmS/stNfWlOkwU0niUgwPHaz21kjXEA5A
# g+aqv26wodqZcnGOlChoWDvSJ8KKgdOFbeAYKAMp1NY7iWV315zpGH19RipCR1NH
# 0ND8iIubk3WGNf2rzEfqlOi3h2ywqVkU6AKXHdO5JV4otSKKEDGCBdkwggXVAgEB
# MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD
# VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI
# QTM4NCAyMDIxIENBMQIQB7Q/dIsmZyhU2sStTLFO3TANBglghkgBZQMEAgEFAKCB
# hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE
# AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ
# BDEiBCDDeFMO5PPq4xzy2zZihdL/1aJ3lGMeViEhdyG4EqHwvzANBgkqhkiG9w0B
# AQEFAASCAYBsQk2l1Tym7p7SmhoShST9qb2FnSJr5DTlGyclPXaP137ecVI0eHY9
# o0+vg4VF7VECIZdX+hYy1+hS4RIUPTXWLutjnHw2+ucQj/X0UxgFYEDR839Tghgl
# bBLd8p+0h7yWcGCWlfZl2W5Pn9pXgMyCoi5a0F/vylkaXJr/1tUhbKyv/S4pu7nd
# Sl3fagrW7remoIjQPiYUScdYPpCX5TaVJ1ep10ff/U4wLnTgc9c1rpXVWu202gVQ
# WKjsHnNk0Z9CQYXcCOm1g8fsiHPDQuYKgkghykiN59OAMgeFkwACw/HdH/y1LugS
# xyhlhAkutfh01r8lCQd8eMqOF+rNU+OegMR0U+ZQ2lOoilNbRu+NZVldHCz6s15k
# 9V5noRViHxXTsX+j0CHh1tpbEfhXzDcD87fngEq5PY3B191FoE/ntOrCY4LDmHVZ
# aVa5E5Jta3HL7csliHoTiVM8ebCp5KBDkhG+GqEDmXNvDdCXL/eH1PvAMLmocxmT
# W5lcCaPkUC6hggMmMIIDIgYJKoZIhvcNAQkGMYIDEzCCAw8CAQEwfTBpMQswCQYD
# VQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lD
# ZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hBMjU2IDIwMjUg
# Q0ExAhAKgO8YS43xBYLRxHanlXRoMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN
# AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjYwMjI1MjIzNzUyWjAv
# BgkqhkiG9w0BCQQxIgQgRWMf0wYSJzQlKe7TX9Wjbtbu1KwG1xBaO2TH5G0JH0Ew
# DQYJKoZIhvcNAQEBBQAEggIAxe+wG6ofF64fXP4PrJoM+WgYhihn8FFrHQfcvmy7
# pcpZ4kfKjm7A4T5d11WQJWYEW7IZAPQAKHAztL6Btaw4YTgYlba4BEawiqEp6fWZ
# prfH1s7uF3FfJ90kRcm7uFgt85+CJezcEZ7tLdicmWWwuuU4J8Q+8xYZwRAjvUT/
# 7ENCgzPpQlHPJXKe9ZPJFvOFMq5mdBR9y5UDBrPKsLSbXoLRoURbuWOWdBTT1ENY
# OBjiSZPBzj326tp07v+bEqpcvtycwlafsLORQGj+S6gYjElq8y843zfFKwqWaPEm
# kp5GQIbxlUWdNmdGlcstw/O4W2+AVG89VTtQEPZ3OAy6pMwjIjaar2m26u0HFp9X
# jP5zfZRbEfis6X8MnFqa3cNRUizgN1CmBtb7ZviXI0hm9jcL1DIYXnyqmJh0N+Ay
# apMVQjFcLSRduKorjT9zlAYeM7DoTHH1c8C4eDD5WfpYzd0cPuLdnDxRWtaSwXJe
# CpwDF4f3nfS/CH0pG6Zw3KTmT64pDxw04E/2PlYa4ocTM7zPV5RzvXSyK6ZNQFun
# OLrarFLNIW4EU37Sta6abCnqm2bOPznbGptgGufK+Uo+ev6t90w+5xxLvXcXe0v4
# omY+4KBr9Atvm1vNlBJQeC2Tb0NUz0y0LDjzBWKORk/jCK6Lk2gsCcFm/KN6NKlg
# WKI=
# SIG # End signature block