IPInfoLite.psm1

### Module Configuration (Private)
$script:config = @{
    api = @{
        baseUrl    = "https://api.ipinfo.io/lite/"
        baseUrlMe  = "https://api.ipinfo.io/lite/me"
        headers    = @{ Accept = "application/json" }
    }
     cache = @{
        queryLimit = 5000
     }
}

function New-ErrorResponse {
    param (
        [string]$ErrorCode,
        [string]$ErrorMessage,
        [string]$ErrorTarget = $null,
        $ErrorDetails = $null,
        $IP = $null
    )

    return [PSCustomObject]@{
        Success         = $false
        IP              = $IP
        ASN             = $null
        ASN_Name        = $null
        ASN_Domain      = $null
        Country         = $null
        Country_Code    = $null
        Continent       = $null
        Continent_Code  = $null
        CacheHit        = $null
        ErrorCode       = $ErrorCode
        ErrorMessage    = $ErrorMessage
        ErrorTarget     = $ErrorTarget
        ErrorTimestamp  = (Get-Date).ToUniversalTime().ToString("o")
        ErrorDetails    = $ErrorDetails
    }
}



class CacheResolverException : Exception {
    CacheResolverException ([string]$Message, [Exception]$InnerException) : base ($Message, $InnerException) { }
}


class QueryCache {
    hidden static [Hashtable]$Records = @{}
    hidden static [System.Collections.Queue]$KeyOrder = [System.Collections.Queue]::new()  # Tracks insertion order for eviction

    hidden [UInt64] $Hit = 0
    hidden [UInt64] $Miss = 0
    hidden [int] $Limit = 0
    hidden [UInt64] $Evicted = 0


    QueryCache () {
        $this.Init()
    }

    QueryCache ($Limit) {
        $this.Init()
        if ($Limit -gt 0) {
            $this.Limit = $Limit
        }
    }

    hidden [void] Init() {
        $this | Add-Member -MemberType ScriptProperty -Name 'Count' -Value { return [QueryCache]::Records.Count }
    }

    [void] Add ([string]$Key, $Value) {
        if ([String]::IsNullOrEmpty($Key)) {
            throw '$Key is null. Key must be a non-empty string.'
        }

        $_key = $Key.ToLower()

        if ([QueryCache]::Records.ContainsKey($_key)) {
            [QueryCache]::Records[$_key] = $Value
            return
        }

        if ($this.Limit -gt 0 -and [QueryCache]::Records.Count -ge $this.Limit) {
            $evictKey = [QueryCache]::KeyOrder.Dequeue()
            [QueryCache]::Records.Remove($evictKey)
            $this.Evicted++  # Tracks evictions
        }


        [QueryCache]::Records[$_key] = $Value
        [QueryCache]::KeyOrder.Enqueue($_key)
    }

    [bool] ContainsKey ([String]$Key) {
        if ([String]::IsNullOrEmpty($Key)) {
            throw '$Key is null. Key must be a non-empty string.'
        }

        $_key = $Key.ToLower()
        return [QueryCache]::Records.ContainsKey($_key)
    }

    [object] Get ([string]$Key) {
        if ([String]::IsNullOrEmpty($Key)) {
            throw '$Key is null. Key must be a non-empty string.'
        }

        $_key = $Key.ToLower()

        if ([QueryCache]::Records.ContainsKey($_key)) {
            $this.Hit++
            return [QueryCache]::Records[$_key]
        }

        $this.Miss++
        throw "Cache miss for key: $Key"
    }

    [object] GetStats () {
        return [PSCustomObject]@{
            Count = [QueryCache]::Records.Count
            Hit   = $this.Hit
            Miss  = $this.Miss
        }
    }

    [void] Clear() {
        [QueryCache]::Records.Clear()
        [QueryCache]::KeyOrder.Clear()
        $this.Hit = 0
        $this.Miss = 0
        $this.Evicted = 0
    }
}


function Get-IPInfoLiteCache {
    <#
    .SYNOPSIS
    Returns current statistics from the IPInfoLite query cache including entry count, cache hits, and evictions.
    .DESCRIPTION
    Get-IPInfoLiteCache retrieves internal cache performance metrics used by the IPInfoLite module.
    This includes the current number of entries in the cache, the number of successful cache hits,
    and the number of failed lookups (misses). It is useful for automation, monitoring, and diagnostics.
    .EXAMPLE
    Get-IPInfoLiteCache
    .OUTPUTS
    Returns a PSCustomObject with Count, Hit, and Miss properties.
    #>

    return [PSCustomObject]@{
        Success = $true
        Count   = [QueryCache]::Records.Count
        Hit     = $script:QueryCache.Hit
        Evicted = $script:QueryCache.Evicted
    }
}


function Clear-IPInfoLiteCache {
    <#
      .SYNOPSIS
        Clears the shared query cache used by the module.
      .DESCRIPTION
        Removes all previously cached query results. Use this if you suspect the
        module is returning outdated or incorrect information due to cached data.
      .EXAMPLE
        Clear-IPInfoLiteCache
    #>

    try {
        if ($script:QueryCache) {
            $script:QueryCache.Clear()
            return [PSCustomObject]@{
                Success = $true
            }
        }
        else {
            return [PSCustomObject]@{
                Success         = $false
                ErrorCode       = "ERR_QUERYCACHE_UNINITIALIZED"
                ErrorMessage    = "QueryCache has not been initialized."
                ErrorTarget     = "QueryCache"
                ErrorTimestamp  = (Get-Date).ToUniversalTime().ToString("o")
                ErrorDetails    = $null
            }
        }
    }
    catch {
        return [PSCustomObject]@{
            Success         = $false
            ErrorCode       = "ERR_QUERYCACHE_CLEAR_FAILED"
            ErrorMessage    = "Failed to clear QueryCache."
            ErrorTarget     = "QueryCache"
            ErrorTimestamp  = (Get-Date).ToUniversalTime().ToString("o")
            ErrorDetails    = $_
        }
    }
}


function Get-IPInfoLiteEntry {
        <#
    .SYNOPSIS
        Gets a single IP geolocation and ASN info using IPinfo Lite API.
    .PARAMETER token
        Your IPinfo API token.
    .PARAMETER ip
        Optional. IP address to look up. If not supplied, looks up caller's IP.
    .OUTPUTS
        PSCustomObject with geolocation and ASN information.
 
    .EXAMPLE
        Get-IPInfoLiteEntry -token "your_token_here" -ip "8.8.8.8"
 
        Returns geolocation and ASN information for the IP address 8.8.8.8
        using the IPInfo Lite API.
 
    .EXAMPLE
        Get-IPInfoLiteEntry -token "your_token_here"
 
        Returns geolocation and ASN information for the caller's public IP
        address using the IPInfo Lite API.
     #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [string]$token,
        [string]$ip = ""
    )

    # Don't attempt to cache or bogon-check self queries
    if ($ip -eq "") {
        $url = "$($script:config.api.baseUrlMe)?token=$token"

        try {
            $response = Invoke-RestMethod -Uri $url -Method Get -Headers $script:config.api.headers

            return [PSCustomObject]@{
                Success         = $true
                IP              = $response.ip
                ASN             = $response.asn
                ASN_Name        = $response.as_name
                ASN_Domain      = $response.as_domain
                Country         = $response.country
                Country_Code    = $response.country_code
                Continent       = $response.continent
                Continent_Code  = $response.continent_code
                CacheHit        = $false
            }
        } catch {
            return New-ErrorResponse `
                -ErrorCode "ERR_API_FAILURE" `
                -ErrorMessage "An error occurred while querying the IPInfo API for the current IP." `
                -ErrorTarget "self" `
                -ErrorDetails $_.ErrorDetails.Message `
                -IP $null
        }
    }

    # Validate input IP
    if (Test-BogonIP -ip $ip) {
        return New-ErrorResponse `
            -ErrorCode "ERR_BOGON_INPUT" `
            -ErrorMessage "The specified IP address is a bogon (non-routable/private) and will not be queried." `
            -ErrorTarget $ip `
            -IP $ip
    }

    # Use cache for normal IP lookups
    $cache = $script:QueryCache

    try {
        if ($cache.ContainsKey($ip)) {
            $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit
            $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true
            return $cached
        }

        $url = "$($script:config.api.baseUrl)$ip" + "?token=$token"
        $response = Invoke-RestMethod -Uri $url -Method Get -Headers $script:config.api.headers

        $result = [PSCustomObject]@{
            Success         = $true
            IP              = $response.ip
            ASN             = $response.asn
            ASN_Name        = $response.as_name
            ASN_Domain      = $response.as_domain
            Country         = $response.country
            Country_Code    = $response.country_code
            Continent       = $response.continent
            Continent_Code  = $response.continent_code
            CacheHit        = $false
        }

        $cache.Add($ip, $result)
        return $result

    } catch {
        $parsedJson = $null

        if ($_.ErrorDetails.Message) {
            try {
                $parsedJson = $_.ErrorDetails.Message | ConvertFrom-Json
            } catch {
                # Fallback: leave $parsedJson as null
            }
        }

        return New-ErrorResponse `
            -ErrorCode "ERR_API_FAILURE" `
            -ErrorMessage "An error occurred while querying the IPInfo API. See ErrorDetails for the full response." `
            -ErrorTarget $ip `
            -ErrorDetails $parsedJson `
            -IP $ip
    }
}


function Get-IPInfoLiteBatch {
    <#
    .SYNOPSIS
        Performs sequential IP info lookups using the IPinfo Lite API.
    .PARAMETER token
        Your IPinfo API token.
    .PARAMETER ips
        Array of IP addresses to look up.
    .OUTPUTS
        Array of custom objects with IP info or error messages.
 
    .EXAMPLE
        Get-IPInfoLiteBatch -token "your_token_here" -ips @("8.8.8.8", "1.1.1.1")
 
        Performs a batch lookup for multiple IP addresses using the IPInfo Lite API.
        Returns a list of geolocation and ASN information for each IP.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$token,

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

    # Validate token once at the top
    $testResult = Test-IPInfoLiteToken -token $token
    if (-not $testResult.Success) {
        return New-ErrorResponse `
            -ErrorCode "ERR_TOKEN_VERIFICATION" `
            -ErrorMessage "The API token provided could not be verified. Please ensure the token is correct, active, and has the necessary permissions" `
            -ErrorTarget "Token Validation" `
            -ErrorDetails $testResult
    }

    $results = New-Object System.Collections.Generic.List[PSObject]
    $cache = $script:QueryCache  # Use shared cache instance

    # Configure HttpClient
    $httpClient = $script:httpClient

    # Apply default headers from config
        foreach ($key in $script:config.api.headers.Keys) {
            if (-not $httpClient.DefaultRequestHeaders.Contains($key)) {
            [void]$httpClient.DefaultRequestHeaders.Add($key, $script:config.api.headers[$key])
        }
    }

    foreach ($ip in $ips) {
        try {
            # Skip bogon IPs
            if (Test-BogonIP -ip $ip) {
                $results.Add((New-ErrorResponse `
                    -ErrorCode "ERR_BOGON_INPUT" `
                    -ErrorMessage "The specified IP address is a bogon (non-routable/private) and will not be queried." `
                    -ErrorTarget $ip `
                    -IP $ip))
                continue
            }

            # Return cached value if available
            if ($cache.ContainsKey($ip)) {
                $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit
                $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true
                $results.Add($cached)
                continue
            }
            
            # Remote Query
            $url = "$($script:config.api.baseUrl)$ip" + "?token=$token"
            $response = $httpClient.GetAsync($url).Result
            $jsonContent = $response.Content.ReadAsStringAsync().Result
            $json = $jsonContent | ConvertFrom-Json

                $result = [PSCustomObject]@{
                    Success         = $true
                    IP              = $json.ip
                    ASN             = $json.asn
                    ASN_Name        = $json.as_name
                    ASN_Domain      = $json.as_domain
                    Country         = $json.country
                    Country_Code    = $json.country_code
                    Continent       = $json.continent
                    Continent_Code  = $json.continent_code
                    CacheHit        = $false
                    }

            $cache.Add($ip, $result)
            $results.Add($result)
                        

        } catch {
            $results.Add((New-ErrorResponse `
                -ErrorCode "ERR_LOOKUP_FAILED" `
                -ErrorMessage "Lookup failed for IP $ip" `
                -ErrorTarget $ip `
                -ErrorDetails $_.Exception.Message `
                -IP $ip))

        }
    }
    
    return ,$results.ToArray()

}


function Get-IPInfoLiteBatchParallel {
    <#
    .SYNOPSIS
        Performs high-efficiency batch IP lookups in parallel using the IPinfo Lite API. Requires PowerShell 7 or later.
    .PARAMETER token
        Your IPinfo API token.
    .PARAMETER ips
        Array of IP addresses to look up.
    .OUTPUTS
        Array of custom objects with IP info or error messages.
 
    .EXAMPLE
        Get-IPInfoLiteBatchParallel -Token "your_token_here" -ips @("8.8.8.8", "1.1.1.1")
 
        Executes a batch of parallel IP lookups using the IPinfo Lite API.
        Returns structured geolocation and ASN information for each IP address.
    #>

    
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$token,

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

    if ($PSVersionTable.PSVersion.Major -lt 7) {
        $errorRecord = [System.Management.Automation.ErrorRecord]::new(
            ([System.NotSupportedException]::new("PowerShell 7 or later is required.")),
            "ERR_PS_VERSION_UNSUPPORTED",
            [System.Management.Automation.ErrorCategory]::NotInstalled,
            $null
        )
        throw $errorRecord
    }


    # Validate token
    $testResult = Test-IPInfoLiteToken -token $token
    if (-not $testResult.Success) {
        return New-ErrorResponse `
            -ErrorCode "ERR_TOKEN_VERIFICATION" `
            -ErrorMessage "The API token provided could not be verified. Please ensure the token is correct, active, and has the necessary permissions" `
            -ErrorTarget "Token Validation" `
            -ErrorDetails $testResult
    }
    # Use shared cache instance
    $cache = $script:QueryCache  
    
    # Create the ConcurrentBag typed to PSCustomObject for thread-safe results
    $resultsBag = [System.Collections.Concurrent.ConcurrentBag[PSCustomObject]]::new()

    # Use a strongly-typed, high-performance list to track IPs to remove
    $ipsToRemove = [System.Collections.Generic.List[string]]::new()
    
    foreach ($ip in $ips) {
    
            # Check if the IP is a bogon
            if (Test-BogonIP -ip $ip) {
                $bogonResponse = New-ErrorResponse `
                    -ErrorCode "ERR_BOGON_INPUT" `
                    -ErrorMessage "The specified IP address is a bogon (non-routable/private) and will not be queried." `
                    -ErrorTarget $ip `
                    -IP $ip
                
                $resultsBag.Add($bogonResponse)
                $ipsToRemove.Add($ip)
                continue
            }

            # Check if the IP is already cached
            if ($cache.ContainsKey($ip)) {
                $cached = $cache.Get($ip) | Select-Object * -ExcludeProperty CacheHit
                $cached | Add-Member -NotePropertyName 'CacheHit' -NotePropertyValue $true
                
                $resultsBag.Add($cached)
                $ipsToRemove.Add($ip)
                continue
            }

    }

    # Remove IPs already processed (bogons or cache hits)
    $ips = $ips | Where-Object { $ipsToRemove -notcontains $_ }

    
    # Move the API configurations to local variables
    # This tends to work better with -Parallel
    $apiBaseUrl = $config.api.baseUrl
    $apiHeaders = $config.api.headers


    $ips | ForEach-Object -Parallel {
    $ip = $_
  
        # Prepare Local Variables
        $locaHeaders = $using:apiHeaders
        $localHttpClient = $using:httpClient
        $url = "$($using:apiBaseUrl)$ip" + "?token=$using:token"
    
        # Build HTTP request
        $request = [System.Net.Http.HttpRequestMessage]::new(
           [System.Net.Http.HttpMethod]::Get, $url
        )

        # Add headers to the request
        foreach ($kvp in $locaHeaders.GetEnumerator()) {
            [void]$request.Headers.TryAddWithoutValidation($kvp.Key, $kvp.Value) #| Out-Null
        }

        # Send the request and read the response
        $response = $localHttpClient.SendAsync($request).Result
        $body = $response.Content.ReadAsStringAsync().Result
        $json = $body | ConvertFrom-Json

        $result = [PSCustomObject]@{
            Success         = $true
            IP              = $json.ip
            ASN             = $json.asn
            ASN_Name        = $json.as_name
            ASN_Domain      = $json.as_domain
            Country         = $json.country
            Country_Code    = $json.country_code
            Continent       = $json.continent
            Continent_Code  = $json.continent_code
            CacheHit        = $false
        }
        

    $bag = $using:resultsBag
    $bag.Add($result)
    } 


    # Update the shared cache with new successful results.
    foreach ($result in $resultsBag) {
        if ($result.Success -and -not $result.CacheHit -and $result.IP -and $result.IP.Trim() -ne "") {
            $cache_object = $result.PSObject.Copy()
            $cache_object.CacheHit = $true
            $cache.Add($result.ip, $cache_object)
        }
    }

    return $resultsBag.ToArray()
}


function Test-IPInfoLiteToken {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [string]$token
    )

    $url = "$($script:config.api.baseUrlMe)?token=$token"

    try {
        $null = Invoke-RestMethod -Uri $url -Headers $script:config.api.headers -Method Get -TimeoutSec 5
        return [PSCustomObject]@{
            Success = $true
            Message = "Token is valid."
        }
    } catch {
        return [PSCustomObject]@{
            Success = $false
            Message = "Token validation failed: $($_.Exception.Message)"
            ErrorCode = $_.Exception.Response.StatusCode.Value__
        }
    }
}


function Initialize-BogonRanges {
    $filePath = Join-Path $PSScriptRoot 'Resources\bogonRanges.json'

    if (-not (Test-Path $filePath)) {
        throw "Bogon range data file not found: $filePath"
    }

    $jsonData = Get-Content $filePath -Raw | ConvertFrom-Json

    return $jsonData | ForEach-Object {
        [PSCustomObject]@{
            Network      = [System.Net.IPAddress]::Parse($_.Network)
            PrefixLength = $_.PrefixLength
        }
    }
}

function Test-BogonIP {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$IPAddress
    )

    $parsedIP = $null
    if (-not [System.Net.IPAddress]::TryParse($IPAddress, [ref]$parsedIP)) {
        Write-Warning "Invalid IP address format: $IPAddress"
        return $false
    }

    foreach ($range in $Script:BogonRanges) {
        if (Test-IPInCIDR -IPAddress $parsedIP -Network $range.Network -PrefixLength $range.PrefixLength) {
            return $true
        }
    }

    return $false
}


function Test-IPInCIDR {
    param (
        [System.Net.IPAddress]$IPAddress,
        [System.Net.IPAddress]$Network,
        [int]$PrefixLength
    )

    $ipBytes  = $IPAddress.GetAddressBytes()
    $netBytes = $Network.GetAddressBytes()

    if ($ipBytes.Length -ne $netBytes.Length) {
        return $false
    }

    $fullBytes = [math]::Floor($PrefixLength / 8)
    $remainingBits = $PrefixLength % 8

    for ($i = 0; $i -lt $fullBytes; $i++) {
        if ($ipBytes[$i] -ne $netBytes[$i]) {
            return $false
        }
    }

    if ($remainingBits -gt 0) {
        $mask = 0xFF -shl (8 - $remainingBits)
        if (($ipBytes[$fullBytes] -band $mask) -ne ($netBytes[$fullBytes] -band $mask)) {
            return $false
        }
    }

    return $true
}

# Ensure the System.Net.Http assembly is loaded.
# PowerShell 5.1 does not automatically load this .NET assembly, even though it exists on all supported systems.
# This check ensures that HttpClient and related types are available before use.
if ($PSVersionTable.PSVersion.Major -eq 5 -and $PSVersionTable.PSEdition -eq 'Desktop') {
    if (-not ("System.Net.Http.HttpClient" -as [type])) {
        Add-Type -AssemblyName "System.Net.Http"
    }
}

# Initialize a reusable HttpClient if not already initialized
if (-not $script:httpClient) {
    $script:httpClient = [System.Net.Http.HttpClient]::new()
}

# Initialize query cache instance
$script:QueryCache = [QueryCache]::new($script:config.cache.queryLimit)

# Initialize static bogon range cache
$Script:BogonRanges = Initialize-BogonRanges

Export-ModuleMember -Function Get-IPInfoLiteEntry, Get-IPInfoLiteBatch, Get-IPInfoLiteBatchParallel, Get-IPInfoLiteCache, Clear-IPInfoLiteCache