PSBuienradar.psm1

#Region './PSBuienradar.prefix.ps1' -1

<# Code inserted in this file will be placed at the top of the .PSM1 file generated by ModuleBuilder #>
<# Delete this file if not needed for your module #>
<# Delete these comments if you don't want them in your module #>
#EndRegion './PSBuienradar.prefix.ps1' 4
#Region './Private/ConvertRainIntensity.ps1' -1

function ConvertRainIntensity {
    <#
    .SYNOPSIS
        Converts a BuienRadar rain intensity value to a human-friendly description

    .DESCRIPTION
        Internal helper that maps BuienRadar rain intensity values (0-255) to
        a human-readable text description with survival-oriented humour and emoji.
        The BuienRadar scale is roughly logarithmic:
            0 = No rain
            1-77 = Very light rain (drizzle)
            78-109 = Light rain
            110-140 = Moderate rain
            141-255 = Heavy rain (SURVIVAL MODE)

    .PARAMETER Value
        The rain intensity value as returned by BuienRadar (0-255)

    .EXAMPLE
        ConvertRainIntensity -Value 0
        Returns: "No rain ☀️"

    .EXAMPLE
        ConvertRainIntensity -Value 120
        Returns: "Moderate rain 🌧️"

    .OUTPUTS
        System.String

    .NOTES
        Private function - not exported from module
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [ValidateRange(0, 255)]
        [int]
        $Value
    )

    if ($Value -eq 0) {
        return 'No rain ☀️'
    }

    if ($Value -le 77) {
        return 'Very light rain (drizzle) 🌂'
    }

    if ($Value -le 109) {
        return 'Light rain 🌦️'
    }

    if ($Value -le 140) {
        return 'Moderate rain 🌧️'
    }

    return 'Heavy rain 🌧️ -- SURVIVAL MODE ACTIVATED'
}
#EndRegion './Private/ConvertRainIntensity.ps1' 60
#Region './Private/GetDistanceBetweenCoordinates.ps1' -1

function GetDistanceBetweenCoordinates {
    <#
    .SYNOPSIS
        Calculates the distance between two geographic coordinates

    .DESCRIPTION
        Internal helper that uses the Haversine formula to calculate the
        great-circle distance in kilometres between two latitude/longitude points.

    .PARAMETER Latitude1
        Latitude of the first point in decimal degrees

    .PARAMETER Longitude1
        Longitude of the first point in decimal degrees

    .PARAMETER Latitude2
        Latitude of the second point in decimal degrees

    .PARAMETER Longitude2
        Longitude of the second point in decimal degrees

    .EXAMPLE
        GetDistanceBetweenCoordinates -Latitude1 52.37 -Longitude1 4.90 -Latitude2 51.92 -Longitude2 4.48
        Returns the distance in km between Amsterdam and Rotterdam.

    .OUTPUTS
        System.Double

    .NOTES
        Private function - not exported from module
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param (
        [Parameter(Mandatory)]
        [ValidateRange(-90.0, 90.0)]
        [double]
        $Latitude1,

        [Parameter(Mandatory)]
        [ValidateRange(-180.0, 180.0)]
        [double]
        $Longitude1,

        [Parameter(Mandatory)]
        [ValidateRange(-90.0, 90.0)]
        [double]
        $Latitude2,

        [Parameter(Mandatory)]
        [ValidateRange(-180.0, 180.0)]
        [double]
        $Longitude2
    )

    $earthRadiusKm = 6371.0

    $deltaLat = ($Latitude2 - $Latitude1) * [Math]::PI / 180.0
    $deltaLon = ($Longitude2 - $Longitude1) * [Math]::PI / 180.0

    $lat1Rad = $Latitude1 * [Math]::PI / 180.0
    $lat2Rad = $Latitude2 * [Math]::PI / 180.0

    $a = [Math]::Sin($deltaLat / 2) * [Math]::Sin($deltaLat / 2) +
         [Math]::Cos($lat1Rad) * [Math]::Cos($lat2Rad) *
         [Math]::Sin($deltaLon / 2) * [Math]::Sin($deltaLon / 2)

    $c = 2 * [Math]::Atan2([Math]::Sqrt($a), [Math]::Sqrt(1 - $a))

    return $earthRadiusKm * $c
}
#EndRegion './Private/GetDistanceBetweenCoordinates.ps1' 72
#Region './Private/GetSurvivalAdvice.ps1' -1

function GetSurvivalAdvice {
    <#
    .SYNOPSIS
        Generates humorous Dutch weather survival advice based on conditions

    .DESCRIPTION
        Internal helper that combines rain intensity and wind speed to produce
        a context-aware, slightly humorous survival tip for the Dutch weather.

    .PARAMETER Intensity
        The current BuienRadar rain intensity value (0-255).

    .PARAMETER Station
        The nearest BuienRadar station measurement object. Used to access
        wind speed for additional survival commentary.

    .EXAMPLE
        GetSurvivalAdvice -Intensity 0 -Station $station
        Returns: "Congratulations, it's not raining (yet). You might survive today."

    .OUTPUTS
        System.String

    .NOTES
        Private function - not exported from module
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory)]
        [ValidateRange(0, 255)]
        [int]
        $Intensity,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object]
        $Station
    )

    $windSpeed = if ($Station.PSObject.Properties['windspeed'] -and $null -ne $Station.windspeed) { [double]$Station.windspeed } else { 0.0 }
    $windWarning = if ($windSpeed -ge 10.0) {
        " Wind is strong enough to question your life choices ($windSpeed m/s). Hold on to your bicycle."
    } elseif ($windSpeed -ge 7.0) {
        " Also, it's quite breezy -- your umbrella may have other plans."
    } else {
        ''
    }

    $rainAdvice = if ($Intensity -eq 0) {
        "Congratulations, it's not raining (yet). You might survive today."
    } elseif ($Intensity -le 77) {
        'There is some drizzle. Your hair will notice even if you pretend not to.'
    } elseif ($Intensity -le 109) {
        'Take an umbrella. Seriously. This is not a drill.'
    } elseif ($Intensity -le 140) {
        'Moderate rain incoming. The Dutch call this "a bit wet". Outsiders call it "miserable".'
    } else {
        'HEAVY RAIN -- SURVIVAL MODE ACTIVATED. Find higher ground, a stroopwafel, and the will to carry on. 🌧️'
    }

    return "$rainAdvice$windWarning"
}
#EndRegion './Private/GetSurvivalAdvice.ps1' 64
#Region './Public/Get-GeoLocation.ps1' -1

function Get-GeoLocation {
    <#
    .SYNOPSIS
        Converts a public IP address to geographic coordinates and city name

    .DESCRIPTION
        Uses the ip-api.com free GeoIP API to resolve an IP address to
        latitude, longitude, and city name. The result is used to find
        nearby BuienRadar weather stations and fetch rain forecasts.

    .PARAMETER IP
        The public IP address to look up. Accepts IPv4 addresses.

    .EXAMPLE
        Get-GeoLocation -IP '203.0.113.42'

        Returns a PSCustomObject with Latitude, Longitude, and City.

    .EXAMPLE
        Get-PublicIP | Get-GeoLocation

        Chains public-IP detection with geo-location lookup.

    .INPUTS
        System.String

    .OUTPUTS
        PSCustomObject

    .NOTES
        Uses http://ip-api.com/json/{ip}. The free tier supports up to
        45 requests per minute from the same IP address.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(
            Mandatory,
            ValueFromPipeline,
            Position = 0
        )]
        [ValidateNotNullOrEmpty()]
        [string]
        $IP
    )

    process {
        try {
            Write-Verbose "$($MyInvocation.MyCommand): Looking up geo-location for IP: $IP"
            $uri = "http://ip-api.com/json/$IP"
            $response = Invoke-RestMethod -Uri $uri -ErrorAction Stop

            if ($response.status -ne 'success') {
                throw "Geo-location lookup failed for '$IP': $($response.message)"
            }

            $location = [PSCustomObject]@{
                IP        = $IP
                Latitude  = [double]$response.lat
                Longitude = [double]$response.lon
                City      = $response.city
                Country   = $response.country
            }
            $location.PSObject.TypeNames.Insert(0, 'BuienRadar.GeoLocation')

            Write-Verbose "$($MyInvocation.MyCommand): Resolved to $($location.City), Lat=$($location.Latitude), Lon=$($location.Longitude)"
            return $location
        } catch {
            Write-Verbose "$($MyInvocation.MyCommand) failed for '$IP': $_"
            throw $_
        }
    }
}
#EndRegion './Public/Get-GeoLocation.ps1' 74
#Region './Public/Get-NearestWeatherStation.ps1' -1

function Get-NearestWeatherStation {
    <#
    .SYNOPSIS
        Finds the nearest BuienRadar weather station to the given coordinates

    .DESCRIPTION
        Accepts an array of BuienRadar station measurement objects (as returned
        by the /2.0/feed/json endpoint) and returns the station closest to the
        supplied latitude and longitude, calculated using the Haversine formula.

    .PARAMETER Latitude
        The latitude of the reference point in decimal degrees.

    .PARAMETER Longitude
        The longitude of the reference point in decimal degrees.

    .PARAMETER Stations
        An array of BuienRadar station measurement objects. Each object must
        expose at least a 'lat' and 'lon' property. Typically sourced from
        the 'actual.stationmeasurements' array of the BuienRadar JSON feed.

    .EXAMPLE
        $feed = Invoke-RestMethod -Uri 'https://data.buienradar.nl/2.0/feed/json'
        Get-NearestWeatherStation -Latitude 52.37 -Longitude 4.90 -Stations $feed.actual.stationmeasurements

        Returns the weather station closest to Amsterdam.

    .INPUTS
        None

    .OUTPUTS
        PSCustomObject

    .NOTES
        Requires BuienRadar station objects with 'lat' and 'lon' properties.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [ValidateRange(-90.0, 90.0)]
        [double]
        $Latitude,

        [Parameter(Mandatory)]
        [ValidateRange(-180.0, 180.0)]
        [double]
        $Longitude,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [object[]]
        $Stations
    )

    try {
        Write-Verbose "$($MyInvocation.MyCommand): Finding nearest station to Lat=$Latitude, Lon=$Longitude from $($Stations.Count) stations"

        $nearest = $null
        $shortestDistance = [double]::MaxValue

        foreach ($station in $Stations) {
            if ($null -eq $station.lat -or $null -eq $station.lon) {
                Write-Verbose "$($MyInvocation.MyCommand): Skipping station with missing coordinates: $($station.stationname)"
                continue
            }

            $distance = GetDistanceBetweenCoordinates `
                -Latitude1 $Latitude `
                -Longitude1 $Longitude `
                -Latitude2 ([double]$station.lat) `
                -Longitude2 ([double]$station.lon)

            if ($distance -lt $shortestDistance) {
                $shortestDistance = $distance
                $nearest = $station
            }
        }

        if ($null -eq $nearest) {
            throw 'No valid weather stations found in the provided data.'
        }

        Write-Verbose "$($MyInvocation.MyCommand): Nearest station is '$($nearest.stationname)' at $([Math]::Round($shortestDistance, 1)) km"
        return $nearest
    } catch {
        Write-Verbose "$($MyInvocation.MyCommand) failed for Lat=$Latitude, Lon=${Longitude}: $_"
        throw $_
    }
}
#EndRegion './Public/Get-NearestWeatherStation.ps1' 91
#Region './Public/Get-PublicIP.ps1' -1

function Get-PublicIP {
    <#
    .SYNOPSIS
        Returns the current user's public IP address

    .DESCRIPTION
        Queries the ipify.org API to determine the public IP address of the machine
        running this command. Useful as the first step in a geo-location workflow.

    .EXAMPLE
        Get-PublicIP

        Returns the public IP address as a string, e.g. "203.0.113.42".

    .INPUTS
        None

    .OUTPUTS
        System.String

    .NOTES
        Requires internet access to reach https://api.ipify.org.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param ()

    try {
        Write-Verbose "$($MyInvocation.MyCommand): Querying ipify.org for public IP"
        $response = Invoke-RestMethod -Uri 'https://api.ipify.org?format=json' -ErrorAction Stop
        $ip = $response.ip

        if ([string]::IsNullOrWhiteSpace($ip)) {
            throw 'ipify.org returned an empty IP address.'
        }

        Write-Verbose "$($MyInvocation.MyCommand): Detected public IP: $ip"
        return $ip
    } catch {
        Write-Verbose "$($MyInvocation.MyCommand) failed: $_"
        throw $_
    }
}
#EndRegion './Public/Get-PublicIP.ps1' 44
#Region './Public/Get-RainForecast.ps1' -1

function Get-RainForecast {
    <#
    .SYNOPSIS
        Retrieves the rain forecast for the next two hours from BuienRadar

    .DESCRIPTION
        Queries the BuienRadar gpsgadget rain-text endpoint for the given
        latitude and longitude and parses the response into a collection of
        time-stamped rain intensity objects.

        Each forecast entry contains:
            - Time : The forecast time (HH:mm)
            - Intensity : Raw BuienRadar rain value (0-255)
            - Description : Human-friendly rain description with emoji

        The rain intensity scale is roughly logarithmic:
            0 = No rain
            1-77 = Very light rain (drizzle)
            78-109 = Light rain
            110-140 = Moderate rain
            141-255 = Heavy rain (SURVIVAL MODE)

    .PARAMETER Latitude
        The latitude of the location to fetch the rain forecast for.

    .PARAMETER Longitude
        The longitude of the location to fetch the rain forecast for.

    .EXAMPLE
        Get-RainForecast -Latitude 52.37 -Longitude 4.90

        Returns the next two-hour rain forecast for Amsterdam.

    .INPUTS
        None

    .OUTPUTS
        PSCustomObject[]

    .NOTES
        Uses https://gpsgadget.buienradar.nl/data/raintext?lat={lat}&lon={lon}.
        Data is updated approximately every 5 minutes.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory)]
        [ValidateRange(-90.0, 90.0)]
        [double]
        $Latitude,

        [Parameter(Mandatory)]
        [ValidateRange(-180.0, 180.0)]
        [double]
        $Longitude
    )

    try {
        $lat = [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:F2}', $Latitude)
        $lon = [string]::Format([System.Globalization.CultureInfo]::InvariantCulture, '{0:F2}', $Longitude)
        $uri = "https://gpsgadget.buienradar.nl/data/raintext?lat=$lat&lon=$lon"

        Write-Verbose "$($MyInvocation.MyCommand): Fetching rain forecast from $uri"
        $raw = Invoke-RestMethod -Uri $uri -ErrorAction Stop

        if ([string]::IsNullOrWhiteSpace($raw)) {
            throw 'BuienRadar returned an empty rain forecast response.'
        }

        $lines = $raw -split "`n" | Where-Object { $_ -match '\S' }
        $forecast = foreach ($line in $lines) {
            $parts = $line.Trim() -split '\|'
            if ($parts.Count -ne 2) {
                Write-Verbose "$($MyInvocation.MyCommand): Skipping malformed line: $line"
                continue
            }

            $intensityValue = [int]$parts[0]
            $entry = [PSCustomObject]@{
                Time        = $parts[1].Trim()
                Intensity   = $intensityValue
                Description = ConvertRainIntensity -Value $intensityValue
            }
            $entry.PSObject.TypeNames.Insert(0, 'BuienRadar.RainForecastEntry')
            $entry
        }

        Write-Verbose "$($MyInvocation.MyCommand): Parsed $(@($forecast).Count) forecast entries"
        return $forecast
    } catch {
        Write-Verbose "$($MyInvocation.MyCommand) failed for Lat=$Latitude, Lon=${Longitude}: $_"
        throw $_
    }
}
#EndRegion './Public/Get-RainForecast.ps1' 95
#Region './Public/Get-WeatherSurvivalAdvice.ps1' -1

function Get-WeatherSurvivalAdvice {
    <#
    .SYNOPSIS
        Returns a humorous Dutch weather survival report for your current location

    .DESCRIPTION
        Orchestrates the full BuienRadar weather survival pipeline:
            1. Detects the user's public IP address
            2. Resolves the IP to geographic coordinates and city name
            3. Fetches the two-hour rain forecast from BuienRadar
            4. Downloads the BuienRadar weather feed and finds the nearest station
            5. Assembles a custom PSCustomObject with weather data and survival advice

        Survival advice is generated based on current conditions:
            - Heavy rain -> urgent evacuation humour
            - Moderate rain -> umbrella reminders
            - Light rain -> mild caution
            - No rain -> cautiously optimistic encouragement

    .EXAMPLE
        Get-WeatherSurvivalAdvice

        Detects location automatically and returns the survival report.

    .EXAMPLE
        $report = Get-WeatherSurvivalAdvice
        $report.SurvivalAdvice

        Retrieves only the survival advice text.

    .INPUTS
        None

    .OUTPUTS
        PSCustomObject

    .NOTES
        Requires internet access to the following endpoints:
            - https://api.ipify.org
            - http://ip-api.com
            - https://gpsgadget.buienradar.nl
            - https://data.buienradar.nl
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param ()

    try {
        Write-Verbose "$($MyInvocation.MyCommand): Starting Dutch weather survival assessment"

        # Step 1: Public IP
        Write-Verbose "$($MyInvocation.MyCommand): Step 1 - Detecting public IP"
        $publicIP = Get-PublicIP

        # Step 2: Geo-location
        Write-Verbose "$($MyInvocation.MyCommand): Step 2 - Resolving geo-location for $publicIP"
        $geoLocation = Get-GeoLocation -IP $publicIP

        # Step 3: Rain forecast
        Write-Verbose "$($MyInvocation.MyCommand): Step 3 - Fetching rain forecast"
        $rainForecast = Get-RainForecast -Latitude $geoLocation.Latitude -Longitude $geoLocation.Longitude

        # Step 4: Nearest weather station
        Write-Verbose "$($MyInvocation.MyCommand): Step 4 - Fetching BuienRadar weather feed"
        $feed = Invoke-RestMethod -Uri 'https://data.buienradar.nl/2.0/feed/json' -ErrorAction Stop
        $nearestStation = Get-NearestWeatherStation `
            -Latitude $geoLocation.Latitude `
            -Longitude $geoLocation.Longitude `
            -Stations $feed.actual.stationmeasurements

        # Step 5: Determine current rain severity from first non-null forecast entry
        $currentIntensity = if ($null -ne $rainForecast -and @($rainForecast).Count -gt 0) {
            @($rainForecast)[0].Intensity
        } else {
            0
        }

        $survivalAdvice = GetSurvivalAdvice -Intensity $currentIntensity -Station $nearestStation

        # Step 6: Build report
        $report = [PSCustomObject]@{
            Location        = "$($geoLocation.City), $($geoLocation.Country)"
            Latitude        = $geoLocation.Latitude
            Longitude       = $geoLocation.Longitude
            StationName     = $nearestStation.stationname
            Temperature     = $nearestStation.temperature
            WindSpeed       = $nearestStation.windspeed
            WindDirection   = $nearestStation.winddirection
            Humidity        = $nearestStation.humidity
            RainForecast    = $rainForecast
            CurrentRain     = if ($null -ne $rainForecast -and @($rainForecast).Count -gt 0) {
                @($rainForecast)[0].Description
            } else {
                'No rain data available'
            }
            SurvivalAdvice  = $survivalAdvice
        }
        $report.PSObject.TypeNames.Insert(0, 'BuienRadar.SurvivalReport')

        Write-Verbose "$($MyInvocation.MyCommand): Survival report assembled for $($report.Location)"
        return $report
    } catch {
        Write-Verbose "$($MyInvocation.MyCommand) failed: $_"
        throw $_
    }
}
#EndRegion './Public/Get-WeatherSurvivalAdvice.ps1' 107
#Region './PSBuienradar.suffix.ps1' -1

<# Code inserted in this file will be placed at the bottom of the .PSM1 file generated by ModuleBuilder #>
<# Delete this file if not needed for your module #>
<# Delete these comments if you don't want them in your module #>
#EndRegion './PSBuienradar.suffix.ps1' 4