registry.ps1

# Map of short and long hive names to canonical PowerShell provider paths.
# HKLM: and HKCU: are default PowerShell drives; the remaining hives use the
# Registry:: provider path because their short PS drives are not guaranteed to
# exist in Windows PowerShell 5.1.
$script:HiveMap = @{
  'HKLM' = 'HKLM:'
  'HKCU' = 'HKCU:'
  'HKCR' = 'Registry::HKEY_CLASSES_ROOT'
  'HKU' = 'Registry::HKEY_USERS'
  'HKCC' = 'Registry::HKEY_CURRENT_CONFIG'
  'HKEY_LOCAL_MACHINE' = 'HKLM:'
  'HKEY_CURRENT_USER' = 'HKCU:'
  'HKEY_CLASSES_ROOT' = 'Registry::HKEY_CLASSES_ROOT'
  'HKEY_USERS' = 'Registry::HKEY_USERS'
  'HKEY_CURRENT_CONFIG' = 'Registry::HKEY_CURRENT_CONFIG'
}

# Normalises any registry path format into a canonical PS drive path (HKLM:\...)
function ConvertTo-RegistryProviderPath {
  <#
    .SYNOPSIS
      Converts a registry path to PS drive format suitable for provider cmdlets.
    .DESCRIPTION
      Accepts short hive notation (HKLM\...), PS drive notation (HKLM:\...),
      Registry:: prefix (Registry::HKEY_LOCAL_MACHINE\...), or long .NET names
      (HKEY_LOCAL_MACHINE\...). Returns a provider path that can be passed to
      registry provider cmdlets. HKLM and HKCU use their default PowerShell
      drives; hives such as HKEY_USERS use the Registry:: provider path because
      aliases like HKU: are not present in every shell.
    .EXAMPLE
      PS> ConvertTo-RegistryProviderPath 'HKLM\Software\MyApp'
      HKLM:\Software\MyApp
    .EXAMPLE
      PS> ConvertTo-RegistryProviderPath 'Registry::HKEY_CURRENT_USER\Control Panel'
      HKCU:\Control Panel
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([string])]
  [CmdletBinding()]
  param (
    # Registry path in any supported format
    [Parameter(Mandatory = $true)]
    [string]
    $Path
  )

  # Strip Registry:: provider prefix if present
  $_normalized = $Path -replace '^Registry::', ''

  # Detect the hive portion and replace with canonical PS drive root
  $_matched = $false
  foreach ($_candidate in $script:HiveMap.Keys) {
    # Match exact hive name at start, optionally followed by : or \
    $_candidatePattern = [regex]::Escape($_candidate)
    if ($_normalized -match "^$($_candidatePattern)(?::|\\|$)") {
      $_remainder = $_normalized.Substring($_candidate.Length).TrimStart(':', '\')
      $_normalized = if ([string]::IsNullOrEmpty($_remainder)) {
        $script:HiveMap[$_candidate]
      }
      else {
        "$($script:HiveMap[$_candidate])\$_remainder"
      }
      $_matched = $true
      break
    }
  }

  if (-not $_matched) {
    Write-Error "Unable to resolve registry hive from path: '$Path'"
    return $null
  }

  # Collapse duplicate separators, trim trailing backslash, and remove
  # trailing colon only when it follows a path separator (preserving bare
  # PS drive names such as 'HKLM:').
  $_normalized = $_normalized -replace '\\+', '\'
  if ($_normalized -match '\\') {
    $_normalized = $_normalized -replace ':+$', ''
  }
  $_normalized = $_normalized -replace '\\+$', ''

  return $_normalized
}

# Resolves a registry path to a RegistryKey object
function Resolve-RegistryPath {
  <#
    .SYNOPSIS
      Resolves a registry path string to a Microsoft.Win32.RegistryKey object.
    .DESCRIPTION
      Parses a registry path supplied in PS drive format (HKLM:\... or
      HKCU:\...), short format (HKLM\...), or full .NET provider format
      (Registry::HKEY_LOCAL_MACHINE\...), looks up the corresponding hive from
      the module's HiveMap, and returns the matching RegistryKey. Returns $null
      when the requested key does not exist.
    .EXAMPLE
      PS> Resolve-RegistryPath -Path 'HKLM:\Software\Microsoft'
    .EXAMPLE
      PS> Resolve-RegistryPath -Path 'HKCU:\Control Panel\Desktop' -Writable
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([Microsoft.Win32.RegistryKey])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp' or 'HKCU:\Control Panel'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Open the key with write access
    [Parameter(Mandatory = $false)]
    [switch]
    $Writable = $false
  )

  # Short hive name -> .NET RegistryHive (kept here for this function only;
  # higher-level functions rely on the provider cmdlets instead.)
  $_hiveEnum = @{
    'HKLM' = [Microsoft.Win32.RegistryHive]::LocalMachine
    'HKCU' = [Microsoft.Win32.RegistryHive]::CurrentUser
    'HKCR' = [Microsoft.Win32.RegistryHive]::ClassesRoot
    'HKU' = [Microsoft.Win32.RegistryHive]::Users
    'HKCC' = [Microsoft.Win32.RegistryHive]::CurrentConfig
    'HKEY_LOCAL_MACHINE' = [Microsoft.Win32.RegistryHive]::LocalMachine
    'HKEY_CURRENT_USER' = [Microsoft.Win32.RegistryHive]::CurrentUser
    'HKEY_CLASSES_ROOT' = [Microsoft.Win32.RegistryHive]::ClassesRoot
    'HKEY_USERS' = [Microsoft.Win32.RegistryHive]::Users
    'HKEY_CURRENT_CONFIG' = [Microsoft.Win32.RegistryHive]::CurrentConfig
  }

  # Normalise path then extract the hive name
  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  $_registryPath = $_providerPath -replace '^Registry::', ''
  $_separator = $_registryPath.IndexOf('\')
  if ($_separator -eq -1) {
    $_hiveName = $_registryPath.TrimEnd(':')
    $_subKey = ''
  }
  else {
    $_hiveName = $_registryPath.Substring(0, $_separator).TrimEnd(':')
    $_subKey = $_registryPath.Substring($_separator + 1)
  }

  if (-not $_hiveEnum.ContainsKey($_hiveName)) {
    Write-Error "Unknown registry hive: '$_hiveName'. Supported: $($_hiveEnum.Keys -join ', ')"
    return $null
  }

  $_hive = $_hiveEnum[$_hiveName]

  try {
    if ([string]::IsNullOrEmpty($_subKey)) {
      return [Microsoft.Win32.RegistryKey]::OpenBaseKey($_hive, [Microsoft.Win32.RegistryView]::Default)
    }
    $_root = [Microsoft.Win32.RegistryKey]::OpenBaseKey($_hive, [Microsoft.Win32.RegistryView]::Default)
    return $_root.OpenSubKey($_subKey, $Writable)
  }
  catch [System.UnauthorizedAccessException] {
    Write-Error "Access denied opening registry key: '$Path'"
    return $null
  }
  catch {
    Write-Error "Failed to resolve registry path '$Path': $_"
    return $null
  }
}

function Get-RegistryKey {
  <#
    .SYNOPSIS
      Retrieves metadata about a registry key.
    .DESCRIPTION
      Opens the registry key at the given path and returns an object describing
      its subkey names and value names. Returns $null when the key does not
      exist.
    .EXAMPLE
      PS> Get-RegistryKey -Path 'HKLM:\Software\Microsoft'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  if (-not (Test-Path -Path $_providerPath)) {
    Write-Error "Registry key not found: '$Path'"
    return $null
  }

  try {
    # Enumerate subkeys via the PS drive provider
    $_subKeys = @(Get-ChildItem -Path $_providerPath -ErrorAction Stop |
        ForEach-Object { $_.PSChildName })

    # Enumerate value names; '(default)' is normalised to '' to match .NET behaviour
    $_itemProps = Get-ItemProperty -Path $_providerPath -ErrorAction Stop
    $_values = @($_itemProps.PSObject.Properties |
        Where-Object { $_.Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider') } |
        ForEach-Object { if ($_.Name -eq '(default)') { '' } else { $_.Name } })

    [PSCustomObject]@{
      Path = $Path
      SubKeys = $_subKeys
      Values = $_values
    }
  }
  catch [System.UnauthorizedAccessException] {
    Write-Error "Access denied reading registry key: '$Path'"
    return $null
  }
  catch {
    Write-Error "Failed to read registry key '$Path': $_"
    return $null
  }
}

function Set-RegistryKey {
  <#
    .SYNOPSIS
      Creates a registry key if it does not already exist.
    .DESCRIPTION
      Attempts to create the registry key at the given path. If the key already
      exists the operation is skipped. Returns a status object indicating
      whether the key was created or already present.
    .EXAMPLE
      PS> Set-RegistryKey -Path 'HKLM:\Software\MyApp'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  # Guard against root-hive creation
  if ($_providerPath -notmatch '\\') {
    Write-Error "Cannot create a root hive key: '$Path'"
    return $null
  }

  # Idempotency: check existence via the provider (cheap, no .NET handle needed)
  if (Test-Path -Path $_providerPath) {
    Write-Verbose "Registry key already exists: '$Path'"
    return [PSCustomObject]@{
      Path = $Path
      Status = 'AlreadyExists'
    }
  }

  if ($PSCmdlet.ShouldProcess($Path, 'Create registry key')) {
    try {
      # New-Item -Force creates the key tree; returns $null when key already exists
      $null = New-Item -Path $_providerPath -Force -ErrorAction Stop
      Write-Verbose "Created registry key: '$Path'"
      return [PSCustomObject]@{
        Path = $Path
        Status = 'Created'
      }
    }
    catch [System.UnauthorizedAccessException] {
      Write-Error "Access denied creating registry key: '$Path'"
      return $null
    }
    catch {
      Write-Error "Failed to create registry key '$Path': $_"
      return $null
    }
  }
}

function Remove-RegistryKey {
  <#
    .SYNOPSIS
      Removes a registry key and optionally its subkeys.
    .DESCRIPTION
      Deletes the registry key at the given path. When -Recurse is specified,
      all descendant subkeys are removed as well. If the key does not exist the
      operation is skipped. Returns a status object indicating whether the key
      was removed or was already absent.
    .EXAMPLE
      PS> Remove-RegistryKey -Path 'HKLM:\Software\MyApp'
    .EXAMPLE
      PS> Remove-RegistryKey -Path 'HKLM:\Software\MyApp' -Recurse
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Remove the key and all its descendant subkeys
    [Parameter(Mandatory = $false)]
    [switch]
    $Recurse = $false
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  # Guard against root-hive removal
  if ($_providerPath -notmatch '\\') {
    Write-Error "Cannot remove a root hive key: '$Path'"
    return $null
  }

  # Idempotency: skip when the key is already absent
  if (-not (Test-Path -Path $_providerPath)) {
    Write-Verbose "Registry key does not exist (nothing to remove): '$Path'"
    return [PSCustomObject]@{
      Path = $Path
      Status = 'NotFound'
    }
  }

  if ($PSCmdlet.ShouldProcess($Path, 'Remove registry key')) {
    try {
      # Remove-Item with -Recurse handles the subkey-tree case natively
      $null = Remove-Item -Path $_providerPath -Recurse:$Recurse -Force -ErrorAction Stop
      Write-Verbose "Removed registry key: '$Path'"
      return [PSCustomObject]@{
        Path = $Path
        Status = 'Removed'
      }
    }
    catch [System.UnauthorizedAccessException] {
      Write-Error "Access denied removing registry key: '$Path'"
      return $null
    }
    catch {
      Write-Error "Failed to remove registry key '$Path': $_"
      return $null
    }
  }
}

function Get-RegistryValue {
  <#
    .SYNOPSIS
      Reads a named value from a registry key.
    .DESCRIPTION
      Opens the registry key at the given path and returns the data stored in
      the named value. Returns $null when either the key or the value does not
      exist.
    .EXAMPLE
      PS> Get-RegistryValue -Path 'HKLM:\Software\MyApp' -Name 'Version'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([object])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Name of the value to read; omit to read the default (unnamed) value
    [Parameter(Mandatory = $false)]
    [string]
    $Name = ''
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  if (-not (Test-Path -Path $_providerPath)) {
    Write-Error "Registry key not found: '$Path'"
    return $null
  }

  try {
    $_resolvedName = if ($Name -eq '') { '(default)' } else { $Name }
    return Get-ItemPropertyValue -Path $_providerPath -Name $_resolvedName -ErrorAction Stop
  }
  catch [System.UnauthorizedAccessException] {
    Write-Error "Access denied reading registry key: '$Path'"
    return $null
  }
  catch {
    Write-Verbose "Value '$Name' not found in registry key '$Path'"
    return $null
  }
}

function Set-RegistryValue {
  <#
    .SYNOPSIS
      Creates or updates a named value in a registry key.
    .DESCRIPTION
      Writes the supplied data to the named value under the given registry key.
      The function is idempotent: if the value already exists and holds the same
      data the operation is skipped. Returns a status object indicating whether
      the value was created, updated, or left unchanged.
    .EXAMPLE
      PS> Set-RegistryValue -Path 'HKLM:\Software\MyApp' -Name 'Version' -Value '2.0'
    .EXAMPLE
      PS> Set-RegistryValue -Path 'HKLM:\Software\MyApp' -Name 'Enabled' -Value 1 -Type DWord
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Name of the value to set; omit to set the default (unnamed) value
    [Parameter(Mandatory = $false)]
    [string]
    $Name = '',

    # Data to store in the value
    [Parameter(Mandatory = $true)]
    [AllowNull()]
    [object]
    $Value,

    # Registry value kind; defaults to String for strings, DWord for integers,
    # and ExpandString / MultiString / Binary / QWord as appropriate
    [Parameter(Mandatory = $false)]
    [ValidateSet('String', 'ExpandString', 'Binary', 'DWord', 'MultiString', 'QWord', 'Unknown')]
    [Microsoft.Win32.RegistryValueKind]
    $Type
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  # --- Ensure the parent key exists (idempotent) ---
  if (-not (Test-Path -Path $_providerPath)) {
    Write-Verbose "Parent key does not exist, creating: '$Path'"
    $_created = Set-RegistryKey -Path $Path
    if (-not $_created -or $_created.Status -notin @('Created', 'AlreadyExists')) {
      Write-Error "Failed to ensure registry key exists: '$Path'"
      return $null
    }
  }

  try {
    # --- Determine the registry value kind if not explicitly provided ---
    if (-not $PSBoundParameters.ContainsKey('Type')) {
      if ($null -eq $Value) {
        $Type = [Microsoft.Win32.RegistryValueKind]::String
      }
      elseif ($Value -is [int] -or $Value -is [long]) {
        $Type = [Microsoft.Win32.RegistryValueKind]::DWord
      }
      elseif ($Value -is [string[]]) {
        $Type = [Microsoft.Win32.RegistryValueKind]::MultiString
      }
      elseif ($Value -is [byte[]]) {
        $Type = [Microsoft.Win32.RegistryValueKind]::Binary
      }
      else {
        $Type = [Microsoft.Win32.RegistryValueKind]::String
      }
    }

    # --- Idempotency: read current value and compare ---
    # The provider surfaces the default (unnamed) value as '(default)'.
    $_resolvedName = if ($Name -eq '') { '(default)' } else { $Name }

    try {
      $_currentValue = Get-ItemPropertyValue -Path $_providerPath -Name $_resolvedName -ErrorAction Stop
      $_valueExists = $true
    }
    catch {
      $_valueExists = $false
      $_currentValue = $null
    }

    if ($_valueExists) {

      # Infer the RegistryValueKind from the .NET type Get-ItemProperty returned.
      # This is reliable for all kinds except String vs ExpandString (both
      # surface as [string]). In that rare edge-case we fall back to a
      # lightweight .NET call for an exact match.
      $_inferKind = {
        param($_v)
        if ($null -eq $_v) { return [Microsoft.Win32.RegistryValueKind]::String }
        if ($_v -is [int]) { return [Microsoft.Win32.RegistryValueKind]::DWord }
        if ($_v -is [long]) { return [Microsoft.Win32.RegistryValueKind]::QWord }
        if ($_v -is [string[]]) { return [Microsoft.Win32.RegistryValueKind]::MultiString }
        if ($_v -is [byte[]]) { return [Microsoft.Win32.RegistryValueKind]::Binary }
        return [Microsoft.Win32.RegistryValueKind]::String
      }
      $_currentInferred = & $_inferKind $_currentValue

      # Compare values with proper handling for null and array types
      $_isSameValue = $false
      if ($null -eq $_currentValue -and $null -eq $Value) {
        $_isSameValue = $true
      }
      elseif ($null -ne $_currentValue -and $null -ne $Value) {
        if ($_currentValue -is [string[]] -and $Value -is [string[]]) {
          $_isSameValue = (($_currentValue -join "`0") -eq ($Value -join "`0"))
        }
        elseif ($_currentValue -is [byte[]] -and $Value -is [byte[]]) {
          $_isSameValue = ([System.Convert]::ToBase64String($_currentValue) -eq [System.Convert]::ToBase64String($Value))
        }
        else {
          $_isSameValue = ($_currentValue.ToString() -eq $Value.ToString())
        }
      }

      # Type check: if the inferred kind disagrees with the target type
      # and either is ExpandString, use a precise .NET read to be certain.
      $_isSameKind = ($_currentInferred -eq $Type)
      if (-not $_isSameKind -and
        ($Type -eq [Microsoft.Win32.RegistryValueKind]::ExpandString -or
        $_currentInferred -eq [Microsoft.Win32.RegistryValueKind]::ExpandString -or
        $_currentInferred -eq [Microsoft.Win32.RegistryValueKind]::String -and
        $Type -eq [Microsoft.Win32.RegistryValueKind]::String)) {
        # Ambiguous case - fall back to a precise .NET read for the actual kind
        $_key = Resolve-RegistryPath -Path $Path
        if ($_key) {
          try { $_isSameKind = ($_key.GetValueKind($Name) -eq $Type) }
          catch { $_isSameKind = $false }
          finally { $_key.Dispose() }
        }
      }

      if ($_isSameValue -and $_isSameKind) {
        Write-Verbose "Registry value '$Name' already set to the requested data in '$Path'"
        return [PSCustomObject]@{
          Path = $Path
          Name = $Name
          Status = 'Unchanged'
        }
      }

      # --- Update existing value ---
      if ($PSCmdlet.ShouldProcess("$Path\$Name", "Update registry value to '$Value'")) {
        $null = Set-ItemProperty -Path $_providerPath -Name $_resolvedName -Value $Value -Type $Type -ErrorAction Stop
        Write-Verbose "Updated registry value '$Name' in '$Path'"
        return [PSCustomObject]@{
          Path = $Path
          Name = $Name
          Status = 'Updated'
        }
      }
    }
    else {
      # --- Create new value ---
      if ($PSCmdlet.ShouldProcess("$Path\$Name", "Create registry value with '$Value'")) {
        $null = Set-ItemProperty -Path $_providerPath -Name $_resolvedName -Value $Value -Type $Type -ErrorAction Stop
        Write-Verbose "Created registry value '$Name' in '$Path'"
        return [PSCustomObject]@{
          Path = $Path
          Name = $Name
          Status = 'Created'
        }
      }
    }
  }
  catch [System.UnauthorizedAccessException] {
    Write-Error "Access denied setting registry value '$Name' in '$Path'"
    return $null
  }
  catch {
    Write-Error "Failed to set registry value '$Name' in '$Path': $_"
    return $null
  }
}

function Remove-RegistryValue {
  <#
    .SYNOPSIS
      Removes a named value from a registry key.
    .DESCRIPTION
      Deletes the named value from the registry key at the given path. If the
      value does not exist the operation is skipped. Returns a status object
      indicating whether the value was removed or was already absent.
    .EXAMPLE
      PS> Remove-RegistryValue -Path 'HKLM:\Software\MyApp' -Name 'Version'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([PSCustomObject])]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Name of the value to remove; omit to remove the default (unnamed) value
    [Parameter(Mandatory = $false)]
    [string]
    $Name = ''
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  # Key existence check (read-only, no handle needed)
  if (-not (Test-Path -Path $_providerPath)) {
    Write-Verbose "Registry key not found (nothing to remove): '$Path'"
    return [PSCustomObject]@{
      Path = $Path
      Name = $Name
      Status = 'KeyNotFound'
    }
  }

  try {
    $_resolvedName = if ($Name -eq '') { '(default)' } else { $Name }

    try {
      $null = Get-ItemPropertyValue -Path $_providerPath -Name $_resolvedName -ErrorAction Stop
    }
    catch {
      Write-Verbose "Registry value '$Name' does not exist in '$Path' (nothing to remove)"
      return [PSCustomObject]@{
        Path = $Path
        Name = $Name
        Status = 'NotFound'
      }
    }

    if ($PSCmdlet.ShouldProcess("$Path\$Name", 'Remove registry value')) {
      $null = Remove-ItemProperty -Path $_providerPath -Name $_resolvedName -ErrorAction Stop
      Write-Verbose "Removed registry value '$Name' from '$Path'"
      return [PSCustomObject]@{
        Path = $Path
        Name = $Name
        Status = 'Removed'
      }
    }
  }
  catch [System.UnauthorizedAccessException] {
    Write-Error "Access denied removing registry value '$Name' from '$Path'"
    return $null
  }
  catch {
    Write-Error "Failed to remove registry value '$Name' from '$Path': $_"
    return $null
  }
}

function Test-RegistryPath {
  <#
    .SYNOPSIS
      Returns $true if the registry path exists, $false otherwise.
    .DESCRIPTION
      A safe, non-terminating existence check for registry keys. Unlike
      Resolve-RegistryPath, this function never emits errors - it simply
      returns a boolean. Suitable for use in conditionals and idempotency
      guards.
    .EXAMPLE
      PS> if (Test-RegistryPath 'HKLM:\Software\MyApp') { ... }
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([bool])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $false }

  return Test-Path -Path $_providerPath
}

function Test-RegistryValue {
  <#
    .SYNOPSIS
      Returns $true if the named registry value exists, $false otherwise.
    .DESCRIPTION
      Checks both that the parent key exists and that the named value is
      present on that key. Never emits errors, making it ideal for
      conditional guards before reading or removing values.
    .EXAMPLE
      PS> if (Test-RegistryValue 'HKLM:\Software\MyApp' -Name 'Version') { ... }
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([bool])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Name of the value to test; omit to test the default (unnamed) value
    [Parameter(Mandatory = $false)]
    [string]
    $Name = ''
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $false }

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

  try {
    $_resolvedName = if ($Name -eq '') { '(default)' } else { $Name }
    $null = Get-ItemPropertyValue -Path $_providerPath -Name $_resolvedName -ErrorAction Stop
    return $true
  }
  catch {
    return $false
  }
}

function Get-RegistryValueKind {
  <#
    .SYNOPSIS
      Gets the exact Microsoft.Win32.RegistryValueKind of a registry value.
    .DESCRIPTION
      Because the PS provider cmdlets (Get-ItemProperty / Get-ItemPropertyValue)
      do not expose the raw RegistryValueKind, this function uses a lightweight
      .NET read solely for type inspection. It is useful when you need to
      distinguish, e.g., String from ExpandString.
      Returns $null if the key or value does not exist.
    .EXAMPLE
      PS> Get-RegistryValueKind -Path 'HKLM:\Software\MyApp' -Name 'PathVar'
      ExpandString
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [OutputType([Microsoft.Win32.RegistryValueKind])]
  [CmdletBinding()]
  param (
    # Registry path, e.g. 'HKLM:\Software\MyApp'
    [Parameter(Mandatory = $true)]
    [string]
    $Path,

    # Name of the value to inspect; omit to inspect the default (unnamed) value
    [Parameter(Mandatory = $false)]
    [string]
    $Name = ''
  )

  $_providerPath = ConvertTo-RegistryProviderPath -Path $Path
  if (-not $_providerPath) { return $null }

  if (-not (Test-Path -Path $_providerPath)) {
    Write-Verbose "Registry key not found: '$Path'"
    return $null
  }

  # Verify the value is present before opening a .NET handle
  if (-not (Test-RegistryValue -Path $Path -Name $Name)) {
    Write-Verbose "Value '$Name' not found in registry key '$Path'"
    return $null
  }

  $_key = Resolve-RegistryPath -Path $Path
  if (-not $_key) { return $null }

  try {
    return $_key.GetValueKind($Name)
  }
  catch {
    Write-Error "Failed to get registry value kind for '$Name' in '$Path': $_"
    return $null
  }
  finally {
    if ($_key) { $_key.Dispose() }
  }
}

function Mount-DefaultUserHive {
  <#
    .SYNOPSIS
      Loads C:\Users\Default\NTUSER.DAT into the registry under HKU\<MountName>.
    .DESCRIPTION
      Mounts the default user profile hive so it can be modified before
      sealing an image (Sysprep / Audit Mode). Changes written here propagate
      to every new user account created on the deployed system.
      HKEY_USERS\.DEFAULT is the LocalSystem profile - do NOT write there for
      this purpose. Use Dismount-DefaultUserHive to unload when done.
      Requires elevation.
    .PARAMETER MountName
      Subkey name under HKEY_USERS to mount at. Defaults to 'DefaultUser'.
    .PARAMETER HivePath
      Path to NTUSER.DAT. Defaults to C:\Users\Default\NTUSER.DAT.
    .EXAMPLE
      Mount-DefaultUserHive
      Set-RegistryValue -Path 'Registry::HKEY_USERS\DefaultUser\Software\...' -Name 'Foo' -Value 1
      Dismount-DefaultUserHive
    .NOTES
      reg.exe is used because hive loading is not exposed through the managed
      registry API without manual privilege elevation (SE_RESTORE_NAME /
      SE_BACKUP_NAME).
  #>

  [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
  [OutputType([string])]
  param(
    [ValidatePattern('^[A-Za-z0-9_-]+$')]
    [string]$MountName = 'DefaultUser',

    [ValidateNotNullOrEmpty()]
    [string]$HivePath = (Join-Path $env:SystemDrive 'Users\Default\NTUSER.DAT')
  )

  if (-not (Test-Path -LiteralPath $HivePath)) {
    Write-Log -Message "Default user hive not found at '$HivePath'." -Color Red
    return $null
  }

  # Refuse if already mounted - silent re-use risks writing to the wrong hive
  $mountPath = "Registry::HKEY_USERS\$MountName"
  if (Test-Path -LiteralPath $mountPath) {
    Write-Log -Message "A hive is already mounted at HKEY_USERS\$MountName. Dismount it first or choose a different MountName." -Color Red
    return $null
  }

  if (-not $PSCmdlet.ShouldProcess("HKEY_USERS\$MountName", "Load hive from '$HivePath'")) {
    return $null
  }

  $output = & reg.exe load "HKU\$MountName" $HivePath 2>&1
  if ($LASTEXITCODE -ne 0) {
    Write-Log -Message "reg.exe load failed (exit $LASTEXITCODE): $output" -Color Red
    return $null
  }

  Write-Log -Message "Mounted default user hive: HKEY_USERS\$MountName" -Color Green
  return $mountPath
}

function Dismount-DefaultUserHive {
  <#
    .SYNOPSIS
      Unloads a previously mounted default user hive.
    .DESCRIPTION
      Calls reg.exe unload. If lingering handles cause the first attempt to
      fail, forces a garbage collection and retries once. A failed dismount
      leaves the default profile corrupted - investigate before sealing the
      image.
    .PARAMETER MountName
      Subkey name under HKEY_USERS to unload. Must match Mount-DefaultUserHive.
  #>

  [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
  param(
    [ValidatePattern('^[A-Za-z0-9_-]+$')]
    [string]$MountName = 'DefaultUser'
  )

  $mountPath = "Registry::HKEY_USERS\$MountName"
  if (-not (Test-Path -LiteralPath $mountPath)) {
    Write-Log -Message "No hive mounted at HKEY_USERS\$MountName; nothing to unload." -Color Yellow
    return
  }

  if (-not $PSCmdlet.ShouldProcess("HKEY_USERS\$MountName", 'Unload hive')) {
    return
  }

  # First attempt
  $output = & reg.exe unload "HKU\$MountName" 2>&1
  if ($LASTEXITCODE -eq 0) {
    Write-Log -Message "Unloaded HKEY_USERS\$MountName." -Color Green
    return
  }

  # Retry after forcing GC - PowerShell's registry provider may still hold handles
  Write-Log -Message "First unload attempt failed: $output. Forcing GC and retrying." -Color Yellow
  [System.GC]::Collect()
  [System.GC]::WaitForPendingFinalizers()
  Start-Sleep -Milliseconds 500

  $output = & reg.exe unload "HKU\$MountName" 2>&1
  if ($LASTEXITCODE -ne 0) {
    Write-Log -Message "reg.exe unload failed after GC retry (exit $LASTEXITCODE): $output. Investigate which process still has the hive open before continuing." -Color Red
    return
  }
  Write-Log -Message "Unloaded HKEY_USERS\$MountName on retry." -Color Green
}

function Export-RegistryKey {
  <#
    .SYNOPSIS
      Exports a registry key to a .reg text file via reg.exe.
    .DESCRIPTION
      Calls reg.exe export /y against the supplied key. Uses Invoke-SafeProcess
      internally so stdout/stderr are captured.
    .PARAMETER Key
      Registry key path, e.g. 'HKLM\Software\Microsoft\Windows\CurrentVersion\Run'.
    .PARAMETER OutputPath
      Path for the exported .reg file.
    .EXAMPLE
      PS> Export-RegistryKey -Key 'HKLM\Software\Microsoft\Windows\CurrentVersion\Run' -OutputPath '.\HKLM-Run.reg'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $Key,

    [Parameter(Mandatory = $true)]
    [string]
    $OutputPath
  )

  try {
    $null = Invoke-SafeProcess -FilePath 'reg.exe' -ArgumentList @('export', $Key, $OutputPath, '/y')
    return $true
  }
  catch {
    Write-Error "Failed to export registry key '$Key': $_"
    return $false
  }
}

function Search-RegistryKey {
  <#
    .SYNOPSIS
      Searches a registry hive for a pattern via reg.exe query.
    .DESCRIPTION
      Calls reg.exe query <Root> /f <Pattern> /s to recursively search for a
      string or pattern across a registry hive. Output is written to -OutputPath.
      Uses Invoke-SafeProcess internally.
    .PARAMETER Root
      Registry hive root, e.g. 'HKLM', 'HKCU'.
    .PARAMETER Pattern
      Search pattern forwarded to /f.
    .PARAMETER OutputPath
      File to write the search results to.
    .EXAMPLE
      PS> Search-RegistryKey -Root 'HKLM' -Pattern 'InstallUtil' -OutputPath '.\Reg-HKLM-InstallUtil.txt'
    .LINK
      https://github.com/adnoctem/winkit/lib/registry.ps1
    .NOTES
      Author: MVProwess <info@mvprowess.com>
      License: MIT
  #>


  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $Root,

    [Parameter(Mandatory = $true)]
    [string]
    $Pattern,

    [Parameter(Mandatory = $true)]
    [string]
    $OutputPath
  )

  try {
    $null = Invoke-SafeProcess -FilePath 'reg.exe' -ArgumentList @('query', $Root, '/f', $Pattern, '/s') -OutputPath $OutputPath
    return $true
  }
  catch {
    Write-Error "Failed to search registry '$Root' for pattern '$Pattern': $_"
    return $false
  }
}