Private/Translation/Write-TranslationSummary.ps1

function Write-TranslationSummary {
    <#
    .SYNOPSIS
        Renders a translation run summary as a readable console block.
    .DESCRIPTION
        Deliberately writes to the host rather than to the pipeline. This is a
        human-facing end-of-run report; the machine-readable form of exactly the same
        data is returned on the translated file's .TranslationSummary property, so
        nothing here is the only copy of anything. Emitting it to the output stream
        instead would corrupt the SubtitleFile that Invoke-SubtitleTranslation returns.
 
        Box-drawing characters are used only when the console can actually render
        them. A Windows PowerShell 5.1 console on a legacy OEM code page turns them
        into mojibake, so the ASCII fallback is not cosmetic - it is the difference
        between a summary and a screenful of question marks.
    .PARAMETER Summary
        A SubtitleTools.TranslationSummary object built by Invoke-SubtitleTranslation.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'End-of-run report for a human. The same data is returned on the pipeline object as .TranslationSummary; writing it to the output stream would corrupt the returned SubtitleFile.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [PSCustomObject] $Summary
    )

    $useUnicode = $false
    try { $useUnicode = ([Console]::OutputEncoding.CodePage -eq 65001) } catch { $useUnicode = $false }

    if ($useUnicode) {
        $ruleChar = [string][char]0x2500  # box drawing horizontal
        $sep      = [string][char]0x00B7  # middle dot
        $arrow    = [string][char]0x2192  # rightwards arrow
    } else {
        $ruleChar = '-'
        $sep      = '|'
        $arrow    = '->'
    }

    $width = 68
    $rule  = $ruleChar * $width

    # 12400 -> '12.4k'. Raw counts stay on the summary object; this is display only.
    $compact = {
        param([long] $Count)
        if ($Count -ge 1000000) { '{0:N1}M' -f ($Count / 1000000) }
        elseif ($Count -ge 1000) { '{0:N1}k' -f ($Count / 1000) }
        else { [string] $Count }
    }

    $duration = {
        param([timespan] $Span)
        # Floor, not [int]: [int]3.68 rounds up to 4, which would report a 3m41s run as
        # "4m 41s".
        if ($Span.TotalHours -ge 1) { '{0}h {1:00}m {2:00}s' -f [Math]::Floor($Span.TotalHours), $Span.Minutes, $Span.Seconds }
        elseif ($Span.TotalMinutes -ge 1) { '{0}m {1:00}s' -f [Math]::Floor($Span.TotalMinutes), $Span.Seconds }
        else { '{0:N1}s' -f $Span.TotalSeconds }
    }

    $plural = {
        param([int] $Count, [string] $Singular, [string] $PluralForm)
        if ($Count -eq 1) { "$Count $Singular" } else { "$Count $PluralForm" }
    }

    $row = {
        param([string] $Label, [string] $Value, [string] $Color)
        if (-not $Value) { return }
        Write-Host (' {0,-10}' -f $Label) -NoNewline -ForegroundColor DarkGray
        if ($Color) { Write-Host $Value -ForegroundColor $Color } else { Write-Host $Value }
    }

    Write-Host ''
    Write-Host " $rule" -ForegroundColor DarkGray
    Write-Host ' Translation complete' -ForegroundColor Cyan
    Write-Host " $rule" -ForegroundColor DarkGray

    $sourceName = if ($Summary.SourcePath) { Split-Path $Summary.SourcePath -Leaf } else { '(in-memory subtitle)' }
    & $row 'Source' ("{0} ({1} {2} {3} {2} {4})" -f
        $sourceName, $Summary.Format, $sep, $Summary.Encoding, (& $plural $Summary.Entries 'entry' 'entries'))
    & $row 'Output' $Summary.OutputPath

    & $row 'Language' ("{0} {1} {2}" -f $Summary.SourceLanguage, $arrow, $Summary.TargetLanguage)
    & $row 'Provider' ("{0} {1} {2}" -f $Summary.Provider, $sep, $Summary.Model)

    if ($Summary.Primed -or $Summary.ContentTitle) {
        $ctxParts = @()
        if ($Summary.ContentType -and $Summary.ContentType -ne 'unknown') { $ctxParts += $Summary.ContentType }
        if ($Summary.ContentTitle -and $Summary.ContentTitle -ne 'UNKNOWN') { $ctxParts += '"{0}"' -f $Summary.ContentTitle }
        if ($Summary.Tone) { $ctxParts += "$($Summary.Tone) tone" }
        $ctxText = $ctxParts -join " $sep "
        if ($Summary.Primed -and $ctxText) { $ctxText += ' (primed)' }
        & $row 'Context' $ctxText
    }

    if ($Summary.GlossaryTerms -gt 0) {
        & $row 'Glossary' (& $plural $Summary.GlossaryTerms 'term' 'terms')
    }

    Write-Host ''

    $entryParts = @("$($Summary.TranslatedEntries) translated")
    if ($Summary.CachedEntries -gt 0)     { $entryParts += "$($Summary.CachedEntries) from cache" }
    if ($Summary.UnresolvedEntries -gt 0) { $entryParts += "$($Summary.UnresolvedEntries) unresolved" }
    $entryColor = if ($Summary.UnresolvedEntries -gt 0) { 'Yellow' } else { 'Green' }
    & $row 'Entries' ($entryParts -join " $sep ") $entryColor

    $reqParts = @(
        (& $plural $Summary.Batches 'batch' 'batches')
        (& $plural $Summary.ApiCalls 'API call' 'API calls')
    )
    if ($Summary.Retries -gt 0)          { $reqParts += (& $plural $Summary.Retries 'retry' 'retries') }
    if ($Summary.TruncatedBatches -gt 0) { $reqParts += "$($Summary.TruncatedBatches) truncated" }
    & $row 'Requests' ($reqParts -join " $sep ")

    & $row 'Tokens' ("{0} in {1} {2} out {1} {3} total" -f
        (& $compact $Summary.InputTokens), $sep,
        (& $compact $Summary.OutputTokens),
        (& $compact $Summary.TotalTokens))

    $timeText = & $duration $Summary.Duration
    if ($Summary.EntriesPerMinute -gt 0) { $timeText += " $sep $($Summary.EntriesPerMinute) entries/min" }
    & $row 'Elapsed' $timeText

    & $row 'Text' ("{0} {1} {2} chars" -f
        ('{0:N0}' -f $Summary.SourceCharacters), $arrow, ('{0:N0}' -f $Summary.OutputCharacters))

    Write-Host " $rule" -ForegroundColor DarkGray
    Write-Host ' Full details: $result.TranslationSummary' -ForegroundColor DarkGray
    Write-Host ''
}