Functions/Private/Update-CtsStopCache.ps1

function Update-CtsStopCache {
  <#
  .SYNOPSIS
  Retrieves the CTS stop list and caches it in memory and on disk
  .DESCRIPTION
  Fetches all CTS stops, lines and destinations from the API and indexes them in the singleton [StopCache] instance.
  The result is also persisted to a JSON file in the temp directory so subsequent sessions can skip the API call.
  .EXAMPLE
  Update-CtsStopCache
  .EXAMPLE
  Update-CtsStopCache -Force
  Bypasses the file cache and fetches fresh data from the API.
  #>

  [CmdletBinding(SupportsShouldProcess)]
  [OutputType([Void])]
  param (
    # Bypass both the in-memory and file caches, fetch fresh data from the API
    [Parameter()]
    [Switch] $Force,

    # Skip writing the cache file to disk (in-memory cache only)
    [Parameter()]
    [Switch] $NoCacheFile
  )
  process {
    $FileCachePath = [System.IO.Path]::GetTempPath() | Join-Path -ChildPath 'cts-stop-cache.json'
    $Refresh = $false

    if ($Force) {
      Write-Verbose -Message 'CtsStop: Refreshing cache'
    } elseif ([StopCache]::Instance.Ready) {
      if ([StopCache]::Instance.ValidUntil -lt [DateTime]::Now) {
        Write-Verbose -Message 'CtsStop: Cache has expired'
        $Refresh = $true
      }
    } else {
      if (Test-Path -Path $FileCachePath) {
        try {
          # Load file cache if available
          [StopFileCache]$FileCache = Get-Content -Path $FileCachePath -Raw | ConvertFrom-Json -AsHashtable

          if ($FileCache.ValidUntil -lt [DateTime]::Now) {
            Write-Verbose -Message 'CtsStop: Cache has expired'
            $Refresh = $true
          } else {
            # Index stops and lines
            Write-Verbose -Message "CtsStop: Using cache: $FileCachePath"
            [StopCache]::Instance.Init($FileCache.ValidUntil)
            $FileCache.Stops | ForEach-Object { [StopCache]::Instance.Stops[$_.Id] = $_ }
            $FileCache.Lines | ForEach-Object {
              [StopCache]::Instance.Lines[$_.Id] = $_
              [StopCache]::Instance.Destinations[$_.Id] = $_.Destinations
            }
            [StopCache]::Instance.Ready = $true
          }
        } catch {
          Write-Warning -Message "CtsStop: Error loading cache: $($_.Exception.Message)"
          $Refresh = $true
        }
      } else {
        Write-Verbose -Message 'CtsStop: Cache not found'
        $Refresh = $true
      }
    }

    if ($Force -or ($Refresh -and $PSCmdlet.ShouldProcess('Memory cache', 'Refresh stop cache'))) {
      try {
        # Refresh stop cache
        $Response = Invoke-CtsApi -Path 'siri/2.0/stoppoints-discovery' -Query @{ IncludeLinesDestinations = $true }
        [CtsStopPointsDelivery]$StopPoints = $Response.StopPointsDelivery

        $LineCache = [System.Collections.Concurrent.ConcurrentDictionary[String, LineRawInfo]]::new()
        [StopCache]::Instance.Init($StopPoints.ResponseTimestamp.AddDays(7))

        $StopPoints.AnnotatedStopPointRef | ForEach-Object {
          $Stop = [StopInfo]@{
            Id   = $_.StopPointRef
            Name = $_.StopName
          }
          $IncludeStop = $false
          $_.Lines | ForEach-Object {
            $RawLine = [LineRawInfo]@{
              Id    = $_.LineRef
              Name  = $_.LineName
              Color = $_.Extension.RouteColor
              Dark  = $_.Extension.RouteTextColor -eq 'FFFFFF'
            }
            $IncludeLine = $false
            $_.Destinations | ForEach-Object {
              $Direction = $_.DirectionRef
              # Ignore terminus (inconsistent across lines)
              $_.DestinationName | Where-Object { $_ -ne $Stop.Name } | ForEach-Object {
                $Destination = [Destination]@{
                  Line      = $RawLine.Id
                  Direction = $Direction
                  Name      = $_
                  Stops     = $Stop.Id
                }
                # Add or update line destinations
                if (-not [StopCache]::Instance.Destinations.TryAdd($Destination.Line, $Destination)) {
                  $Destinations = [StopCache]::Instance.Destinations[$Destination.Line]
                  $IsNewDest = $true
                  foreach ($Dest in $Destinations) {
                    if ($Dest.Direction -eq $Destination.Direction -and $Dest.Name -eq $Destination.Name) {
                      $Dest.Stops.Add($Stop.Id)
                      $IsNewDest = $false
                      break
                    }
                  }
                  if ($IsNewDest) {
                    $Destinations.Add($Destination)
                  }
                }
                $IncludeLine += $true
              }
            }
            if ($IncludeLine) {
              # Use line cache as reference
              if ($LineCache.TryAdd($RawLine.Id, $RawLine)) {
                [StopCache]::Instance.Lines[$RawLine.Id] = $RawLine
              }
              $IncludeStop += $true
            }
          }
          if ($IncludeStop) {
            [StopCache]::Instance.Stops[$Stop.Id] = $Stop
          }
        }
        [StopCache]::Instance.Ready = $true

        # Update file cache
        if (-not $NoCacheFile -and ($Force -or $PSCmdlet.ShouldProcess($FileCachePath, 'Replace file cache'))) {
          $FileCache = [StopFileCache]@{
            ValidUntil = [StopCache]::Instance.ValidUntil
            Stops      = [StopCache]::Instance.Stops.Values
            Lines      = $LineCache.Values
          }
          $FileCache.Lines | ForEach-Object { $_.Destinations = [StopCache]::Instance.Destinations[$_.Id] }
          $SetParam = @{
            Path    = $FileCachePath
            Force   = true
            WhatIf  = $false
            Confirm = $false
          }
          $FileCache | ConvertTo-Json -Depth 100 -Compress | Set-Content @SetParam
          Write-Verbose -Message "CtsStop: Updated cache: $FileCachePath"
        }
      } catch {
        $PSCmdlet.ThrowTerminatingError($_)
      }
    }
  }
}