Providers/AD/Public/New-ADTestDevice.ps1

function New-ADTestDevice {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates Active Directory test device objects from CSV data
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Colour-coded console progress is intentional; results are returned as objects.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '',
        Justification = 'Job blocks take param() and bind by -ArgumentList; Using: does not apply.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = 'Passed into the job and read by the preference system, not by name.')]
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [switch]$PassThru,

        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$BatchSize = 17,

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$ThrottleLimit = 6
    )

    begin {
        $correlationId = [System.Guid]::NewGuid()
        Write-Verbose "Starting New-ADTestDevice - CorrelationId: $correlationId"

        # Get data paths
        $dataPath = Get-ADTestDataPath
        $devicesCSV = Join-Path $dataPath "ADDevices.csv"

        # Verify prerequisites
        if (-not (Test-Path $devicesCSV)) {
            throw "ADDevices.csv not found at: $devicesCSV"
        }

        # Get domain information
        $domain = Get-ADTestDomain

        # Resolved once here, before any job starts, because a job runs in a fresh runspace and
        # cannot call back into the module to ask for it.
        $seedMarker = Get-ADTestSeedMarker

        # Counters
        $script:DevicesCreated = 0
        $script:DevicesSkipped = 0
        $script:Errors = @()
        $script:ProcessingJobs = [System.Collections.Generic.List[System.Management.Automation.Job]]::new()

        Write-Verbose "Batch processing configuration: BatchSize=$BatchSize, ThrottleLimit=$ThrottleLimit"
    }

    process {
        try {
            Write-TestMessage -Message "Creating Active Directory Test Devices" -Type Header
            Write-TestMessage -Message "Loading device data from CSV..." -Type Info

            # Import device data
            $devices = Import-Csv $devicesCSV
            Write-Verbose "Loaded $($devices.Count) devices from CSV"

            $totalDevices = $devices.Count
            Write-TestMessage -Message "Processing $totalDevices devices in batches of $BatchSize..." -Type Info

            # Group devices into batches
            $deviceBatches = @()
            for ($i = 0; $i -lt $totalDevices; $i += $BatchSize) {
                $batchEnd = [Math]::Min($i + $BatchSize - 1, $totalDevices - 1)
                $deviceBatches += ,@($devices[$i..$batchEnd])
            }

            Write-Verbose "Created $($deviceBatches.Count) batches for processing"

            # Process batches with throttling
            $batchNumber = 0
            $completedBatches = 0

            foreach ($batch in $deviceBatches) {
                $batchNumber++

                # Wait for available slot if at throttle limit
                while ($script:ProcessingJobs.Count -ge $ThrottleLimit) {
                    Start-Sleep -Milliseconds 500

                    # Check for completed jobs
                    $completedJobs = $script:ProcessingJobs | Where-Object { $_.State -eq 'Completed' }
                    if ($completedJobs) {
                        foreach ($job in $completedJobs) {
                            $result = Receive-Job -Job $job
                            Remove-Job -Job $job

                            # Aggregate results
                            $script:DevicesCreated += $result.Created
                            $script:DevicesSkipped += $result.Skipped
                            $script:Errors += $result.Errors

                            $completedBatches++
                        }

                        # Remove completed jobs from tracking
                        $remainingJobs = $script:ProcessingJobs | Where-Object { $_.State -ne 'Completed' }
                        $script:ProcessingJobs.Clear()
                        foreach ($job in $remainingJobs) {
                            $script:ProcessingJobs.Add($job)
                        }

                        # Update progress
                        $percentComplete = ($completedBatches / $deviceBatches.Count) * 100
                        Write-Progress -Activity "Creating Device Batches" -Status ("Completed " +
                            "$completedBatches of $($deviceBatches.Count) " +
                            "batches") -PercentComplete $percentComplete
                    }
                }

                # Start new batch job
                $jobName = "DeviceBatch_$batchNumber"
                Write-Verbose "Starting batch $batchNumber with $($batch.Count) devices"

                $job = Start-Job -Name $jobName -ScriptBlock {
                    param($DeviceBatch, $Domain, $WhatIfPreference, $VerbosePreference, $RootName, $SeedPrefix, $SeedTag)

                    # Import required modules in job
                    Import-Module ActiveDirectory -Verbose:$false

                    $batchResults = @{
                        Created = 0
                        Skipped = 0
                        Errors = @()
                    }

                    foreach ($device in $DeviceBatch) {
                        try {
                            # Matched on the PREFIXED name, because that is what gets created.
                            # Comparing the bare name found nothing on a re-run, so all 688
                            # devices were attempted again and the directory rejected every one
                            # with "the specified account already exists" - the sAMAccountName
                            # is unprefixed and does collide. The object counts still came out
                            # right, which is exactly why a count-based idempotency check
                            # missed it and a re-run had to be watched to find it.
                            $existingDevice = Get-ADComputer -Filter ("Name -eq " +
                                "'$SeedPrefix$($device.DeviceName)'") -ErrorAction SilentlyContinue
                            if ($existingDevice) {
                                Write-Verbose "Device $($device.DeviceName) already exists, skipping"
                                $batchResults.Skipped++
                                continue
                            }

                            # Determine OU path based on device type
                            $ouPath = switch ($device.DeviceType) {
                                'Workstation' { "OU=Workstations,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Database Server' { "OU=Servers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Domain Controller' { "OU=Servers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Exchange Server' { "OU=Servers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'File Server' { "OU=Servers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Server' { "OU=Servers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Mobile Device' { "OU=Mobile,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Mobile' { "OU=Mobile,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Printer' { "OU=Printers,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Laptop' { "OU=Workstations,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                'Tablet' { "OU=Mobile,OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                                default { "OU=Devices,OU=$RootName,$($Domain.DomainDN)" }
                            }

                            # Verify OU exists
                            try {
                                Get-ADOrganizationalUnit -Identity $ouPath -ErrorAction Stop | Out-Null
                            }
                            catch {
                                $batchResults.Errors += "OU not found for $($device.DeviceName): $ouPath"
                                $batchResults.Skipped++
                                continue
                            }

                            # Prepare device parameters
                            $deviceParams = @{
                                # The prefix goes on the CN only. A computer's sAMAccountName is
                                # capped at 20 characters including its trailing $, and
                                # 'ZZ-TEST-' plus even the shortest device name overruns it -
                                # verified against a live domain, which rejects it as "not a
                                # properly formed account name". The same domain accepts a long
                                # prefixed CN beside a short account name, and finds it by
                                # Name -like 'ZZ-TEST-*'.
                                Name = '{0}{1}' -f $SeedPrefix, $device.DeviceName
                                SamAccountName = "$($device.DeviceName)$"
                                Path = $ouPath
                                Enabled = [bool]::Parse($device.Enabled)
                                Description = $device.Description
                                OtherAttributes = @{ adminDescription = $SeedTag }
                            }

                            # Create computer object
                            if (-not $WhatIfPreference) {
                                Write-Verbose "Creating device: $($device.DeviceName) in $ouPath"
                                New-ADComputer @deviceParams

                                # Set additional attributes
                                $additionalAttributes = @{}

                                if (-not [string]::IsNullOrWhiteSpace($device.OperatingSystem)) {
                                    $additionalAttributes['OperatingSystem'] = $device.OperatingSystem
                                }

                                if (-not [string]::IsNullOrWhiteSpace($device.Office)) {
                                    $additionalAttributes['Location'] = $device.Office
                                }

                                # Store additional info in Description field if needed
                                if (-not [string]::IsNullOrWhiteSpace($device.SerialNumber) -or
                                    -not [string]::IsNullOrWhiteSpace($device.AssetTag) -or
                                    -not [string]::IsNullOrWhiteSpace($device.Manufacturer) -or
                                    -not [string]::IsNullOrWhiteSpace($device.Model)) {

                                    $additionalInfo = @()
                                    if (-not [string]::IsNullOrWhiteSpace($device.SerialNumber)) {
                                        $additionalInfo += "SN:$($device.SerialNumber)"
                                    }
                                    if (-not [string]::IsNullOrWhiteSpace($device.AssetTag)) {
                                        $additionalInfo += "Asset:$($device.AssetTag)"
                                    }
                                    if (-not [string]::IsNullOrWhiteSpace($device.Manufacturer)) {
                                        $additionalInfo += "Make:$($device.Manufacturer)"
                                    }
                                    if (-not [string]::IsNullOrWhiteSpace($device.Model)) {
                                        $additionalInfo += "Model:$($device.Model)"
                                    }

                                    # Append to existing description
                                    $enhancedDescription = ("$($device.Description) | " +
                                        "$($additionalInfo -join ' | ')")
                                    $additionalAttributes['Description'] = $enhancedDescription
                                }

                                if (-not [string]::IsNullOrWhiteSpace($device.Department)) {
                                    $additionalAttributes['Department'] = $device.Department
                                }

                                # Apply additional attributes if any
                                if ($additionalAttributes.Count -gt 0) {
                                    Set-ADComputer -Identity $device.DeviceName -Replace $additionalAttributes
                                    Write-Verbose "Set additional attributes for $($device.DeviceName)"
                                }

                                # Set managed by user if specified
                                if (-not [string]::IsNullOrWhiteSpace($device.AssignedUser)) {
                                    try {
                                        # Escape single quotes in the name for AD filter
                                        $escapedAssignedUser = $device.AssignedUser -replace "'", "''"
                                        $assignedUser = Get-ADUser -Filter ("Name -eq " +
                                            "'$escapedAssignedUser'") -ErrorAction SilentlyContinue
                                        if ($assignedUser) {
                                            $setADComputerArgs1 = @{
                                                Identity  = $device.DeviceName
                                                ManagedBy = $assignedUser.DistinguishedName
                                            }
                                            Set-ADComputer @setADComputerArgs1
                                            Write-Verbose ("Set managed by for $($device.DeviceName): " +
                                                "$($device.AssignedUser)")
                                        }
                                        else {
                                            $batchResults.Errors += ("Assigned user not found for " +
                                                "$($device.DeviceName): $($device.AssignedUser)")
                                        }
                                    }
                                    catch {
                                        $batchResults.Errors += ("Managed by error for $($device.DeviceName): " +
                                            "$($_.Exception.Message)")
                                    }
                                }

                                $batchResults.Created++
                            }
                            else {
                                Write-Verbose "Would create device: $($device.DeviceName) in $ouPath"
                                $batchResults.Created++
                            }
                        }
                        catch {
                            $batchResults.Errors += ("Device creation error for $($device.DeviceName): " +
                                "$($_.Exception.Message)")
                        }
                    }

                    return $batchResults
                } -ArgumentList $batch, $domain, $WhatIfPreference, $VerbosePreference, $script:ADTestRootName, $seedMarker.Prefix, $seedMarker.Tag

                $script:ProcessingJobs.Add($job)
            }

            # Wait for all remaining jobs to complete
            Write-Verbose "Waiting for all batch jobs to complete..."
            while ($script:ProcessingJobs.Count -gt 0) {
                Start-Sleep -Milliseconds 500

                $completedJobs = $script:ProcessingJobs | Where-Object { $_.State -eq 'Completed' }
                if ($completedJobs) {
                    foreach ($job in $completedJobs) {
                        $result = Receive-Job -Job $job
                        Remove-Job -Job $job

                        # Aggregate results
                        $script:DevicesCreated += $result.Created
                        $script:DevicesSkipped += $result.Skipped
                        $script:Errors += $result.Errors

                        $completedBatches++
                    }

                    # Remove completed jobs from tracking
                    $remainingJobs = $script:ProcessingJobs | Where-Object { $_.State -ne 'Completed' }
                    $script:ProcessingJobs.Clear()
                    foreach ($job in $remainingJobs) {
                        $script:ProcessingJobs.Add($job)
                    }

                    # Update progress
                    $percentComplete = ($completedBatches / $deviceBatches.Count) * 100
                    Write-Progress -Activity "Creating Device Batches" -Status ("Completed $completedBatches of " +
                        "$($deviceBatches.Count) batches") -PercentComplete $percentComplete
                }
            }

            Write-Progress -Activity "Creating Device Batches" -Status "Complete" -PercentComplete 100 -Completed

            # Retry transient errors
            $transientErrors = @()
            $permanentErrors = @()
            $retriedDevices = @()

            # Identify transient connection errors.
            #
            # $deviceError, not $error. $error is the automatic variable holding the
            # session's error history; using it as a loop variable overwrites that history
            # for the rest of the scope, and PSScriptAnalyzer rejects it outright.
            foreach ($deviceError in $script:Errors) {
                if ($deviceError -like "*transient condition*" -or
                    $deviceError -like "*connection to the directory*was unavailable*" -or
                    $deviceError -like "*server is not operational*" -or
                    $deviceError -like "*RPC server is unavailable*") {

                    # Extract device name from error message
                    if ($deviceError -match "Device creation error for ([^:]+):") {
                        $deviceName = $matches[1]
                        $transientErrors += @{
                            DeviceName = $deviceName
                            OriginalError = $deviceError
                        }
                    }
                } else {
                    $permanentErrors += $deviceError
                }
            }

            # Retry transient errors if any found
            if ($transientErrors.Count -gt 0) {
                Write-TestMessage -Message ("Retrying $($transientErrors.Count) devices with transient " +
                    "connection errors...") -Type Info

                foreach ($transientError in $transientErrors) {
                    $deviceName = $transientError.DeviceName
                    $device = $devices | Where-Object { $_.DeviceName -eq $deviceName }

                    if ($device) {
                        Write-Verbose "Retrying device creation for: $deviceName"

                        try {
                            # Add small delay to help with transient issues
                            Start-Sleep -Milliseconds 500

                            # Prefixed here too - $deviceName came out of an error message and is
                            # the bare CSV name, not what the object is called in the directory.
                            $existingDevice = Get-ADComputer -Filter ("Name -eq " +
                                "'$($seedMarker.Prefix)$deviceName'") -ErrorAction SilentlyContinue
                            if ($existingDevice) {
                                Write-Verbose "Device $deviceName now exists, removing from error list"
                                $retriedDevices += $deviceName
                                $script:DevicesCreated++
                                continue
                            }

                            # Determine OU path based on device type
                            $ouPath = switch ($device.DeviceType) {
                                'Workstation' { "OU=Workstations,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Database Server' { "OU=Servers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Domain Controller' { "OU=Servers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Exchange Server' { "OU=Servers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'File Server' { "OU=Servers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Server' { "OU=Servers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Mobile Device' { "OU=Mobile,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Mobile' { "OU=Mobile,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Printer' { "OU=Printers,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Laptop' { "OU=Workstations,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                'Tablet' { "OU=Mobile,OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                                default { "OU=Devices,OU=$($script:ADTestRootName),$($domain.DomainDN)" }
                            }

                            # Prepare device parameters
                            $deviceParams = @{
                                # The prefix goes on the CN only. A computer's sAMAccountName is
                                # capped at 20 characters including its trailing $, and
                                # 'ZZ-TEST-' plus even the shortest device name overruns it -
                                # verified against a live domain, which rejects it as "not a
                                # properly formed account name". The same domain accepts a long
                                # prefixed CN beside a short account name, and finds it by
                                # Name -like 'ZZ-TEST-*'.
                                Name = '{0}{1}' -f $seedMarker.Prefix, $device.DeviceName
                                SamAccountName = "$($device.DeviceName)$"
                                Path = $ouPath
                                Enabled = [bool]::Parse($device.Enabled)
                                Description = $device.Description
                                OtherAttributes = @{ adminDescription = $seedMarker.Tag }
                            }

                            # Retry device creation
                            if (-not $WhatIfPreference) {
                                Write-Verbose "Retrying creation of device: $deviceName in $ouPath"
                                New-ADComputer @deviceParams -ErrorAction Stop

                                # Set additional attributes if needed
                                $additionalAttributes = @{}

                                if (-not [string]::IsNullOrWhiteSpace($device.OperatingSystem)) {
                                    $additionalAttributes['OperatingSystem'] = $device.OperatingSystem
                                }

                                if (-not [string]::IsNullOrWhiteSpace($device.Office)) {
                                    $additionalAttributes['Location'] = $device.Office
                                }

                                if (-not [string]::IsNullOrWhiteSpace($device.Department)) {
                                    $additionalAttributes['Department'] = $device.Department
                                }

                                # Apply additional attributes if any
                                if ($additionalAttributes.Count -gt 0) {
                                    $setADComputerArgs2 = @{
                                        Identity    = $device.DeviceName
                                        Replace     = $additionalAttributes
                                        ErrorAction = 'SilentlyContinue'
                                    }
                                    Set-ADComputer @setADComputerArgs2
                                }

                                # Set managed by user if specified
                                if (-not [string]::IsNullOrWhiteSpace($device.AssignedUser)) {
                                    try {
                                        $escapedAssignedUser = $device.AssignedUser -replace "'", "''"
                                        $assignedUser = Get-ADUser -Filter ("Name -eq " +
                                            "'$escapedAssignedUser'") -ErrorAction SilentlyContinue
                                        if ($assignedUser) {
                                            $setADComputerArgs3 = @{
                                                Identity    = $device.DeviceName
                                                ManagedBy   = $assignedUser.DistinguishedName
                                                ErrorAction = 'SilentlyContinue'
                                            }
                                            Set-ADComputer @setADComputerArgs3
                                        }
                                    }
                                    catch {
                                        # ManagedBy is cosmetic on test data, so a failure
                                        # here must not fail the retry that created the
                                        # computer object. Recorded rather than swallowed
                                        # silently, so -Verbose still shows what was lost.
                                        Write-Verbose ("Could not set ManagedBy on " +
                                            "$($device.DeviceName): $($_.Exception.Message)")
                                    }
                                }

                                $retriedDevices += $deviceName
                                $script:DevicesCreated++
                                Write-Verbose "Successfully retried device creation for: $deviceName"
                            }
                            else {
                                Write-Verbose "Would retry creating device: $deviceName in $ouPath"
                                $retriedDevices += $deviceName
                                $script:DevicesCreated++
                            }
                        }
                        catch {
                            Write-Verbose "Retry failed for device $deviceName : $($_.Exception.Message)"
                            # Keep original error but mark as retry failed
                            $permanentErrors += ("Retry failed for $deviceName : $($_.Exception.Message) " +
                                "(Original: $($transientError.OriginalError))")
                        }
                    }
                }

                # Update error list - remove successfully retried devices
                $script:Errors = $permanentErrors

                if ($retriedDevices.Count -gt 0) {
                    Write-TestMessage -Message ("Successfully retried $($retriedDevices.Count) devices with " +
                        "transient errors") -Type Success
                }
            }

            # Create summary
            $results = @{
                CorrelationId = $correlationId
                TotalDevices = $totalDevices
                CreatedDevices = $script:DevicesCreated
                SkippedDevices = $script:DevicesSkipped
                BatchesProcessed = $deviceBatches.Count
                BatchSize = $BatchSize
                ThrottleLimit = $ThrottleLimit
                Errors = $script:Errors
            }

            # Display summary
            Write-TestMessage -Message "Device Creation Summary (Batch Mode)" -Type Success
            Write-Host " Total Batches: $($results.BatchesProcessed)" -ForegroundColor Cyan
            Write-Host " Batch Size: $($results.BatchSize)" -ForegroundColor Cyan
            Write-Host " Devices Created: $($results.CreatedDevices)" -ForegroundColor Green
            Write-Host " Devices Skipped: $($results.SkippedDevices)" -ForegroundColor Yellow

            if ($results.Errors.Count -gt 0) {
                Write-Host " Errors: $($results.Errors.Count)" -ForegroundColor Red
                $results.Errors | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
            }

            if ($PassThru) {
                return [PSCustomObject]$results
            }

        } catch {
            # Cleanup any remaining jobs on error
            if ($script:ProcessingJobs.Count -gt 0) {
                Write-Verbose "Cleaning up $($script:ProcessingJobs.Count) background jobs due to error"
                foreach ($job in $script:ProcessingJobs) {
                    Stop-Job -Job $job -ErrorAction SilentlyContinue
                    Remove-Job -Job $job -ErrorAction SilentlyContinue
                }
                $script:ProcessingJobs.Clear()
            }
            Write-Error "Failed to create devices: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

    end {
        # Cleanup any remaining background jobs
        if ($script:ProcessingJobs.Count -gt 0) {
            Write-Verbose "Cleaning up $($script:ProcessingJobs.Count) remaining background jobs"
            foreach ($job in $script:ProcessingJobs) {
                Stop-Job -Job $job -ErrorAction SilentlyContinue
                Remove-Job -Job $job -ErrorAction SilentlyContinue
            }
            $script:ProcessingJobs.Clear()
        }

        Write-Verbose "Completed New-ADTestDevice - CorrelationId: $correlationId"
    }
}