Get WhoIs Info from a list of IP Addresses

[CmdletBinding(SupportsShouldProcess)]
param(
  [Parameter(Mandatory = $false,
  ValueFromPipeline = $true, Position = 0)] [string[]]$IPAddresses,
  [Parameter(Mandatory = $false)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$InputFile,
  [Parameter(Mandatory = $false)] [string]$OutputPath,
  [Parameter(Mandatory = $false)] [ValidateRange(0, 5000)] [int]$RateLimitDelay = 250,
  [Parameter(Mandatory = $false)] [string]$LogPath = ".\whois-lookup-$(Get-Date -Format 'yyyyMMdd-HHmmss').log",
  [Parameter(Mandatory = $false)] [switch]$IncludeGeoData,
  [Parameter(Mandatory = $false)] [switch]$ContinueOnError
)

begin {
  $ErrorActionPreference = 'Stop'
  $ProgressPreference = 'SilentlyContinue'

  # Stats tracking
  $script:stats = @{
  Total = 0 Success = 0 Failed = 0 Skipped = 0 StartTime = Get-Date
  }

  # Collection for pipeline input
  $pipelineIPs = [System.Collections.Generic.List[string]]::new()

  # Logging function
  function Write-Log {
    param(
    [string]$Message,
    [ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS')]
    [string]$Level = 'INFO'
    )

    $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $logMessage = "[$timestamp] [$Level] $Message"

    # Console output with color
    $color = switch
    ($Level) {
      'ERROR' { 'Red' }
      'WARN' { 'Yellow' }
      'SUCCESS' { 'Green' }
      default { 'White' }
  }
  Write-Host $logMessage -ForegroundColor $color

  # File output
  try {
    Add-Content -Path $LogPath -Value $logMessage -ErrorAction SilentlyContinue
  }

  catch {
    Write-Warning "Failed to write to log: $($_.Exception.Message)"
  }
}

# Validate IP format
function Test-IPAddress {
  param([string]$IP) if ([string]::IsNullOrWhiteSpace($IP)) {
  return $false
  }
  try {
    $octets = $IP.Trim() -split '\.'
    if ($octets.Count -ne 4) {
      return $false
    }
    foreach ($octet in $octets) {
      $num = [int]$octet
      if ($num -lt 0 -or $num -gt 255) {
        return $false
      }
    }
    return $true
  }
    
  catch {
    return $false
  }
}

# Walk full exception chain - GetAwaiter wraps in AggregateException hiding the real cause
function Get-ExceptionChain {
  param(
    [System.Exception]$ex
  )
  $msgs = [System.Collections.Generic.List[string]]::new()
  # Unwrap AggregateException first
  if ($ex -is [System.AggregateException]) {
    $ex = $ex.GetBaseException()
    }
  while ($ex) {
    if ($msgs -notcontains $ex.Message) {
      $msgs.Add($ex.Message)
    }
    $ex = $ex.InnerException
  }
  return ($msgs -join ' → ')
}