Private/WebTools.psm1

using namespace System.Net

using module .\Enums.psm1
using module .\Abstracts.psm1
using module .\Console.psm1
using module .\Console\Internal.psm1
using module .\Utilities.psm1

# downloadhelper
class DownloadHelper {
  [string]$Id
  hidden [pscustomobject] $DownloadOptions
  DownloadHelper() {
    $this.Id = [Guid]::NewGuid().Guid.replace('-', '').SubString(0, 20)
    $this.PsObject.Properties.Add([PSScriptProperty]::new('Data', [scriptblock]::Create("`$e = Get-Event -SourceIdentifier $($this.Id) -ea Ignore; if (`$e) { return `$e[-1].SourceEventArgs }; return `$null")))
    $Options = [pscustomobject]@{
      ProgressMessage = [string]::Empty
      RetryTimeout    = 1000
      Headers         = @{}
      Proxy           = $null
      Force           = $false
    }
    $Options.PsObject.Properties.Add([PSScriptProperty]::new('ProgressBarLength', [scriptblock]::Create("return [int]([ConsoleWriter]::get_ConsoleWidth() * 0.7)")))
    $Options.PsObject.Properties.Add([PSScriptProperty]::new('ShowProgress', [scriptblock]::Create("return (`$global:ProgressPreference -eq 'Continue')")))
    $this.DownloadOptions = $Options
  }
  [string] GetfileSize([long]$Bytes) {
    $sizestr = switch ($bytes) {
      { $bytes -lt 1MB } { "$([Math]::Round($bytes / 1KB, 2)) KB"; break }
      { $bytes -lt 1GB } { "$([Math]::Round($bytes / 1MB, 2)) MB"; break }
      { $bytes -lt 1TB } { "$([Math]::Round($bytes / 1GB, 2)) GB"; break }
      default { "$([Math]::Round($bytes / 1TB, 2)) TB" }
    }
    return [string]$sizestr
  }
  [string] GetSizeProgress() {
    if ($null -eq $this.Data) {
      return [string]::Empty
    }
    return $this.GetSizeProgress($this.Data.BytesReceived, $this.Data.TotalBytesToReceive)
  }
  [string] GetSizeProgress($r, $t) {
    return "{0} / {1}" -f $($this.GetfileSize($r)), $($this.GetfileSize($t))
  }

  [IO.FileInfo] DownloadFile([uri]$url) {
    $randomSuffix = [Guid]::NewGuid().Guid.subString(15).replace('-', [string]::Join('', (0..9 | Get-Random -Count 1)))
    return $this.DownloadFile($url, "$(Split-Path $url.AbsolutePath -Leaf)_$randomSuffix")
  }

  [IO.FileInfo] DownloadFile([uri]$url, [string]$outFile) {
    return $this.DownloadFile($url, $outFile, $false)
  }

  [IO.FileInfo] DownloadFile([uri]$url, [string]$outFile, [bool]$Force) {
    [ValidateNotNullOrEmpty()][uri]$url = $url
    [ValidateNotNullOrEmpty()][string]$outFile = $outFile
    $name = Split-Path $url -Leaf
    $outPath = [IO.Path]::GetFullPath($outFile)
    if ([System.IO.Directory]::Exists($outFile)) {
      if (!$Force) { throw [ArgumentException]::new("Please provide valid file path, not a directory.", "outFile") }
      $outPath = Join-Path -Path $outFile -ChildPath $name
    }
    $Outdir = [IO.Path]::GetDirectoryName($outPath)
    if (![System.IO.Directory]::Exists($Outdir)) { [void][System.IO.Directory]::CreateDirectory($Outdir) }
    if ([IO.File]::Exists($outPath)) {
      if (!$Force) { throw "$outFile already exists" }
      Remove-Item $outPath -Force -ErrorAction Ignore | Out-Null
    }

    $Progress_Msg = $this.DownloadOptions.ProgressMessage
    $show_progress = $this.DownloadOptions.ShowProgress
    if ([string]::IsNullOrWhiteSpace($Progress_Msg)) { $Progress_Msg = "Downloading $name" }

    # --- Runtime type resolution (avoids parse-time forward-ref failures) ---
    $consoleType = [type]'AnsiConsole'
    $progressType = [type]'Progress'
    $statusType = [type]'Status'
    $settingsType = [type]'ProgressTaskSettings'
    $descColType = [type]'TaskDescriptionColumn'
    $progressBarColType = [type]'ProgressBarColumn'
    $pctColType = [type]'PercentageColumn'
    $spinnerColType = [type]'SpinnerColumn'

    $console = $consoleType::Console
    $console.MarkupLine("[steelblue1][+][/] [steelblue1]$Progress_Msg[/]")

    $stream = $null
    $fileStream = $null
    $response = $null
    $totalBytesReceived = 0
    $buffer = New-Object byte[] 8192
    $fileStream = [System.IO.FileStream]::new($outPath, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)

    try {
      $request = [System.Net.HttpWebRequest]::Create($url)
      $request.UserAgent = "Mozilla/5.0"
      $capturedRequest = $request
      $capturedResponseRef = [ref]$null

      if ($show_progress) {
        # ── Phase 1: Spinner while waiting for the HTTP response ──────────────
        $status = $statusType::new($console.GetWriter())
        $status.Spinner = ""
        $status.RefreshRateMs = 80

        $status.Start('Connecting…', {
            param($sctx)
            $responseTask = $capturedRequest.GetResponseAsync()
            while (!$responseTask.IsCompleted) {
              [System.Threading.Thread]::Sleep(50)
            }
            if ($responseTask.IsFaulted) { throw $responseTask.Exception.InnerException }
            $capturedResponseRef.Value = $responseTask.Result
            $sctx.Complete()
          }
        )

        $response = $capturedResponseRef.Value
        $contentLength = $response.ContentLength
        $stream = $response.GetResponseStream()

        # ── Phase 2: Deterministic progress bar for the download body ─────────
        $capturedBuffer = $buffer
        $capturedFileStream = $fileStream
        $capturedSettings = $settingsType::new()
        $capturedSettings.MaxValue = 100
        $capturedSettings.IsIndeterminate = ($contentLength -le 0)

        $capturedTotal = [ref]$totalBytesReceived
        $capturedStream = $stream
        $capturedLen = $contentLength
        $capturedMsg = $Progress_Msg

        $progress = $progressType::new($console)
        $progress.RefreshRateMs = 80
        $progress.Columns.Clear()
        $progress.Columns.Add($descColType::new($progress))
        $progress.Columns.Add($progressBarColType::new($progress))
        $progress.Columns.Add($pctColType::new($progress))
        $progress.Columns.Add($spinnerColType::new($progress))

        $progress.Start([System.Action[object]] {
            param([object]$ctx)
            $task = $ctx.AddTask("[lightyellow3]$capturedMsg[/]", $capturedSettings)

            while ($true) {
              $bytesRead = $capturedStream.Read($capturedBuffer, 0, $capturedBuffer.Length)
              if ($bytesRead -le 0) { break }
              $capturedTotal.Value += $bytesRead
              $capturedFileStream.Write($capturedBuffer, 0, $bytesRead)

              if ($capturedLen -gt 0) {
                $pct = [Math]::Min(100, [int][Math]::Round($capturedTotal.Value / $capturedLen * 100))
                $task.SetValue($pct)
              } else {
                $task.SetValue(0) # indeterminate: just tick
              }
            }
            if ($capturedLen -le 0) { $task._state.IsIndeterminate = $false }
            $task.Complete()
          }
        )
        $totalBytesReceived = $capturedTotal.Value
      } else {
        # No progress display — drain the stream directly
        $response = $request.GetResponse()
        $contentLength = $response.ContentLength
        $stream = $response.GetResponseStream()
        while ($true) {
          $bytesRead = $stream.Read($buffer, 0, $buffer.Length)
          if ($bytesRead -le 0) { break }
          $totalBytesReceived += $bytesRead
          $fileStream.Write($buffer, 0, $bytesRead)
        }
      }
    } catch {
      throw $_
    } finally {
      try { if ($stream) { $stream.Close() } } catch { $null }
      try { if ($fileStream) { $fileStream.Close() } } catch { $null }
      try { if ($response) { $response.Dispose() } } catch { $null }
    }
    return (Get-Item $outPath)
  }
  [IO.FileInfo] DownloadFileAsync([uri]$Uri, [string]$OutFile, $dlEvent, [bool]$verbose) {
    <#
    .SYNOPSIS
      Downloads a file synchronously while providing a live progress bar.
      Phase 1 — spinner while waiting for HTTP response headers.
      Phase 2 — deterministic progress bar while streaming the body.
    #>

    $show_progress = $this.DownloadOptions.ShowProgress

    # --- Runtime type resolution (avoids parse-time forward-ref failures) ---
    $consoleType = [type]'AnsiConsole'
    $progressType = [type]'Progress'
    $statusType = [type]'Status'
    $settingsType = [type]'ProgressTaskSettings'
    $descColType = [type]'TaskDescriptionColumn'
    $progressBarColType = [type]'ProgressBarColumn'
    $pctColType = [type]'PercentageColumn'
    $spinnerColType = [type]'SpinnerColumn'


    $console = $consoleType::Console
    $console.use_animation($false)

    if ($verbose) {
      $console.MarkupLine(" [steelblue1]Attempting to download '[/][white]$Uri[/][steelblue1]' ...[/]")
    }

    $stream = $null
    $fileStream = $null
    $response = $null
    $contentLength = 0
    $totalBytesReceived = 0
    $outPath = [IO.Path]::GetFullPath($OutFile)
    $Outdir = [IO.Path]::GetDirectoryName($outPath)
    if (![System.IO.Directory]::Exists($Outdir)) { [void][System.IO.Directory]::CreateDirectory($Outdir) }

    try {
      $request = [System.Net.HttpWebRequest]::Create($Uri)
      $request.UserAgent = "Mozilla/5.0"
      $buffer = New-Object byte[] 8192
      $fileStream = [System.IO.FileStream]::new($outPath, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
      $capturedRequest = $request
      $capturedResponseRef = [ref]$null

      if ($show_progress) {
        # ── Phase 1: Spinner while waiting for the HTTP response ──────────────
        $status = $statusType::new($console.GetWriter())
        $status.Spinner = ""
        $status.RefreshRateMs = 80

        $status.Start('Connecting…', {
            param($sctx)
            $responseTask = $capturedRequest.GetResponseAsync()
            while (!$responseTask.IsCompleted) {
              [System.Threading.Thread]::Sleep(50)
            }
            if ($responseTask.IsFaulted) { throw $responseTask.Exception.InnerException }
            $capturedResponseRef.Value = $responseTask.Result
            $sctx.Complete()
          }
        )

        $response = $capturedResponseRef.Value
        $contentLength = $response.ContentLength
        $stream = $response.GetResponseStream()

        # ── Phase 2: Deterministic progress bar for the download body ─────────
        $capturedBuffer = $buffer
        $capturedFileStream = $fileStream
        $capturedDlEvent = $dlEvent
        $capturedSettings = $settingsType::new()
        $capturedSettings.MaxValue = 100
        $capturedSettings.IsIndeterminate = ($contentLength -le 0)

        $capturedTotal = [ref]$totalBytesReceived
        $capturedStream = $stream
        $capturedLen = $contentLength

        $progress = $progressType::new($console)
        $progress.RefreshRateMs = 80
        $progress.Columns.Clear()
        $progress.Columns.Add($descColType::new($progress))
        $progress.Columns.Add($progressBarColType::new($progress))
        $progress.Columns.Add($pctColType::new($progress))
        $progress.Columns.Add($spinnerColType::new($progress))

        $progress.Start([System.Action[object]] {
            param([object]$ctx)
            $pbTask = $ctx.AddTask('[lightyellow3]Downloading[/]', $capturedSettings)

            while ($true) {
              $bytesRead = $capturedStream.Read($capturedBuffer, 0, $capturedBuffer.Length)
              if ($bytesRead -le 0) { break }
              $capturedTotal.Value += $bytesRead
              $capturedFileStream.Write($capturedBuffer, 0, $bytesRead)

              if ($capturedLen -gt 0) {
                $pct = [Math]::Min(100, [int][Math]::Round($capturedTotal.Value / $capturedLen * 100))
                $received = $capturedDlEvent.GetSizeProgress($capturedTotal.Value, $capturedLen)
                $pbTask.SetDescription("[lightyellow3]Downloading[/] [grey]$received[/]")
                $pbTask.SetValue($pct)
              } else {
                $received = $capturedDlEvent.GetSizeProgress($capturedTotal.Value, $capturedTotal.Value)
                $pbTask.SetDescription("[lightyellow3]Downloading[/] [grey]$received[/]")
                $pbTask.SetValue(0)
              }
            }
            if ($capturedLen -le 0) { $pbTask._state.IsIndeterminate = $false }
            $pbTask.Complete()
          }
        )
        $totalBytesReceived = $capturedTotal.Value
      } else {
        # No progress UI
        $response = $request.GetResponse()
        $contentLength = $response.ContentLength
        $stream = $response.GetResponseStream()
        while ($true) {
          $bytesRead = $stream.Read($buffer, 0, $buffer.Length)
          if ($bytesRead -le 0) { break }
          $totalBytesReceived += $bytesRead
          $fileStream.Write($buffer, 0, $bytesRead)
        }
      }

      if ($show_progress) {
        if ($contentLength -le 0 -or $totalBytesReceived -ge $contentLength) {
          $console.MarkupLine(" [green]✓ Downloaded $($dlEvent.GetSizeProgress($totalBytesReceived, $totalBytesReceived))[/]")
        } else {
          $console.MarkupLine(" [red]✗ Download failed: $($Uri.AbsoluteUri)[/]")
        }
      }
    } catch {
      $escapedMsg = $_.Exception.GetBaseException().Message -replace '\[', '[[' -replace '\]', ']]'
      $console.MarkupLine(" [red]$escapedMsg[/]")
      throw $_
    } finally {
      if ($verbose -and [IO.File]::Exists($outPath)) {
        $console.MarkupLine(" [steelblue1]OutPath: '[/][white]$outPath[/][steelblue1]'[/]")
      }
      try { if ($stream) { $stream.Close() } } catch { $null }
      try { if ($fileStream) { $fileStream.Close() } } catch { $null }
      try { if ($response) { $response.Dispose() } } catch { $null }
    }

    if ([IO.File]::Exists($outPath)) {
      return Get-Item $outPath
    } else {
      return [IO.FileInfo]::new($outPath)
    }
  }
}