modules/ISC.IQService.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

function Initialize-IQServiceData {
    param([switch]$NonInteractive)

    $script:DefaultInstallPath = 'C:\SailPoint\IQService'
    $script:LogLevelMap = [ordered]@{
        Off   = 0
        Error = 1
        Info  = 2
        Debug = 3
        Trace = 4
    }
    $script:NonInteractive = [bool]$NonInteractive
}

function Test-IQServiceNonInteractive {
    return [bool]$script:NonInteractive
}

function Get-IQServiceDefaultInstallPath {
    $variable = Get-Variable -Name 'DefaultInstallPath' -Scope Script -ErrorAction SilentlyContinue
    if (-not $variable -or [string]::IsNullOrWhiteSpace([string]$variable.Value)) {
        Initialize-IQServiceData
        $variable = Get-Variable -Name 'DefaultInstallPath' -Scope Script -ErrorAction SilentlyContinue
    }
    return [string]$variable.Value
}

function Assert-WindowsHost {
    if ($PSVersionTable.PSVersion.Major -ge 6 -and -not $IsWindows) {
        throw 'IQService Control must run on Windows.'
    }
    if ($env:OS -notlike 'Windows*') {
        throw 'IQService Control must run on Windows.'
    }
}

function Test-IsElevated {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = [Security.Principal.WindowsPrincipal]$identity
    return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Assert-Elevated {
    param([string]$Operation)

    if (-not (Test-IsElevated)) {
        throw "Administrator privileges are required for: $Operation. Re-run PowerShell as Administrator."
    }
}
function Build-IQServiceResult {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [Parameter(Mandatory)][string]$CompletedAction,
        [switch]$ServiceStoppedAfterUpdate,
        [switch]$RestartPendingForLogLevel,
        [int]$BlockedFilesRemaining = -1
    )

    $exePath = Join-Path -Path $InstallPath -ChildPath 'IQService.exe'
    $hasExe = Test-Path -LiteralPath $exePath
    $snapshot = Get-IQServiceConfigurationSnapshot -InstallPath $InstallPath -ExecutablePath:$(if ($hasExe) { $exePath } else { $null })
    $zipPath = Get-IQServiceZipPath -InstallPath $InstallPath
    $build = Get-IQServiceBuildFromPath -Path $zipPath
    $utilsDll = Join-Path -Path $InstallPath -ChildPath 'Utils.dll'
    $utilsBlocked = $false
    if (Test-Path -LiteralPath $utilsDll) {
        $utilsBlocked = Test-FileBlocked -Path $utilsDll
    }
    if ($BlockedFilesRemaining -lt 0) {
        $blockedCount = 0
        if ($utilsBlocked) { $blockedCount++ }
        $BlockedFilesRemaining = $blockedCount
    }

    $primaryService = $snapshot.Services | Select-Object -First 1
    $primaryRegistry = $snapshot.Registry | Select-Object -First 1
    $hostName = $env:COMPUTERNAME
    try {
        $fqdn = [System.Net.Dns]::GetHostEntry('localhost').HostName
        if ($fqdn) { $hostName = $fqdn }
    }
    catch { }

    $port = if ($primaryRegistry -and $primaryRegistry.Port) { [string]$primaryRegistry.Port } else { '' }
    $tlsPort = if ($primaryRegistry -and $primaryRegistry.TlsPort) { [string]$primaryRegistry.TlsPort } else { '' }
    $traceFile = if ($primaryRegistry -and $primaryRegistry.TraceFile) { [string]$primaryRegistry.TraceFile } else { (Join-Path $InstallPath 'iqtrace.log') }
    $serviceStatus = if ($primaryService) { $primaryService.Status } else { 'Not registered' }

    $situation = [System.Collections.Generic.List[string]]::new()
    switch ($CompletedAction) {
        'Download' {
            $situation.Add('IQService.zip is on this host. Next: run Install or Update (Unblock first if Utils.dll is blocked).')
        }
        'Install' {
            $situation.Add('IQService is registered on this host. Confirm TLS certificates and client authentication if required, verify the Windows Log On account, then paste host and ports into ISC.')
        }
        'Update' {
            $situation.Add('IQService was updated on this host. Confirm the Log On account in Services after uninstall/reinstall cleared registry entries.')
            if ($ServiceStoppedAfterUpdate) {
                $situation.Add('Pending: start IQService before testing the ISC source connection.')
            }
        }
        'Start' {
            $situation.Add("IQService service status: $serviceStatus. Paste host and ports into the ISC source IQService panel when the service is running.")
        }
        'Stop' {
            $situation.Add("IQService is stopped ($serviceStatus). Start it again before ISC provisioning or aggregation tests.")
        }
        'Restart' {
            $situation.Add("IQService was restarted. Current status: $serviceStatus.")
        }
        'SetLogLevel' {
            $situation.Add('Trace level was updated on this host.')
            if ($RestartPendingForLogLevel) {
                $situation.Add('Pending: restart IQService for the new trace level to take effect.')
            }
        }
        'Unblock' {
            $situation.Add('File unblock completed.')
            if ($utilsBlocked -or $BlockedFilesRemaining -gt 0) {
                $situation.Add('Pending: some binaries may still be blocked. Re-run Unblock or unblock IQService.zip before extracting.')
            }
        }
        'Uninstall' {
            $situation.Add('IQService registration was removed. Reinstall from the menu or -Action Install when you are ready.')
            $situation.Add('Pending: if the Log On account changed during uninstall, set it again in Services (services.msc).')
        }
        default {
            $situation.Add("IQService on this host — status: $serviceStatus.")
        }
    }

    if ($utilsBlocked) {
        $situation.Add('Pending: Utils.dll is blocked. Run Unblock before install or service start.')
    }
    if (-not $hasExe) {
        $situation.Add('Pending: IQService.exe is missing. Download and install before configuring ISC.')
    }
    elseif ($serviceStatus -ne 'Running' -and $CompletedAction -notin @('Stop', 'Uninstall', 'Download')) {
        $situation.Add('Pending: IQService is not running. Start the service before testing from ISC.')
    }
    $otherInstances = @($snapshot.OtherInstances)
    if ($otherInstances.Count -gt 0) {
        $situation.Add("This host runs $($otherInstances.Count) other IQService instance(s): $(($otherInstances | ForEach-Object { Get-IQServiceInstanceLabel -Instance $_ }) -join '; '). The ports and trace file below belong to $InstallPath only.")
    }
    $situation.Add('In ISC: Connections > Sources > [source requiring IQService] > IQService / Integration Service.')

    $items = [System.Collections.Generic.List[object]]::new()
    $items.Add([PSCustomObject]@{ Label = 'Host name'; Value = $hostName; Kind = 'Copy'; Mask = $false })
    if ($port) { $items.Add([PSCustomObject]@{ Label = 'Port (non-TLS)'; Value = $port; Kind = 'Copy'; Mask = $false }) }
    if ($tlsPort) { $items.Add([PSCustomObject]@{ Label = 'TLS port'; Value = $tlsPort; Kind = 'Copy'; Mask = $false }) }
    $items.Add([PSCustomObject]@{ Label = 'Install path'; Value = $InstallPath; Kind = 'Copy'; Mask = $false })
    if ($hasExe) {
        $items.Add([PSCustomObject]@{ Label = 'IQService.exe path'; Value = $exePath; Kind = 'Copy'; Mask = $false })
    }
    if ($snapshot.Version -and $snapshot.Version -ne 'Not installed') {
        $items.Add([PSCustomObject]@{ Label = 'Version'; Value = $snapshot.Version; Kind = 'Copy'; Mask = $false })
    }
    if ($build) {
        $items.Add([PSCustomObject]@{ Label = 'ZIP build'; Value = $build; Kind = 'Copy'; Mask = $false })
    }
    if ($snapshot.Instance -and $snapshot.Instance.HasRegistry) {
        $items.Add([PSCustomObject]@{ Label = 'Instance name'; Value = [string]$snapshot.Instance.InstanceName; Kind = 'Copy'; Mask = $false })
    }
    if ($primaryService) {
        $items.Add([PSCustomObject]@{ Label = 'Windows service name'; Value = $primaryService.Name; Kind = 'Copy'; Mask = $false })
        if ($primaryService.StartName) {
            $items.Add([PSCustomObject]@{ Label = 'Log On account'; Value = $primaryService.StartName; Kind = 'Copy'; Mask = $false })
        }
    }
    if ($traceFile) {
        $items.Add([PSCustomObject]@{ Label = 'Trace log file'; Value = $traceFile; Kind = 'Copy'; Mask = $false })
    }
    if (Test-Path -LiteralPath $zipPath) {
        $items.Add([PSCustomObject]@{ Label = 'IQService.zip path'; Value = $zipPath; Kind = 'Copy'; Mask = $false })
    }

    $items.Add([PSCustomObject]@{
        Label = 'Install documentation'
        Value = 'https://documentation.sailpoint.com/connectors/iqservice/help/integrating_iqservice_admin/install_register.html'
        Kind  = 'Copy'
        Mask  = $false
    })

    $connectionSettings = [ordered]@{
        'Host name' = $hostName
    }
    if ($port) { $connectionSettings['Port (non-TLS)'] = $port }
    if ($tlsPort) { $connectionSettings['TLS port'] = $tlsPort }
    $connectionSettings['Install path'] = $InstallPath

    return [ordered]@{
        status             = 'ok'
        connectionSettings = $connectionSettings
        artifacts          = @()
        secretArtifacts    = @()
        manualSteps        = @($situation)
        verification       = [ordered]@{ serviceStatus = $serviceStatus; version = $snapshot.Version }
        situation          = @($situation)
        completionItems    = $items.ToArray()
    }
}

function Show-IQServiceCompletion {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [Parameter(Mandatory)][string]$CompletedAction,
        [switch]$ServiceStoppedAfterUpdate,
        [switch]$RestartPendingForLogLevel,
        [int]$BlockedFilesRemaining = -1
    )

    $result = Build-IQServiceResult -InstallPath $InstallPath -CompletedAction $CompletedAction `
        -ServiceStoppedAfterUpdate:$ServiceStoppedAfterUpdate `
        -RestartPendingForLogLevel:$RestartPendingForLogLevel `
        -BlockedFilesRemaining $BlockedFilesRemaining

    Write-CompletionSummary -Title 'Next: configure ISC IQService connection' `
        -Situation $result.situation `
        -Items $result.completionItems
}

function Test-LooksLikeIQServiceDirectory {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path -LiteralPath $Path)) {
        return $false
    }

    foreach ($marker in @('IQService.exe', 'Utils.dll')) {
        if (Test-Path -LiteralPath (Join-Path -Path $Path -ChildPath $marker)) {
            return $true
        }
    }

    return $false
}

function Get-IQServiceWindowsServices {
    $services = @()
    try {
        $serviceKeys = Get-ChildItem -Path 'HKLM:\SYSTEM\CurrentControlSet\Services' -ErrorAction Stop |
            Where-Object { $_.PSChildName -like '*IQService*' }

        foreach ($serviceKey in $serviceKeys) {
            $name = $serviceKey.PSChildName
            $imagePath = [string](Get-ItemProperty -Path $serviceKey.PSPath -Name ImagePath -ErrorAction SilentlyContinue).ImagePath
            if ([string]::IsNullOrWhiteSpace($imagePath)) {
                continue
            }

            if ($imagePath -match '^\s*"([^"]+)"') {
                $exePath = $matches[1]
            }
            elseif ($imagePath -match '^\s*(\S+)') {
                $exePath = $matches[1]
            }
            else {
                continue
            }

            $installDir = Split-Path -Path $exePath -Parent
            $winService = Get-Service -Name $name -ErrorAction SilentlyContinue
            $startName = $null
            try {
                $cim = Get-CimInstance -ClassName Win32_Service -Filter "Name='$name'" -ErrorAction Stop
                $startName = $cim.StartName
            }
            catch {
                # Ignore.
            }

            $services += [PSCustomObject]@{
                Name        = $name
                DisplayName = if ($winService) { $winService.DisplayName } else { $name }
                Status      = if ($winService) { $winService.Status.ToString() } else { 'Unknown' }
                StartType   = if ($winService) { $winService.StartType.ToString() } else { 'Unknown' }
                ImagePath   = $exePath
                InstallPath = $installDir
                StartName   = $startName
            }
        }
    }
    catch {
        Write-Verbose "Could not enumerate IQService Windows services: $($_.Exception.Message)"
    }

    return $services
}

function Resolve-IQServiceInstallPath {
    param(
        [string]$PreferredPath,
        [string]$InstanceName
    )

    $defaultPath = Get-IQServiceDefaultInstallPath

    if (-not [string]::IsNullOrWhiteSpace($InstanceName)) {
        $instances = @(Get-IQServiceInstances)
        $matched = @($instances | Where-Object {
            [string]$_.InstanceName -eq $InstanceName -or [string]$_.ServiceName -eq $InstanceName
        })

        if ($matched.Count -eq 0) {
            $known = ($instances | ForEach-Object { Get-IQServiceInstanceLabel -Instance $_ }) -join '; '
            if (-not $known) { $known = 'none detected' }
            throw "IQService instance '$InstanceName' was not found on this host. Detected: $known"
        }
        if ($matched.Count -gt 1) {
            throw "IQService instance name '$InstanceName' matched $($matched.Count) records on this host. Use -InstallPath instead."
        }

        $instancePath = [string]$matched[0].InstallPath
        if ([string]::IsNullOrWhiteSpace($instancePath)) {
            throw "IQService instance '$InstanceName' has no discoverable install path (no Windows service and no trace file). Use -InstallPath."
        }
        if (-not [string]::IsNullOrWhiteSpace($PreferredPath) -and -not (Test-SameIQServicePath -Left $PreferredPath -Right $instancePath)) {
            throw "-InstanceName '$InstanceName' resolves to '$instancePath', which conflicts with -InstallPath '$PreferredPath'. Pass only one."
        }
        return $instancePath
    }

    if (-not [string]::IsNullOrWhiteSpace($PreferredPath)) {
        if (-not (Test-Path -LiteralPath $PreferredPath)) {
            New-Item -ItemType Directory -Path $PreferredPath -Force | Out-Null
        }
        return (Resolve-Path -LiteralPath $PreferredPath).Path
    }

    $candidates = @(Get-IQServiceInstances | Where-Object { Test-LooksLikeIQServiceDirectory -Path $_.InstallPath })
    if ($candidates.Count -eq 1) {
        return [string]$candidates[0].InstallPath
    }
    if ($candidates.Count -gt 1) {
        if (Test-IQServiceNonInteractive) {
            $known = ($candidates | ForEach-Object { Get-IQServiceInstanceLabel -Instance $_ }) -join '; '
            throw "This host has $($candidates.Count) IQService instances: $known. Pass -InstanceName or -InstallPath to choose one."
        }
        $selected = Select-IQServiceInstance -Instances $candidates -Prompt 'Multiple IQService instances found. Select the one to manage:'
        return [string]$selected.InstallPath
    }

    if (Test-LooksLikeIQServiceDirectory -Path $defaultPath) {
        return (Resolve-Path -LiteralPath $defaultPath).Path
    }

    if (-not (Test-Path -LiteralPath $defaultPath)) {
        New-Item -ItemType Directory -Path $defaultPath -Force | Out-Null
    }

    return (Resolve-Path -LiteralPath $defaultPath).Path
}

function Get-IQServiceExecutable {
    param([Parameter(Mandatory)][string]$InstallPath)

    $exePath = Join-Path -Path $InstallPath -ChildPath 'IQService.exe'
    if (-not (Test-Path -LiteralPath $exePath)) {
        throw "IQService.exe was not found in '$InstallPath'. Download and extract IQService first."
    }

    return $exePath
}

function Get-IQServiceRegistryValue {
    param(
        $Properties,
        [Parameter(Mandatory)][string]$Name
    )

    if (-not $Properties) { return $null }
    $prop = $Properties.PSObject.Properties[$Name]
    if (-not $prop) { return $null }
    return $prop.Value
}

function Get-IQServiceRegistryInstances {
    $instances = @()
    $root = 'HKLM:\SOFTWARE\SailPoint\IQService Instances'

    if (-not (Test-Path -LiteralPath $root)) {
        return $instances
    }

    foreach ($key in Get-ChildItem -Path $root -ErrorAction SilentlyContinue) {
        $props = Get-ItemProperty -Path $key.PSPath -ErrorAction SilentlyContinue
        if (-not $props) { continue }

        $instances += [PSCustomObject]@{
            InstanceName    = $key.PSChildName
            Port            = Get-IQServiceRegistryValue -Properties $props -Name 'port'
            TlsPort         = Get-IQServiceRegistryValue -Properties $props -Name 'tlsPort'
            TraceFile       = Get-IQServiceRegistryValue -Properties $props -Name 'tracefile'
            TraceLevel      = Get-IQServiceRegistryValue -Properties $props -Name 'tracelevel'
            MaxTraceFiles   = Get-IQServiceRegistryValue -Properties $props -Name 'maxTraceFiles'
            TraceFileSize   = Get-IQServiceRegistryValue -Properties $props -Name 'traceFileSize'
            ClientAuthUsers = Get-IQServiceRegistryValue -Properties $props -Name 'clientAuthUsers'
        }
    }

    return $instances
}

# -----------------------------------------------------------------------------
# Instance model
#
# A host can run several IQService instances, each in its own directory with its
# own Windows service and its own key under 'IQService Instances'. Registry keys
# do not record an install path, so instances are correlated to services by
# service name first, then by the directory their trace file lives in.
# -----------------------------------------------------------------------------

function Get-IQServiceComparablePath {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return '' }
    return $Path.Trim().TrimEnd('\', '/').ToLowerInvariant()
}

function Test-SameIQServicePath {
    param(
        [string]$Left,
        [string]$Right
    )

    $left = Get-IQServiceComparablePath -Path $Left
    $right = Get-IQServiceComparablePath -Path $Right
    if (-not $left -or -not $right) { return $false }
    return $left -eq $right
}

# Split-Path follows the running platform's separator, so parse both separators
# here to keep Windows paths comparable when this runs on a non-Windows host.
function Get-IQServiceParentPath {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return '' }
    $trimmed = $Path.Trim().TrimEnd('\', '/')
    $index = $trimmed.LastIndexOfAny([char[]]@('\', '/'))
    if ($index -lt 1) { return '' }
    return $trimmed.Substring(0, $index)
}

function Resolve-IQServiceInstanceRecords {
    param(
        [object[]]$Services,
        [object[]]$RegistryInstances
    )

    $serviceList = @(@($Services) | Where-Object { $_ })
    $registryList = @(@($RegistryInstances) | Where-Object { $_ })
    $records = [System.Collections.Generic.List[object]]::new()
    $claimed = @{}

    foreach ($service in $serviceList) {
        $index = -1
        $matchedBy = 'None'

        for ($i = 0; $i -lt $registryList.Count; $i++) {
            if ($claimed.ContainsKey($i)) { continue }
            $instanceName = [string](Get-IQServiceRegistryValue -Properties $registryList[$i] -Name 'InstanceName')
            if ($instanceName -and $instanceName -eq [string]$service.Name) {
                $index = $i
                $matchedBy = 'ServiceName'
                break
            }
        }

        if ($index -lt 0) {
            for ($i = 0; $i -lt $registryList.Count; $i++) {
                if ($claimed.ContainsKey($i)) { continue }
                $traceDir = Get-IQServiceParentPath -Path ([string](Get-IQServiceRegistryValue -Properties $registryList[$i] -Name 'TraceFile'))
                if (Test-SameIQServicePath -Left $traceDir -Right ([string]$service.InstallPath)) {
                    $index = $i
                    $matchedBy = 'TraceFilePath'
                    break
                }
            }
        }

        if ($index -lt 0 -and $serviceList.Count -eq 1 -and $registryList.Count -eq 1 -and $claimed.Count -eq 0) {
            $index = 0
            $matchedBy = 'SoleInstance'
        }

        $registry = $null
        if ($index -ge 0) {
            $claimed[$index] = $true
            $registry = $registryList[$index]
        }

        $registryName = [string](Get-IQServiceRegistryValue -Properties $registry -Name 'InstanceName')

        $records.Add([PSCustomObject]@{
            InstanceName    = if ($registryName) { $registryName } else { [string]$service.Name }
            Name            = [string]$service.Name
            ServiceName     = [string]$service.Name
            DisplayName     = [string]$service.DisplayName
            InstallPath     = [string]$service.InstallPath
            ImagePath       = [string]$service.ImagePath
            Status          = [string]$service.Status
            StartType       = [string]$service.StartType
            StartName       = $service.StartName
            Port            = Get-IQServiceRegistryValue -Properties $registry -Name 'Port'
            TlsPort         = Get-IQServiceRegistryValue -Properties $registry -Name 'TlsPort'
            TraceFile       = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceFile'
            TraceLevel      = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceLevel'
            MaxTraceFiles   = Get-IQServiceRegistryValue -Properties $registry -Name 'MaxTraceFiles'
            TraceFileSize   = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceFileSize'
            ClientAuthUsers = Get-IQServiceRegistryValue -Properties $registry -Name 'ClientAuthUsers'
            HasService      = $true
            HasRegistry     = [bool]$registry
            MatchedBy       = $matchedBy
        })
    }

    for ($i = 0; $i -lt $registryList.Count; $i++) {
        if ($claimed.ContainsKey($i)) { continue }
        $registry = $registryList[$i]
        $registryName = [string](Get-IQServiceRegistryValue -Properties $registry -Name 'InstanceName')
        $traceFile = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceFile'

        $records.Add([PSCustomObject]@{
            InstanceName    = $registryName
            Name            = $registryName
            ServiceName     = ''
            DisplayName     = $registryName
            InstallPath     = Get-IQServiceParentPath -Path ([string]$traceFile)
            ImagePath       = ''
            Status          = 'Not registered'
            StartType       = 'Unknown'
            StartName       = $null
            Port            = Get-IQServiceRegistryValue -Properties $registry -Name 'Port'
            TlsPort         = Get-IQServiceRegistryValue -Properties $registry -Name 'TlsPort'
            TraceFile       = $traceFile
            TraceLevel      = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceLevel'
            MaxTraceFiles   = Get-IQServiceRegistryValue -Properties $registry -Name 'MaxTraceFiles'
            TraceFileSize   = Get-IQServiceRegistryValue -Properties $registry -Name 'TraceFileSize'
            ClientAuthUsers = Get-IQServiceRegistryValue -Properties $registry -Name 'ClientAuthUsers'
            HasService      = $false
            HasRegistry     = $true
            MatchedBy       = 'None'
        })
    }

    return $records.ToArray()
}

function Get-IQServiceInstances {
    return @(Resolve-IQServiceInstanceRecords -Services (Get-IQServiceWindowsServices) -RegistryInstances (Get-IQServiceRegistryInstances))
}

function Get-IQServiceInstanceForPath {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [object[]]$Instances
    )

    $list = if ($PSBoundParameters.ContainsKey('Instances')) { @($Instances) } else { @(Get-IQServiceInstances) }
    return @($list | Where-Object { Test-SameIQServicePath -Left $_.InstallPath -Right $InstallPath }) | Select-Object -First 1
}

function Get-IQServiceInstanceLabel {
    param($Instance)

    if (-not $Instance) { return '(unknown instance)' }

    $name = if ($Instance.InstanceName) { [string]$Instance.InstanceName } else { '(unnamed)' }
    $path = if ($Instance.InstallPath) { [string]$Instance.InstallPath } else { 'install path unknown' }
    $detail = [System.Collections.Generic.List[string]]::new()
    if ($Instance.Port) { $detail.Add("port $($Instance.Port)") }
    if ($Instance.TlsPort) { $detail.Add("TLS $($Instance.TlsPort)") }
    if ($Instance.Status) { $detail.Add([string]$Instance.Status) }

    $label = "$name - $path"
    if ($detail.Count -gt 0) { $label += " ($($detail -join ', '))" }
    return $label
}

function Select-IQServiceInstance {
    param(
        [Parameter(Mandatory)][object[]]$Instances,
        [string]$Prompt = 'Select an IQService instance:'
    )

    $list = @($Instances)
    if ($list.Count -eq 0) { throw 'No IQService instances were detected on this host.' }
    if ($list.Count -eq 1) { return $list[0] }

    $options = @()
    $labels = @()
    for ($i = 0; $i -lt $list.Count; $i++) {
        $options += [string]($i + 1)
        $labels += (Get-IQServiceInstanceLabel -Instance $list[$i])
    }

    $choice = Read-Choice -Prompt $Prompt -Options $options -Labels $labels -Default $options[0]
    return $list[([int]$choice - 1)]
}

function Get-IQServiceZipPath {
    param([Parameter(Mandatory)][string]$InstallPath)
    return Join-Path -Path $InstallPath -ChildPath 'IQService.zip'
}

function Get-IQServiceBuildFromPath {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
    $leaf = Split-Path -Path $Path -Leaf
    if ($leaf -match 'IQService-(\d+)\.zip$') {
        return $matches[1]
    }
    return $null
}

# -----------------------------------------------------------------------------
# IQService command wrapper
# -----------------------------------------------------------------------------

function Invoke-IQServiceCommand {
    param(
        [Parameter(Mandatory)][string]$ExecutablePath,
        [Parameter(Mandatory)][string[]]$Arguments,
        [switch]$AllowNonZeroExit
    )

    $displayArgs = ($Arguments | ForEach-Object {
        if ($_ -match '\s') { """$_""" } else { $_ }
    }) -join ' '

    Write-Verbose "Running: $ExecutablePath $displayArgs"

    $output = & $ExecutablePath @Arguments 2>&1
    $text = if ($null -eq $output) { '' } else { ($output | Out-String).Trim() }
    $exitCode = $LASTEXITCODE

    if ($text) {
        foreach ($line in ($text -split "`r?`n")) {
            if (-not [string]::IsNullOrWhiteSpace($line)) {
                Write-Info $line
            }
        }
    }

    if (-not $AllowNonZeroExit -and $exitCode -ne 0) {
        throw "IQService.exe exited with code $exitCode. Arguments: $displayArgs"
    }

    return [PSCustomObject]@{
        ExitCode = $exitCode
        Output   = $text
    }
}

function Get-IQServiceVersion {
    param([Parameter(Mandatory)][string]$ExecutablePath)

    $result = Invoke-IQServiceCommand -ExecutablePath $ExecutablePath -Arguments @('-v') -AllowNonZeroExit
    if ($result.Output) {
        return $result.Output.Trim()
    }
    return 'Unknown'
}

# -----------------------------------------------------------------------------
# File unblock helpers
# -----------------------------------------------------------------------------

function Test-FileBlocked {
    param([Parameter(Mandatory)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) {
        return $false
    }

    try {
        $zoneStream = Get-Item -LiteralPath $Path -Stream 'Zone.Identifier' -ErrorAction SilentlyContinue
        return [bool]$zoneStream
    }
    catch {
        return $false
    }
}

function Unblock-IQServiceFiles {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string[]]$AdditionalPaths
    )

    $paths = [System.Collections.Generic.List[string]]::new()
    foreach ($path in @($AdditionalPaths)) {
        if (-not [string]::IsNullOrWhiteSpace($path) -and (Test-Path -LiteralPath $path)) {
            $paths.Add($path)
        }
    }

    $zipPath = Get-IQServiceZipPath -InstallPath $InstallPath
    if (Test-Path -LiteralPath $zipPath) {
        $paths.Add($zipPath)
    }

    $utilsDll = Join-Path -Path $InstallPath -ChildPath 'Utils.dll'
    if (Test-Path -LiteralPath $utilsDll) {
        $paths.Add($utilsDll)
    }

    if (Test-Path -LiteralPath $InstallPath) {
        foreach ($file in Get-ChildItem -LiteralPath $InstallPath -Recurse -File -Include '*.dll', '*.exe' -ErrorAction SilentlyContinue) {
            $paths.Add($file.FullName)
        }
    }

    $unique = @($paths | Select-Object -Unique)
    $unblocked = 0
    foreach ($path in $unique) {
        if (Test-FileBlocked -Path $path) {
            Unblock-File -LiteralPath $path
            $unblocked++
        }
    }

    return [PSCustomObject]@{
        Checked   = $unique.Count
        Unblocked = $unblocked
    }
}

# -----------------------------------------------------------------------------
# Download and extract
# -----------------------------------------------------------------------------


function Save-IQServiceZip {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$DownloadUri,
        [string]$ZipPath
    )

    return Save-IQServicePayload -InstallPath $InstallPath -DownloadUri $DownloadUri -ZipPath $ZipPath `
        -GetZipPath { param($p) Get-IQServiceZipPath -InstallPath $p } `
        -GetBuildFromPath { param($p) Get-IQServiceBuildFromPath -Path $p } `
        -UnblockFiles { param($p, $extra) Unblock-IQServiceFiles -InstallPath $p -AdditionalPaths $extra }
}

function Expand-IQServiceZip {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$ZipPath
    )

    Expand-IQServicePayload -InstallPath $InstallPath -ZipPath $ZipPath `
        -GetZipPath { param($p) Get-IQServiceZipPath -InstallPath $p } `
        -UnblockFiles { param($p) Unblock-IQServiceFiles -InstallPath $p }
}

function Get-IQServiceConfigurationSnapshot {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$ExecutablePath
    )

    $allServices = @(Get-IQServiceWindowsServices)
    $allInstances = @(Resolve-IQServiceInstanceRecords -Services $allServices -RegistryInstances (Get-IQServiceRegistryInstances))
    $services = @($allServices | Where-Object { Test-SameIQServicePath -Left $_.InstallPath -Right $InstallPath })
    $scoped = @($allInstances | Where-Object { Test-SameIQServicePath -Left $_.InstallPath -Right $InstallPath })
    $registry = @($scoped | Where-Object { $_.HasRegistry })
    $version = 'Not installed'

    if ($ExecutablePath -and (Test-Path -LiteralPath $ExecutablePath)) {
        try {
            $version = Get-IQServiceVersion -ExecutablePath $ExecutablePath
        }
        catch {
            $version = "Unavailable: $($_.Exception.Message)"
        }
    }

    return [PSCustomObject]@{
        Version        = $version
        Services       = $services
        Registry       = $registry
        Instance       = @($scoped) | Select-Object -First 1
        Instances      = $allInstances
        OtherInstances = @($allInstances | Where-Object { -not (Test-SameIQServicePath -Left $_.InstallPath -Right $InstallPath) })
    }
}

function Build-IQServiceInstallArguments {
    param(
        [int]$Port,
        [int]$TlsPort,
        [switch]$SkipSecondary
    )

    $installArgs = @('-i')
    if ($PSBoundParameters.ContainsKey('Port') -and $Port -gt 0) {
        $installArgs += @('-p', [string]$Port)
    }
    if ($PSBoundParameters.ContainsKey('TlsPort') -and $TlsPort -gt 0) {
        $installArgs += @('-o', [string]$TlsPort)
    }
    if ($SkipSecondary) {
        $installArgs += '-b'
    }
    return $installArgs
}

function Restore-IQServiceServiceAccount {
    param(
        [Parameter(Mandatory)][array]$Services
    )

    foreach ($service in $Services) {
        if ([string]::IsNullOrWhiteSpace($service.StartName)) {
            continue
        }

        if ($service.StartName -match 'LocalSystem|NT AUTHORITY\\LocalService|NT AUTHORITY\\NetworkService') {
            Write-Info "Service $($service.Name) uses built-in account $($service.StartName); no restore needed."
            continue
        }

        Write-Warning "Service $($service.Name) was configured to run as '$($service.StartName)'. IQService uninstall clears registry but not the service Log On account."
        Write-Warning 'If the account changed, open Services (services.msc), set Log On to the domain service account, and restart IQService.'
    }
}

function Install-IQServiceInstance {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [int]$Port,
        [int]$TlsPort,
        [switch]$SkipSecondary,
        [switch]$StartAfterInstall
    )

    Assert-Elevated -Operation 'install IQService'

    $exe = Get-IQServiceExecutable -InstallPath $InstallPath
    $zipPath = Get-IQServiceZipPath -InstallPath $InstallPath
    if (-not (Test-Path -LiteralPath $exe)) {
        Expand-IQServiceZip -InstallPath $InstallPath -ZipPath $zipPath
        $exe = Get-IQServiceExecutable -InstallPath $InstallPath
    }

    $installArgs = Build-IQServiceInstallArguments -Port:$Port -TlsPort:$TlsPort -SkipSecondary:$SkipSecondary
    Write-Step 'Registering IQService Windows service'
    if (-not $PSCmdlet.ShouldProcess($InstallPath, 'Install IQService')) { return }

    $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments $installArgs
    Write-Ok 'IQService registered'

    if ($StartAfterInstall) {
        Write-Step 'Starting IQService'
        $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-s')
        Write-Ok 'IQService started'
    }
}

function Update-IQServiceInstance {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$DownloadUri,
        [string]$ZipPath,
        [int]$Port,
        [int]$TlsPort,
        [switch]$SkipSecondary,
        [switch]$StartAfterInstall
    )

    Assert-Elevated -Operation 'update IQService'

    $exe = $null
    if (Test-Path -LiteralPath (Join-Path $InstallPath 'IQService.exe')) {
        $exe = Get-IQServiceExecutable -InstallPath $InstallPath
    }

    Write-Step 'Capturing current IQService configuration'
    $snapshot = Get-IQServiceConfigurationSnapshot -InstallPath $InstallPath -ExecutablePath $exe
    Write-Info "Current version: $($snapshot.Version)"

    $primaryRegistry = $snapshot.Registry | Select-Object -First 1
    if ($primaryRegistry) {
        Write-Info "Reusing settings from instance '$($primaryRegistry.InstanceName)' (matched by $($primaryRegistry.MatchedBy))."
    }
    elseif (@($snapshot.Instances).Count -gt 0) {
        Write-Warning "No registry instance could be attributed to '$InstallPath', so ports and trace settings from the other instance(s) on this host will not be reused. Pass -Port and -TlsPort if this instance needs them."
    }

    $savedPort = if ($PSBoundParameters.ContainsKey('Port') -and $Port -gt 0) { $Port } elseif ($primaryRegistry -and $primaryRegistry.Port) { [int]$primaryRegistry.Port } else { 0 }
    $savedTlsPort = if ($PSBoundParameters.ContainsKey('TlsPort') -and $TlsPort -gt 0) { $TlsPort } elseif ($primaryRegistry -and $primaryRegistry.TlsPort) { [int]$primaryRegistry.TlsPort } else { 0 }
    $savedTraceLevel = if ($primaryRegistry -and $null -ne $primaryRegistry.TraceLevel) { [int]$primaryRegistry.TraceLevel } else { $null }
    $savedTraceFile = if ($primaryRegistry) { [string]$primaryRegistry.TraceFile } else { '' }
    if ([string]::IsNullOrWhiteSpace($savedTraceFile)) {
        $savedTraceFile = Join-Path -Path $InstallPath -ChildPath 'iqtrace.log'
    }

    $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
    $backupPath = "$InstallPath.backup.$timestamp"

    if (Test-Path -LiteralPath $InstallPath) {
        Write-Step "Backing up $InstallPath to $backupPath"
        if (-not $PSCmdlet.ShouldProcess($InstallPath, "Back up to $backupPath")) { return }
        Copy-Item -LiteralPath $InstallPath -Destination $backupPath -Recurse -Force
        Write-Ok "Backup created at $backupPath"
    }

    if ($exe) {
        Write-Step 'Stopping IQService'
        $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-k') -AllowNonZeroExit
        Write-Step 'Uninstalling IQService registration'
        $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-u') -AllowNonZeroExit
    }

    if (-not [string]::IsNullOrWhiteSpace($DownloadUri) -or -not [string]::IsNullOrWhiteSpace($ZipPath)) {
        Save-IQServiceZip -InstallPath $InstallPath -DownloadUri $DownloadUri -ZipPath $ZipPath | Out-Null
    }

    Expand-IQServiceZip -InstallPath $InstallPath
    $exe = Get-IQServiceExecutable -InstallPath $InstallPath

    $installArgs = Build-IQServiceInstallArguments
    if ($savedPort -gt 0) { $installArgs += @('-p', [string]$savedPort) }
    if ($savedTlsPort -gt 0) { $installArgs += @('-o', [string]$savedTlsPort) }
    if ($SkipSecondary) { $installArgs += '-b' }

    Write-Step 'Re-registering IQService'
    if (-not $PSCmdlet.ShouldProcess($InstallPath, 'Reinstall IQService')) { return }
    $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments $installArgs
    Write-Ok 'IQService re-registered'

    if ($null -ne $savedTraceLevel) {
        $logName = ($script:LogLevelMap.GetEnumerator() | Where-Object { $_.Value -eq $savedTraceLevel } | Select-Object -First 1).Name
        if ($logName) {
            Write-Step "Restoring trace level $logName ($savedTraceLevel)"
            $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-l', [string]$savedTraceLevel, '-f', $savedTraceFile)
        }
    }

    Restore-IQServiceServiceAccount -Services $snapshot.Services

    if ($StartAfterInstall) {
        Write-Step 'Starting IQService'
        $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-s')
        Write-Ok 'IQService started'
    }
    else {
        Write-Info 'Service was stopped for the update. Start it with -Action Start or IQService.exe -s.'
    }

    Write-Ok "Update complete. Backup retained at $backupPath"
}

function Invoke-IQServiceServiceAction {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)][ValidateSet('Start', 'Stop', 'Restart', 'Uninstall')]
        [string]$ServiceAction,
        [Parameter(Mandatory)][string]$InstallPath
    )

    Assert-Elevated -Operation "$($ServiceAction.ToLowerInvariant()) IQService"
    $exe = Get-IQServiceExecutable -InstallPath $InstallPath

    $argMap = @{
        Start     = @('-s')
        Stop      = @('-k')
        Restart   = @('-t')
        Uninstall = @('-u')
    }

    if ($ServiceAction -eq 'Uninstall') {
        if (-not (Test-IQServiceNonInteractive) -and -not (Read-YesNo -Prompt 'Uninstall IQService registration and clear registry entries?' -Default $false)) {
            Write-Host 'Cancelled.' -ForegroundColor Yellow
            return
        }
    }

    if (-not $PSCmdlet.ShouldProcess($InstallPath, "$ServiceAction IQService")) { return }

    Write-Step "$ServiceAction IQService"
    $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments $argMap[$ServiceAction] -AllowNonZeroExit:($ServiceAction -in @('Stop', 'Uninstall'))
    Write-Ok "$ServiceAction completed"
}

function Set-IQServiceTraceLevel {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [Parameter(Mandatory)][ValidateSet('Off', 'Error', 'Info', 'Debug', 'Trace')]
        [string]$Level,
        [string]$TraceFile,
        [switch]$RestartIfRunning
    )

    Assert-Elevated -Operation 'set IQService log level'
    $exe = Get-IQServiceExecutable -InstallPath $InstallPath
    $numeric = $script:LogLevelMap[$Level]

    if ([string]::IsNullOrWhiteSpace($TraceFile)) {
        $TraceFile = Resolve-IQServiceTraceFile -InstallPath $InstallPath
    }

    $services = @(Get-IQServiceWindowsServices | Where-Object { Test-SameIQServicePath -Left $_.InstallPath -Right $InstallPath })
    $wasRunning = @($services | Where-Object { $_.Status -eq 'Running' }).Count -gt 0
    $doRestart = $RestartIfRunning -or ($wasRunning -and -not (Test-IQServiceNonInteractive) -and (Read-YesNo -Prompt 'Restart IQService to apply the new log level?' -Default $true))

    if (-not $PSCmdlet.ShouldProcess($InstallPath, "Set log level to $Level ($numeric)")) {
        return [PSCustomObject]@{ RestartPending = $false }
    }

    Write-Step "Setting trace level to $Level ($numeric)"
    $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-l', [string]$numeric, '-f', $TraceFile)
    Write-Ok "Trace file: $TraceFile"

    if ($doRestart) {
        Write-Step 'Restarting IQService'
        $null = Invoke-IQServiceCommand -ExecutablePath $exe -Arguments @('-t') -AllowNonZeroExit
        Write-Ok 'IQService restarted'
        return [PSCustomObject]@{ RestartPending = $false }
    }
    if ($wasRunning) {
        Write-Info 'Service is running. Restart IQService for the new log level to take effect.'
        return [PSCustomObject]@{ RestartPending = $true }
    }
    return [PSCustomObject]@{ RestartPending = $false }
}

function Show-IQServiceStatus {
    param([Parameter(Mandatory)][string]$InstallPath)

    Show-IQServiceCompletion -InstallPath $InstallPath -CompletedAction 'Status'
}

# -----------------------------------------------------------------------------
# Trace log streaming
# -----------------------------------------------------------------------------

function Resolve-IQServiceTraceFile {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$TraceFile,
        $Instance
    )

    if (-not [string]::IsNullOrWhiteSpace($TraceFile)) {
        return $TraceFile
    }

    # Only this install path's own instance; another instance's trace file is not a fallback.
    if (-not $PSBoundParameters.ContainsKey('Instance')) {
        $Instance = Get-IQServiceInstanceForPath -InstallPath $InstallPath
    }
    if ($Instance -and -not [string]::IsNullOrWhiteSpace([string]$Instance.TraceFile)) {
        return [string]$Instance.TraceFile
    }

    foreach ($name in @('iqtrace.log', 'IQTrace.log')) {
        $candidate = Join-Path -Path $InstallPath -ChildPath $name
        if (Test-Path -LiteralPath $candidate) {
            return $candidate
        }
    }

    return Join-Path -Path $InstallPath -ChildPath 'iqtrace.log'
}

function Get-IQServiceLogLevelColor {
    param([string]$Level)

    switch -Regex ($Level) {
        '^ERR'          { return [System.ConsoleColor]::Red }
        '^WARN'         { return [System.ConsoleColor]::Yellow }
        '^INFO'         { return [System.ConsoleColor]::Cyan }
        '^DEBUG|^TRACE' { return [System.ConsoleColor]::DarkGray }
        default         { return [System.ConsoleColor]::Gray }
    }
}

function ConvertTo-CompleteLogLines {
    param(
        [string]$Chunk,
        [Parameter(Mandatory)][ref]$Carry
    )

    $text = $Carry.Value + $Chunk
    if ([string]::IsNullOrEmpty($text)) {
        return @()
    }

    $parts = [regex]::Split($text, '\r?\n')
    $endsWithNewline = $text.EndsWith("`n") -or $text.EndsWith("`r")
    if ($endsWithNewline) {
        $Carry.Value = ''
        if ($parts.Count -gt 0 -and $parts[-1] -eq '') {
            if ($parts.Count -eq 1) { return @() }
            return @($parts[0..($parts.Count - 2)])
        }
        return @($parts)
    }

    $Carry.Value = $parts[-1]
    if ($parts.Count -le 1) {
        return @()
    }
    return @($parts[0..($parts.Count - 2)])
}

function Write-IQServiceLogLine {
    param(
        [string]$Line,
        [Parameter(Mandatory)][ref]$LastLevel
    )

    if ($null -eq $Line) { return }

    $header = [regex]::Match(
        $Line,
        '^(?<ts>\d{1,2}/\d{1,2}/\d{4}\s+\d{1,2}:\d{2}:\d{2})\s*:\s*(?<comp>\S+)\s*\[\s*(?<thread>[^\]]+)\]\s*(?<level>ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE)\s*:\s*(?<msg>.*)$'
    )

    if ($header.Success) {
        $level = $header.Groups['level'].Value.ToUpperInvariant()
        if ($level -eq 'WARNING') { $level = 'WARN' }
        $LastLevel.Value = $level
        $levelColor = Get-IQServiceLogLevelColor -Level $level
        $messageColor = if ($level -match '^(ERR|WARN)') { $levelColor } else { [System.ConsoleColor]::Gray }

        Write-Host $header.Groups['ts'].Value -ForegroundColor DarkGray -NoNewline
        Write-Host ' : ' -ForegroundColor DarkGray -NoNewline
        Write-Host $header.Groups['comp'].Value -ForegroundColor White -NoNewline
        Write-Host ' [' -ForegroundColor DarkGray -NoNewline
        Write-Host $header.Groups['thread'].Value.Trim() -ForegroundColor DarkCyan -NoNewline
        Write-Host '] ' -ForegroundColor DarkGray -NoNewline
        Write-Host $level -ForegroundColor $levelColor -NoNewline
        Write-Host ' : ' -ForegroundColor DarkGray -NoNewline
        Write-Host $header.Groups['msg'].Value -ForegroundColor $messageColor
        return
    }

    $color = Get-IQServiceLogLevelColor -Level $LastLevel.Value
    if ($Line -match '\bERROR\b') {
        $color = [System.ConsoleColor]::Red
        $LastLevel.Value = 'ERROR'
    }
    elseif ($Line -match '\bWARN(?:ING)?\b') {
        $color = [System.ConsoleColor]::Yellow
        $LastLevel.Value = 'WARN'
    }
    Write-Host $Line -ForegroundColor $color
}

function Test-LogStreamCancel {
    try {
        if (-not [Console]::KeyAvailable) { return $false }
        $key = [Console]::ReadKey($true)
        if ($key.Key -eq [ConsoleKey]::C -and $key.Modifiers -eq [ConsoleModifiers]::Control) { return $true }
        if ($key.Key -eq [ConsoleKey]::Q -or $key.Key -eq [ConsoleKey]::Escape) { return $true }
    }
    catch {
        # Host cannot poll keys (ISE, redirected stdin); Ctrl+C still stops the loop via the runtime.
    }
    return $false
}

function Get-IQServiceLogFileIdentity {
    param([string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) { return $null }
    $item = Get-Item -LiteralPath $Path
    return [PSCustomObject]@{
        Length           = $item.Length
        CreationTimeUtc  = $item.CreationTimeUtc
        LastWriteTimeUtc = $item.LastWriteTimeUtc
    }
}

function Read-IQServiceLogDelta {
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][ref]$Position,
        [Parameter(Mandatory)][ref]$Carry,
        [Parameter(Mandatory)]$Encoding
    )

    $stream = $null
    try {
        $stream = [System.IO.File]::Open(
            $Path,
            [System.IO.FileMode]::Open,
            [System.IO.FileAccess]::Read,
            [System.IO.FileShare]::ReadWrite
        )

        if ($stream.Length -lt $Position.Value) {
            $Position.Value = [int64]0
            $Carry.Value = ''
        }

        if ($stream.Length -le $Position.Value) {
            return @()
        }

        $null = $stream.Seek($Position.Value, [System.IO.SeekOrigin]::Begin)
        $toRead = [int]($stream.Length - $Position.Value)
        $buffer = New-Object byte[] $toRead
        $read = $stream.Read($buffer, 0, $toRead)
        $Position.Value += $read
        $chunk = $Encoding.GetString($buffer, 0, $read)
        return @(ConvertTo-CompleteLogLines -Chunk $chunk -Carry $Carry)
    }
    finally {
        if ($stream) { $stream.Dispose() }
    }
}

function Show-IQServiceLogStream {
    param(
        [Parameter(Mandatory)][string]$InstallPath,
        [string]$TraceFile,
        [int]$TailLines = 50
    )

    $instance = Get-IQServiceInstanceForPath -InstallPath $InstallPath
    $path = Resolve-IQServiceTraceFile -InstallPath $InstallPath -TraceFile $TraceFile -Instance $instance
    $encoding = [System.Text.Encoding]::Default

    Write-Step "Streaming $path"
    if ($instance) {
        Write-Info "Instance: $(Get-IQServiceInstanceLabel -Instance $instance)"
    }
    Write-Info 'Ctrl+C, Q, or Esc to stop.'

    $traceLevel = if ($instance -and $null -ne $instance.TraceLevel) { [int]$instance.TraceLevel } else { $null }
    if ($traceLevel -eq 0) {
        Write-Host ' Trace level is Off; the file may stay empty until you set Error, Info, Debug, or Trace.' -ForegroundColor Yellow
    }

    $treatCtrlC = $null
    try {
        $treatCtrlC = [Console]::TreatControlCAsInput
        [Console]::TreatControlCAsInput = $true
    }
    catch {
        $treatCtrlC = $null
    }

    $lastLevel = 'INFO'
    $position = [int64]0
    $carry = ''
    $identity = $null

    try {
        $waitingNotice = $false
        while (-not (Test-Path -LiteralPath $path)) {
            if (-not $waitingNotice) {
                Write-Host ' Waiting for the log file to appear...' -ForegroundColor Yellow
                $waitingNotice = $true
            }
            if (Test-LogStreamCancel) { return }
            Start-Sleep -Milliseconds 500
        }
        if ($waitingNotice) {
            Write-Ok "Log file appeared: $path"
        }

        $identity = Get-IQServiceLogFileIdentity -Path $path
        $fileLength = $identity.Length
        $bootstrapCarry = ''
        $bootstrapPosition = [int64]0

        if ($TailLines -le 0) {
            $position = $fileLength
            $carry = ''
            Write-Info 'Following new lines only.'
        }
        else {
            $window = [Math]::Max([int64]524288, [int64]$TailLines * 512)
            if ($fileLength -gt $window) {
                $bootstrapPosition = $fileLength - $window
            }
            $existing = @(Read-IQServiceLogDelta -Path $path -Position ([ref]$bootstrapPosition) -Carry ([ref]$bootstrapCarry) -Encoding $encoding)
            if ($fileLength -gt $window -and $existing.Count -gt 0) {
                $existing = @($existing | Select-Object -Skip 1)
            }
            $position = $bootstrapPosition
            $carry = $bootstrapCarry

            if ($existing.Count -gt 0) {
                $start = [Math]::Max(0, $existing.Count - $TailLines)
                for ($i = $start; $i -lt $existing.Count; $i++) {
                    Write-IQServiceLogLine -Line $existing[$i] -LastLevel ([ref]$lastLevel)
                }
            }
            else {
                Write-Info 'Log file is empty; waiting for new lines.'
            }
        }

        while ($true) {
            if (Test-LogStreamCancel) { break }

            if (-not (Test-Path -LiteralPath $path)) {
                $position = [int64]0
                $carry = ''
                $identity = $null
                Start-Sleep -Milliseconds 400
                continue
            }

            $current = Get-IQServiceLogFileIdentity -Path $path
            if (-not $current) {
                $position = [int64]0
                $carry = ''
                $identity = $null
                Start-Sleep -Milliseconds 400
                continue
            }
            if ($identity -and $current.CreationTimeUtc -ne $identity.CreationTimeUtc) {
                $position = [int64]0
                $carry = ''
            }
            $identity = $current

            $lines = @(Read-IQServiceLogDelta -Path $path -Position ([ref]$position) -Carry ([ref]$carry) -Encoding $encoding)
            foreach ($line in $lines) {
                Write-IQServiceLogLine -Line $line -LastLevel ([ref]$lastLevel)
            }

            Start-Sleep -Milliseconds 250
        }
    }
    catch [System.OperationCanceledException] {
        # Ctrl+C when TreatControlCAsInput could not be set.
    }
    finally {
        if ($null -ne $treatCtrlC) {
            try { [Console]::TreatControlCAsInput = $treatCtrlC } catch { }
        }
        Write-Host ''
        Write-Ok 'Stopped streaming.'
    }
}

# -----------------------------------------------------------------------------
# Interactive menu
# -----------------------------------------------------------------------------



function Write-IQServiceMenuHeader {
    Write-Host ''
    Write-Host ' SailPoint ISC - IQService Control' -ForegroundColor Cyan
    Write-Host ' Download, install, update, manage IQService, and enable AD LDAPS.' -ForegroundColor DarkCyan
    Write-Host ''
}

function Show-InteractiveMenu {
    param(
        [string]$ResolvedInstallPath,
        [string]$TraceFile,
        [int]$TailLines = 50
    )

    while ($true) {
        Write-IQServiceMenuHeader

        $instances = @(Get-IQServiceInstances | Where-Object { Test-LooksLikeIQServiceDirectory -Path $_.InstallPath })
        $current = Get-IQServiceInstanceForPath -InstallPath $ResolvedInstallPath -Instances $instances

        Write-Info "Install path: $ResolvedInstallPath"
        if ($current) {
            Write-Info "Instance: $(Get-IQServiceInstanceLabel -Instance $current)"
        }
        if ($instances.Count -gt 1) {
            Write-Info "$($instances.Count) IQService instances on this host. Every action applies to the install path above."
        }
        Write-Host ''

        $options = @('Status', 'Download', 'Install', 'Update', 'Service', 'SetLogLevel', 'StreamLogs', 'Unblock', 'EnableLdaps')
        $labels = @(
            'Status'
            'Download ZIP'
            'Install / register'
            'Update (backup, replace, reinstall)'
            'Start / Stop / Restart'
            'Set log level'
            'Stream logs'
            'Unblock Utils.dll and other binaries'
            'Enable AD LDAPS (636) + export VA PEM'
        )
        if ($instances.Count -gt 1) {
            $options += 'Instance'
            $labels += 'Switch instance'
        }
        $options += 'Exit'
        $labels += 'Exit'

        try {
            $choice = Read-Choice -Prompt 'Select an action:' -Options $options -Labels $labels -Default 'Status'
        }
        catch {
            if (Test-PromptBack $_) { return }
            throw
        }

        try {
            switch ($choice) {
            'Status' {
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'Status'
            }
            'Download' {
                $source = Read-Choice -Prompt 'Download from URL or use a local ZIP?' -Options @('Url', 'Local') -Labels @(
                    'Paste a pre-signed ISC download URL'
                    'Use a local ZIP file path'
                ) -Default 'Url'
                if ($source -eq 'Url') {
                    $uri = Read-InputString -Prompt 'Download URL' -Required
                    Save-IQServiceZip -InstallPath $ResolvedInstallPath -DownloadUri $uri | Out-Null
                }
                else {
                    $localZip = Read-InputString -Prompt 'Path to IQService.zip' -Required
                    Save-IQServiceZip -InstallPath $ResolvedInstallPath -ZipPath $localZip | Out-Null
                }
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'Download'
            }
            'Install' {
                if (-not (Test-Path -LiteralPath (Get-IQServiceZipPath -InstallPath $ResolvedInstallPath))) {
                    $getZip = Read-YesNo -Prompt 'IQService.zip is not in the install path. Download or copy it first?' -Default $true
                    if ($getZip) {
                        $source = Read-Choice -Prompt 'Download from URL or use a local ZIP?' -Options @('Url', 'Local') -Labels @(
                            'Paste a pre-signed ISC download URL'
                            'Use a local ZIP file path'
                        ) -Default 'Url'
                        if ($source -eq 'Url') {
                            $uri = Read-InputString -Prompt 'Download URL' -Required
                            Save-IQServiceZip -InstallPath $ResolvedInstallPath -DownloadUri $uri | Out-Null
                        }
                        else {
                            $localZip = Read-InputString -Prompt 'Path to IQService.zip' -Required
                            Save-IQServiceZip -InstallPath $ResolvedInstallPath -ZipPath $localZip | Out-Null
                        }
                    }
                }
                $start = Read-YesNo -Prompt 'Start IQService after install?' -Default $false
                Install-IQServiceInstance -InstallPath $ResolvedInstallPath -StartAfterInstall:$start
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'Install'
            }
            'Update' {
                $uri = $null
                $localZip = $null
                if (Read-YesNo -Prompt 'Download or supply a new ZIP for this update?' -Default $true) {
                    $source = Read-Choice -Prompt 'Download from URL or use a local ZIP?' -Options @('Url', 'Local') -Labels @(
                        'Paste a pre-signed ISC download URL'
                        'Use a local ZIP file path'
                    ) -Default 'Url'
                    if ($source -eq 'Url') {
                        $uri = Read-InputString -Prompt 'Download URL' -Required
                    }
                    else {
                        $localZip = Read-InputString -Prompt 'Path to IQService.zip' -Required
                    }
                }
                $start = Read-YesNo -Prompt 'Start IQService after update?' -Default $true
                Update-IQServiceInstance -InstallPath $ResolvedInstallPath -DownloadUri $uri -ZipPath $localZip -StartAfterInstall:$start
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'Update' -ServiceStoppedAfterUpdate:(-not $start)
            }
            'Service' {
                $svcAction = Read-Choice -Prompt 'Service action:' -Options @('Start', 'Stop', 'Restart', 'Uninstall') -Default 'Restart'
                Invoke-IQServiceServiceAction -ServiceAction $svcAction -InstallPath $ResolvedInstallPath
                $completionAction = switch ($svcAction) {
                    'Start' { 'Start' }
                    'Stop' { 'Stop' }
                    'Restart' { 'Restart' }
                    'Uninstall' { 'Uninstall' }
                }
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction $completionAction
            }
            'SetLogLevel' {
                $level = Read-Choice -Prompt 'Trace level:' -Options @('Off', 'Error', 'Info', 'Debug', 'Trace') -Default 'Info'
                $logResult = Set-IQServiceTraceLevel -InstallPath $ResolvedInstallPath -Level $level -RestartIfRunning
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'SetLogLevel' `
                    -RestartPendingForLogLevel:([bool]$logResult.RestartPending)
            }
            'StreamLogs' {
                Show-IQServiceLogStream -InstallPath $ResolvedInstallPath -TraceFile $TraceFile -TailLines $TailLines
            }
            'Unblock' {
                Write-Step 'Unblocking IQService files'
                $result = Unblock-IQServiceFiles -InstallPath $ResolvedInstallPath
                Write-Ok "Unblocked $($result.Unblocked) of $($result.Checked) file(s)"
                $remaining = [Math]::Max(0, $result.Checked - $result.Unblocked)
                Show-IQServiceCompletion -InstallPath $ResolvedInstallPath -CompletedAction 'Unblock' -BlockedFilesRemaining $remaining
            }
            'EnableLdaps' {
                $pemPath = $null
                if (Read-YesNo -Prompt 'Use a custom PEM output directory?' -Default $false) {
                    $pemPath = Read-InputString -Prompt 'PEM output directory' -Required
                }
                $extraDns = $null
                if (Read-YesNo -Prompt 'Add extra DNS names for the certificate?' -Default $false) {
                    $rawDns = Read-InputString -Prompt 'Comma-separated DNS names' -Required
                    $extraDns = @($rawDns -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
                }
                $selection = Select-AdLdapsCertificate -DnsName $extraDns
                $restartNtds = $false
                if ($selection.CreateSelfSigned) {
                    $restartNtds = Read-YesNo -Prompt 'Restart NTDS after creating the new certificate? (brief AD outage)' -Default $false
                }
                Enable-AdLdaps -InstallPath $ResolvedInstallPath -PemOutputPath $pemPath `
                    -Thumbprint $selection.Thumbprint -DnsName $extraDns `
                    -CreateSelfSigned:$selection.CreateSelfSigned -RestartNtds:$restartNtds | Out-Null
            }
            'Instance' {
                $selected = Select-IQServiceInstance -Instances $instances -Prompt 'Select the IQService instance to manage:'
                $ResolvedInstallPath = [string]$selected.InstallPath
                Write-Ok "Now managing $(Get-IQServiceInstanceLabel -Instance $selected)"
            }
            'Exit' {
                return
            }
        }
        }
        catch {
            if (Test-PromptBack $_) { continue }
            throw
        }
    }
}


Export-ModuleMember -Function @(
    'Initialize-IQServiceData'
    'Test-IQServiceNonInteractive'
    'Build-IQServiceResult'
    'Assert-WindowsHost'
    'Test-IsElevated'
    'Assert-Elevated'
    'Show-IQServiceCompletion'
    'Test-LooksLikeIQServiceDirectory'
    'Get-IQServiceWindowsServices'
    'Get-IQServiceDefaultInstallPath'
    'Resolve-IQServiceInstallPath'
    'Get-IQServiceExecutable'
    'Get-IQServiceRegistryInstances'
    'Get-IQServiceComparablePath'
    'Test-SameIQServicePath'
    'Get-IQServiceParentPath'
    'Resolve-IQServiceInstanceRecords'
    'Get-IQServiceInstances'
    'Get-IQServiceInstanceForPath'
    'Get-IQServiceInstanceLabel'
    'Select-IQServiceInstance'
    'Get-IQServiceZipPath'
    'Get-IQServiceBuildFromPath'
    'Invoke-IQServiceCommand'
    'Get-IQServiceVersion'
    'Test-FileBlocked'
    'Unblock-IQServiceFiles'
    'Save-IQServiceZip'
    'Expand-IQServiceZip'
    'Get-IQServiceConfigurationSnapshot'
    'Build-IQServiceInstallArguments'
    'Restore-IQServiceServiceAccount'
    'Install-IQServiceInstance'
    'Update-IQServiceInstance'
    'Invoke-IQServiceServiceAction'
    'Set-IQServiceTraceLevel'
    'Show-IQServiceStatus'
    'Resolve-IQServiceTraceFile'
    'Get-IQServiceLogLevelColor'
    'ConvertTo-CompleteLogLines'
    'Write-IQServiceLogLine'
    'Test-LogStreamCancel'
    'Get-IQServiceLogFileIdentity'
    'Read-IQServiceLogDelta'
    'Show-IQServiceLogStream'
    'Show-InteractiveMenu'
    'Write-IQServiceMenuHeader'
)