PSADSync.psm1

Add-Type -AssemblyName 'System.DirectoryServices.AccountManagement'

function ConvertToSchemaAttributeType
{
    [OutputType([bool],[string])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$AttributeName,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$AttributeValue
    )

    switch ($AttributeName)
    {
        'accountExpires' {
            ([datetime]$AttributeValue).AddDays(2)
        }
        default {
            $AttributeValue
        }
    }    
    
}

function ConvertToAdUser
{
    [OutputType('string')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory,ParameterSetName = 'String')]
        [ValidateNotNullOrEmpty()]
        [string]$String
    )

    $baseLdapString = '(&(objectCategory=person)(objectClass=user)'
    
    $ldapString = switch -regex ($String)
    {
        '^(?<givenName>\w+)\s+(?<sn>\w+)$' { ## John Doe
            '(&(givenName={0})(sn={1}))'  -f $Matches.givenName,$Matches.sn
        }
        '^(?<sn>\w+),\s?(?<givenName>\w+)$' { ## Doe,John
            '(&(givenName={0})(sn={1}))'  -f $Matches.givenName,$Matches.sn
        }
        '^(?<samAccountName>\w+)$' { ## jdoe
            '(samAccountName={0})' -f $Matches.samAccountName
        }
        '^(?<distinguishedName>(\w+[=]{1}\w+)([,{1}]\w+[=]{1}\w+)*)$' {
            '(distinguishedName={0})' -f $Matches.distinguishedName
        }
        default {
            Write-Warning -Message "Unrecognized input: [$_]: Unable to convert [$($String)] to LDAP filter."
        }
    }
    if ($ldapString) {
        $ldapFilter = '{0}{1})' -f $baseLdapString,$ldapString

        Write-Verbose -Message "LDAP filter is [$($ldapFilter)]"
        Get-AdUser -LdapFilter $ldapFilter
    }
    
}

function SetAdUser
{
    [OutputType([void])]
    [CmdletBinding(SupportsShouldProcess,ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Identity,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$ActiveDirectoryAttributes
    )    

    $replaceHt = @{}
    foreach ($attrib in $ActiveDirectoryAttributes.GetEnumerator()) {
        $attribName = $attrib.Key
        $replaceHt.$attribName = (ConvertToSchemaAttributeType -AttributeName $attrib.Key -AttributeValue $attrib.Value)
    }

    $setParams = @{
        Identity = $Identity
        Replace = $replaceHt
    }
        
    if ($PSCmdlet.ShouldProcess("User: [$($Identity)] AD attribs: [$($replaceHt.Keys -join ',')] to [$($replaceHt.Values -join ',')]",'Set AD attributes')) {
        Write-Verbose -Message "Replacing AD attribs: [$($setParams.Replace | Out-String)]"
        Set-AdUser @setParams
    } 
}

function Get-CompanyAdUser
{
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldSyncMap
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
    }
    process
    {
        try
        {
            $userProperties = [array]($FieldSyncMap.Values)
            @($FieldMatchMap.GetEnumerator()).foreach({
                if ($_.Value -is 'scriptblock') {
                    $userProperties += ParseScriptBlockHeaders -FieldScriptBlock $_.Value | Select-Object -Unique
                } else {
                    $userProperties += $_.Value
                }
            })

            Write-Verbose -Message "Finding all AD users in domain with properties: $($userProperties -join ',')"
            @(Get-AdUser -Filter '*' -Properties $userProperties).where({
                $adUser = $_
                ## Ensure at least one ID field is populated
                @($userProperties).where({ $adUser.($_) })
            })
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}

function NewUserName
{
    [OutputType('string')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Pattern,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMap
    )

    if (-not (TestFieldMapIsValid -UserMatchMap $FieldMap)) {
        throw 'One or more values in FieldMap parameter are missing.'
    }

    switch ($Pattern)
    {
        'FirstInitialLastName' {
            '{0}{1}' -f ($CsvUser.($FieldMap.FirstName)).SubString(0, 1), $CsvUser.($FieldMap.LastName)
        }
        'FirstNameLastName' {
            '{0}{1}' -f $CsvUser.($FieldMap.FirstName), $CsvUser.($FieldMap.LastName)
        }
        'FirstNameDotLastName' {
            '{0}.{1}' -f $CsvUser.($FieldMap.FirstName), $CsvUser.($FieldMap.LastName)
        }
        default {
            throw "Unrecognized UserNamePattern: [$_]"
        }
    }
}

function GetCsvColumnHeaders
{
    [OutputType([string])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath
    )
    
    (Get-Content -Path $CsvFilePath | Select-Object -First 1).Split(',') -replace '"'
}

function Get-AvailableAdUserAttribute {
    param()

    $schema =[DirectoryServices.ActiveDirectory.ActiveDirectorySchema]::GetCurrentSchema()
    $userClass = $schema.FindClass('user')
    
    foreach ($name in $userClass.GetAllProperties().Name | Sort-Object) {
        
        $output = [ordered]@{
            ValidName = $name
            CommonName = $null
        }
        switch ($name)
        {
            'sn' {
                $output.CommonName = 'SurName'
            }
        }
        
        [pscustomobject]$output
    }
}

function TestIsValidAdAttribute {
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Name
    )

    if ($Name -in (Get-AvailableAdUserAttribute).ValidName) {
        $true
    } else {
        $false
    }
}

function TestCsvHeaderExists
{
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [object[]]$Header
    )

    $csvHeaders = GetCsvColumnHeaders -CsvFilePath $CsvFilePath

    ## Parse out the CSV headers used if the field is a scriptblock
    $commonHeaders = @($Header).foreach({
        $_ | ForEach-Object {
            if ($_ -is 'scriptblock') {
                ParseScriptBlockHeaders -FieldScriptBlock $_
            } else {
                $_
            }
        }
    })
    $commonHeaders = $commonHeaders | Select-Object -Unique

    $matchedHeaders = $csvHeaders | Where-Object { $_ -in $commonHeaders }
    if (@($matchedHeaders).Count -ne @($commonHeaders).Count) {
        $false
    } else {
        $true
    }
}

function ParseScriptBlockHeaders
{
    [OutputType('$')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [scriptblock[]]$FieldScriptBlock
    )
    
    $headers = @($FieldScriptBlock).foreach({
        $ast = [System.Management.Automation.Language.Parser]::ParseInput($_.ToString(),[ref]$null,[ref]$null)
        $ast.FindAll({$args[0] -is [System.Management.Automation.Language.StringConstantExpressionAst]},$true).Value
    })
    $headers | Select-Object -Unique
    
}

function Get-CompanyCsvUser
{
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [ValidateScript({Test-Path -Path $_ -PathType Leaf})]
        [string]$CsvFilePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldValueMap,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$Exclude
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
        Write-Verbose -Message "Enumerating all users in CSV file [$($CsvFilePath)]"
    }
    process
    {
        try
        {    
            $whereFilter = { '*' }
            if ($PSBoundParameters.ContainsKey('Exclude'))
            {
                $conditions = $Exclude.GetEnumerator() | ForEach-Object { "(`$_.'$($_.Key)' -ne '$($_.Value)')" }
                $whereFilter = [scriptblock]::Create($conditions -join ' -and ')
            }

            Import-Csv -Path $CsvFilePath | Where-Object -FilterScript $whereFilter
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}

function New-CompanyAdUser
{
    [OutputType([void])]
    [CmdletBinding(SupportsShouldProcess,ConfirmImpact = 'High')]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('FirstInitialLastName','FirstNameLastName','FirstNameDotLastName')]
        [string]$UsernamePattern,
        
        [Parameter(Mandatory,ParameterSetName = 'Password')]
        [ValidateNotNullOrEmpty()]
        [securestring]$Password,

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

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldSyncMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$UserMatchMap
    )
    
    $userName = NewUserName -CsvUser $CsvUser -Pattern $UsernamePattern -FieldMap $UserMatchMap
    $newAdUserParams = @{ 
        Name = $userName 
        PassThru = $true
        GivenName = $CsvUser.($UserMatchMap.FirstName)
        Surname = $CsvUser.($UserMatchMap.LastName)
    }

    if ($RandomPassword.IsPresent) {
        $pw = NewRandomPassword
    } else {
        $Password = $pw
    }

    $otherAttribs = @{}
    $FieldSyncMap.GetEnumerator().foreach({
        $adAttribName = $_.Value
        $adAttribValue = $CsvUser.($_.Key)
        $otherAttribs.$adAttribName = $adAttribValue
    })
    $FieldMatchMap.GetEnumerator().foreach({
        $adAttribName = $_.Value
        $adAttribValue = $CsvUser.($_.Key)
        $otherAttribs.$adAttribName = $adAttribValue
    })

    $newAdUserParams.OtherAttributes = $otherAttribs

    if ($PSCmdlet.ShouldProcess("User: [$($userName)] AD attribs: [$($newAdUserParams | Out-String)]",'New AD User')) {
        if (Get-AdUser -Filter "samAccountName -eq '$userName'") {
            throw "The user to be created [$($userName)] already exists."
        } else {
            if ($newUser = New-ADUser @newAdUserParams) {
                Set-ADAccountPassword -Identity $newUser.DistinguishedName -Reset -NewPassword $pw
            }
        }
        
    }
    
}

function TestFieldMapIsValid
{
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory,ParameterSetName = 'Sync')]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldSyncMap,

        [Parameter(Mandatory,ParameterSetName = 'Match')]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap,

        [Parameter(Mandatory,ParameterSetName = 'Value')]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldValueMap,

        [Parameter(Mandatory,ParameterSetName = 'UserMatch')]
        [ValidateNotNullOrEmpty()]
        [hashtable]$UserMatchMap,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath    
    )

    <#
        FieldSyncMap
        --------------
            Valid:
                @{ <scriptblock>; <string> }
                @{ { if ($_.'NICK_NAME') { 'NICK_NAME' } else { 'FIRST_NAME' }} = 'givenName' }

                @{ <string>; <string> }

        FieldMatchMap
        --------------
            Valid:
                @{ <scriptblock>; <string> }
                @{ <array>; <array> }
                @{ { if ($_.'csvIdField2') { $_.'csvIdField2' } else { $_.'csvIdField3'} } = 'adIdField2' }

                @{ <string>; <string> }

        FieldValueMap
        --------------
            Valid:
                @{ <string>; <scriptblock> }
                @{ 'SUPERVISOR' = { $supId = $_.'SUPERVISOR_ID'; (Get-AdUser -Filter "EmployeeId -eq '$supId'").DistinguishedName }}
    #>


    if (-not $PSBoundParameters.ContainsKey('CsvFilePath') -and -not $UserMatchMap)
    {    
        throw 'CSVFilePath is required when testing any map other than UserMatchMap.'
    }

    $result = $true
    switch ($PSCmdlet.ParameterSetName)
    {
        'Sync' {
            $mapHt = $FieldSyncMap.Clone()
            if ($FieldSyncMap.GetEnumerator().where({ $_.Value -is 'scriptblock' })) {
                Write-Warning -Message 'Scriptblocks are not allowed as a value in FieldSyncMap.'
                $result = $false
            }
        }
        'Match' {
            $mapHt = $FieldMatchMap.Clone()
            if ($FieldMatchMap.GetEnumerator().where({ $_.Value -is 'scriptblock' })) {
                Write-Warning -Message 'Scriptblocks are not allowed as a value in FieldMatchMap.'
                $result = $false
            } elseif ($FieldMatchMap.GetEnumerator().where({ @($_.Key).Count -gt 1 -and @($_.Value).Count -eq 1 })) {
                $result = $false
            }
        }
        'Value' {
            $mapHt = $FieldValueMap.Clone()
            if ($FieldValueMap.GetEnumerator().where({ $_.Value -isnot 'scriptblock' })) {
                Write-Warning -Message 'A scriptblock must be a value in FieldValueMap.'
                $result = $false
            }
            
        }
        'UserMatch' {
            $mapHt = $UserMatchMap.Clone()
            if (($UserMatchMap.Keys | Where-Object { $_ -in @('FirstName','LastName') }).Count -ne 2) {
                $result = $false
            }
        }
        default {
            throw "Unrecognized input: [$_]"
        }
    }
    if ($result -and (-not $UserMatchMap)) {
         if (-not (TestCsvHeaderExists -CsvFilePath $CsvFilePath -Header ([array]($mapHt.Keys)))) {
            Write-Warning -Message 'CSV header check failed.'
            $false
        } else {
            $true
        }
    } else {
        $result
    }
    
}

function FindUserMatch
{
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object]$CsvUser,

        [Parameter()]
        [object[]]$AdUsers = $script:adUsers
    )
    $ErrorActionPreference = 'Stop'

    <# Possibilities
        $FieldMatchMap = @{
            @( { if ($_.'NICK_NAME') { 'NICK_NAME' } else { $_.'FIRST_NAME' }}, 'LAST_NAME' )
            @( 'givenName','surName' )
        }

        @($AdUsers).where({ $_.givenName -eq 'nick' -and $_.surName -eq 'last' })

        $FieldMatchMap = @{
            @( 'FIRST_NAME', 'LAST_NAME' )
            @( 'givenName', 'surName' )
        }

        @($AdUsers).where({ $_.givenName -eq 'first' -and $_.surName -eq 'last' })

        $CsvUser = [pscustomobject]@{
            NICK_NAME = 'nick'
            FIRST_NAME = 'first'
            LAST_NAME = 'last'
        }

    #>


    $whereFilterElements = @()

    [string[]]$fieldVals = $FieldMatchmap.Values | Select-Object
    $fieldKeys = @()

    $i = 0
    $FieldMatchMap.Keys.foreach({
        ## @( { if ($_.'NICK_NAME') { 'NICK_NAME' } else { 'FIRST_NAME'} },'LAST_NAME')
    
        foreach ($k in $_) {
            if ($k -is 'scriptblock') {
                ## { if ($_.'NICK_NAME') { 'NICK_NAME' } else { 'FIRST_NAME'} }

                ## 'NICK_NAME'
                $csvProp = EvaluateCsvFieldCondition -Condition $k -CsvUser $CsvUser

            } else {
                $csvProp = $k
            }
            $fieldKeys += $csvProp

            ## 'Joel'
            if ($value = $CsvUser.$csvProp) {
                $adProp = $fieldVals[$i]

                $whereFilterElements += '$_.{0} -eq "{1}"' -f $adProp,$value
            }
            $i++

        }
    })

    if (@($FieldMatchMap.Keys).Count -gt 1) {
        $whereFilter = [scriptblock]::Create($whereFilterElements -join ' -or ')
    } else {
        $whereFilter = [scriptblock]::Create($whereFilterElements -join ' -and ')
    }
    if ($adUserMatch = @($AdUsers).where($whereFilter)) {
        if (@($adUserMatch).Count -gt 1) {
            Write-Warning -Message 'More than one AD user found to match found. Skipping user...'
        } else {
            [pscustomobject]@{
                MatchedAdUser = $adUserMatch
                CSVAttemptedMatchIds = ($fieldKeys -join ',')
                ADAttemptedMatchIds = ($fieldVals -join ',')
            }
        }
        
    } else {
        Write-Verbose -Message 'No user match found for CSV user'
    }
}

function EvaluateCsvFieldCondition
{
    [OutputType('string')]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [scriptblock]$Condition,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser
    )

    $csvFieldScript = $Condition.ToString() -replace '\$_','$CsvUser'
    & ([scriptblock]::Create($csvFieldScript))
    
}    

function FindAttributeMismatch
{
    [OutputType([hashtable])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object]$AdUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldSyncMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser
    )

    $ErrorActionPreference = 'Stop'

    Write-Verbose -Message "Starting AD attribute mismatch check..."
    $FieldSyncMap.GetEnumerator().foreach({
        if ($_.Key -is 'scriptblock') {
            $csvFieldName = EvaluateCsvFieldCondition -Condition $_.Key -CsvUser $CsvUser
        } else {
            $csvFieldName = $_.Key
        }
        Write-Verbose -Message "Checking CSV field [$($csvFieldName)] for mismatches..."
        $adAttribName = $_.Value
        Write-Verbose -Message "Checking AD attribute [$($adAttribName)] for mismatches..."
        
        ## Remove the null fields
        if (-not $AdUser.$adAttribName) {
            $AdUser | Add-Member -MemberType NoteProperty -Name $adAttribName -Force -Value ''
        }
        if ($CsvUser.$csvFieldName) {
            if (-not ($csvValue = ConvertToSchemaAttributeType -AttributeName $adAttribName -AttributeValue $CsvUser.$csvFieldName)) {
                $false
            } else {
                Write-Verbose -Message "Comparing AD attribute [$($Aduser.$adAttribName)] with converted CSV value [$($csvValue)]..."
                
                ## Compare the two property values and return the AD attribute name and value to be synced
                if ($AdUser.$adAttribName -ne $csvValue) {
                    @{
                        ActiveDirectoryAttribute = @{ $adAttribName = $AdUser.$adAttribName }
                        CSVField = @{ $csvFieldName = $CsvUser.$csvFieldName }
                        ADShouldBe = @{ $adAttribName = $CsvUser.$csvFieldName }
                    }
                    Write-Verbose -Message "AD attribute mismatch found on AD attribute: [$($adAttribName)]."
                }
            }
        }
    })
}

function NewRandomPassword
{
    [CmdletBinding()]
    [OutputType([System.Security.SecureString])]
    param
    (
        [Parameter()]
        [ValidateRange(8, 64)]
        [int]$Length = (Get-Random -Minimum 20 -Maximum 32),

        [Parameter()]
        [ValidateRange(0, 8)]
        [int]$Complexity = 3
    )
    $ErrorActionPreference = 'Stop'

    Add-Type -AssemblyName 'System.Web'

    # Generate a password with the specified length and complexity.
    Write-Verbose ('Generating password {0} characters in length and with a complexity of {1}.' -f $Length, $Complexity);
    $pw = [System.Web.Security.Membership]::GeneratePassword($Length, $Complexity)
    ConvertTo-SecureString -String $pw -AsPlainText -Force
    
}

function ConvertToAdAttribute
{
    [OutputType([hashtable])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMap
    )

    $adAttributes = @{}
    @($CsvUser.PsObject.Properties).foreach({
        $csvField = $_
        $adAttrib = $FieldMap.($csvField.Name)
        $adAttributes.$adAttrib = $csvField.Value
    })

    $adAttributes

}

function SyncCompanyUser
{
    [OutputType()]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Identity,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [pscustomobject]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable[]]$ActiveDirectoryAttributes
    )

    $ErrorActionPreference = 'Stop'
    try {
        foreach ($ht in $ActiveDirectoryAttributes) {
            SetAdUser -Identity $Identity -ActiveDirectoryAttributes $ht
        }
        
    } catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}

function WriteLog
{
    [OutputType([void])]
    [CmdletBinding()]
    param
    (
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$FilePath = '.\PSAdSync.csv',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$CsvIdentifierField,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvIdentifierValue,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$Attributes
    )
    
    $ErrorActionPreference = 'Stop'
    
    $time = Get-Date -Format 'g'
    $Attributes['CsvIdentifierValue'] = $CsvIdentifierValue
    $Attributes['CsvIdentifierField'] = $CsvIdentifierField
    $Attributes['Time'] = $time
    
    ([pscustomobject]$Attributes) | Export-Csv -Path $FilePath -Append -NoTypeInformation

}

function GetCsvIdField
{
    [OutputType([bool])]
    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object]$CsvUser,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap
    )


    $FieldMatchMap.Keys | ForEach-Object { 
        [pscustomobject]@{
            Field = $_
            Value = $CSVUser.$_
        }
    }
    
}

function Write-ProgressHelper {
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [int]$StepNumber,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$Message
    )
    Write-Progress -Activity 'Active Directory Report/Sync' -Status $Message -PercentComplete (($StepNumber / $script:totalSteps) * 100)
}

function Invoke-AdSync
{
    [OutputType()]
    [CmdletBinding(SupportsShouldProcess)]
    param
    (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFilePath,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldSyncMap,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldMatchMap,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$FieldValueMap,

        [Parameter(ParameterSetName = 'CreateNewUsers')]
        [ValidateNotNullOrEmpty()]
        [switch]$CreateNewUsers,

        [Parameter(ParameterSetName = 'CreateNewUsers')]
        [ValidateNotNullOrEmpty()]
        [hashtable]$UserMatchMap,

        [Parameter(ParameterSetName = 'CreateNewUsers')]
        [ValidateNotNullOrEmpty()]
        [string]$UsernamePattern,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [switch]$ReportOnly,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [hashtable]$Exclude
    )
    begin
    {
        $ErrorActionPreference = 'Stop'
    }
    process
    {
        try
        {
            $getCsvParams = @{
                CsvFilePath = $CsvFilePath
            }
            if ($PSBoundParameters.ContainsKey('FieldValueMap'))
            {
                $getCsvParams.FieldValueMap = $FieldValueMap    
            }

            if ($PSBoundParameters.ContainsKey('Exclude'))
            {
                if (-not (TestCsvHeaderExists -CsvFilePath $CsvFilePath -Header ([array]$Exclude.Keys))) {
                    throw 'One or more CSV headers excluded with -Exclude do not exist in the CSV file.'
                }
                $getCsvParams.Exclude = $Exclude
            }

            if (-not (TestFieldMapIsValid -FieldSyncMap $FieldSyncMap -CsvFilePath $CsvFilePath)) {
                throw 'Invalid attribute found in FieldSyncMap.'
            }
            if (-not (TestFieldMapIsValid -FieldMatchMap $FieldMatchMap -CsvFilePath $CsvFilePath)) {
                throw 'Invalid attribute found in FieldMatchMap.'
            }

            if ($PSBoundParameters.ContainsKey('FieldValueMap'))
            {
                if (-not (TestFieldMapIsValid -FieldMatchMap $FieldValueMap -CsvFilePath $CsvFilePath)) {
                    throw 'Invalid attribute found in FieldValueMap.'
                }    
            }

            $FieldSyncMap.GetEnumerator().where({$_.Value -is 'string'}).foreach({
                if (-not (TestIsValidAdAttribute -Name $_.Value)) {
                    throw 'One or more AD attributes in FieldSyncMap do not exist. Use Get-AvailableAdUserAttribute for a list of available attributes.'
                }
            })

            Write-Output 'Enumerating all Active Directory users. This may take a few minutes depending on the number of users...'
            if (-not ($script:adUsers = Get-CompanyAdUser -FieldMatchMap $FieldMatchMap -FieldSyncMap $FieldSyncMap)) {
                throw 'No AD users found'
            }
            Write-Output 'Active Directory user enumeration complete.'
            Write-Output 'Enumerating all CSV users...'
            if (-not ($csvusers = Get-CompanyCsvUser @getCsvParams)) {
                throw 'No CSV users found'
            }
            Write-Output 'CSV user enumeration complete.'

            $script:totalSteps = @($csvusers).Count
            $stepCounter = 0
            @($csvUsers).foreach({
                if ($ReportOnly.IsPresent) {
                    $prgMsg = "Attempting to find attribute mismatch for user in CSV row [$($stepCounter + 1)]"
                } else {
                    $prgMsg = "Attempting to find and sync AD any attribute mismatches for user in CSV row [$($stepCounter + 1)]"
                }
                Write-ProgressHelper -Message $prgMsg -StepNumber ($stepCounter++)
                $csvUser = $_
                if ($adUserMatch = FindUserMatch -CsvUser $csvUser -FieldMatchMap $FieldMatchMap) {
                    Write-Verbose -Message 'Match'

                    $CSVAttemptedMatchIds = $aduserMatch.CSVAttemptedMatchIds
                    $csvIdValue = $CSVAttemptedMatchIds -join ','
                    $csvIdField = $CSVAttemptedMatchIds -join ','

                    #region FieldValueMap check
                        if ($PSBoundParameters.ContainsKey('FieldValueMap'))
                        {
                            $csvUserSelectParams = @{
                                Property = '*'
                            }
                            $csvUserSelectParams.ExcludeProperty = $FieldValueMap.Keys
                            $csvUserSelectParams.Property = @('*')
                            $FieldValueMap.GetEnumerator().foreach({
                                if ($_.Value -isnot 'scriptblock') {
                                    throw 'A value in FieldValueMap is not a scriptblock'
                                }
                                $csvUserSelectParams.Property += @{ 'Name' = $_.Key; Expression = $_.Value }
                            })

                            $csvUser| Select-Object @selectParams | foreach {
                                if ($FieldValueMap -and (-not $_.($FieldValueMap.Keys))) {
                                    Write-Warning -Message "The CSV [$($FieldValueMap.Keys)] field in FieldValueMap returned nothing for CSV user [$($csvIdValue)]."
                                } else {
                                    $_
                                }
                            }
                        }
                    #endregion

                    $findParams = @{
                        AdUser = $adUserMatch.MatchedAdUser
                        CsvUser = $csvUser
                        FieldSyncMap = $FieldSyncMap
                    }
                    $attribMismatches = FindAttributeMismatch @findParams
                    if ($attribMismatches) {
                        $logAttribs = @{
                            CSVAttributeName = ([array]($attribMismatches.CSVField.Keys))[0]
                            CSVAttributeValue = ([array]($attribMismatches.CSVField.Values))[0]
                            ADAttributeName = ([array]($attribMismatches.ActiveDirectoryAttribute.Keys))[0]
                            ADAttributeValue = ([array]($attribMismatches.ActiveDirectoryAttribute.Values))[0]
                        }
                        if (-not $ReportOnly.IsPresent) {
                            $syncParams = @{
                                CsvUser = $csvUser
                                ActiveDirectoryAttributes = $attribMismatches.ADShouldBe
                                Identity = $adUserMatch.MatchedAduser.samAccountName
                            }
                            Write-Verbose -Message "Running SyncCompanyUser with params: [$($syncParams | Out-String)]"
                            SyncCompanyUser @syncParams
                        }
                    } elseif ($attribMismatches -eq $false) {
                        $logAttribs = @{
                            CSVAttributeName = 'SyncError'
                            CSVAttributeValue = 'SyncError'
                            ADAttributeName = 'SyncError'
                            ADAttributeValue = 'SyncError'
                        }

                    } else {
                        Write-Verbose -Message "No attributes found to be mismatched between CSV and AD user account for user [$csvIdValue]"
                        $logAttribs = @{
                            CSVAttributeName = 'AlreadyInSync'
                            CSVAttributeValue = 'AlreadyInSync'
                            ADAttributeName = 'AlreadyInSync'
                            ADAttributeValue = 'AlreadyInSync'
                        }
                    }
                } else {
                    ## No user match was found
                    if (-not ($csvIds = @(GetCsvIdField -CsvUser $csvUser -FieldMatchMap $FieldMatchMap).where({ $_.Field }))) {
                        Write-Warning -Message  'No CSV ID fields were found.'
                        $csvIdField = 'N/A'
                        $csvIdValue = 'N/A'

                        $logAttribs = @{
                            CSVAttributeName = 'N/A'
                            CSVAttributeValue = 'N/A'
                            ADAttributeName = 'NoMatch'
                            ADAttributeValue = 'NoMatch'
                        }
                    } elseif ($CreateNewUsers.IsPresent) {
                        $csvIdField = $csvIds.Field -join ','
                        $newUserParams = @{
                            CsvUser = $csvUser
                            UsernamePattern = $UsernamePattern
                            UserMatchMap = $UserMatchMap
                            RandomPassword = $true
                            FieldSyncMap = $FieldSyncMap
                            FieldMatchMap = $FieldMatchMap
                        }
                        New-CompanyAdUser @newUserParams

                        $logAttribs = @{
                            CSVAttributeName = 'NewUserCreated'
                            CSVAttributeValue = 'NewUserCreated'
                            ADAttributeName = 'NewUserCreated'
                            ADAttributeValue = 'NewUserCreated'
                        }
                        $csvIdValue = ($csvIds | foreach { $csvUser.($_.Field) })
                    } else {
                        $csvIdField = $csvIds.Field -join ','
                        $csvIdValue = 'N/A'

                        $logAttribs = @{
                            CSVAttributeName = 'N/A'
                            CSVAttributeValue = 'N/A'
                            ADAttributeName = 'NoMatch'
                            ADAttributeValue = 'NoMatch'
                        }
                    }
                }
                WriteLog -CsvIdentifierField $csvIdField -CsvIdentifierValue $csvIdValue -Attributes $logAttribs
            })
        }
        catch
        {
            Write-Error -Message "Function: $($MyInvocation.MyCommand.Name) Error: $($_.Exception.Message)"
        }
    }
}