src/UI/Renderer.ps1

# Renderer — Helpers de bajo nivel para escribir al host.
# Wrapper sobre AnsiService + utilidades de medición y padding.

class Renderer {
    [object] $Theme   # ThemeService (untyped por ps-class-scope)

    Renderer($themeService) {
        $this.Theme = $themeService
    }

    # Ancho visible del host. Override con env $RNCOLUMNS para testing/debug.
    # Fallback 120 si no se puede detectar.
    [int] Width() {
        $override = [Environment]::GetEnvironmentVariable('RNCOLUMNS')
        if ($override) {
            $n = 0
            if ([int]::TryParse($override, [ref]$n) -and $n -gt 0) { return $n }
        }
        try {
            $w = $global:Host.UI.RawUI.WindowSize.Width
            if ($w -gt 0) { return $w }
        } catch {
            # Algunos hosts (CI sin tty, redirected stdin) no exponen RawUI — usamos default.
            $null = $_
        }
        return 120
    }

    [int] Height() {
        try {
            $h = $global:Host.UI.RawUI.WindowSize.Height
            if ($h -gt 0) { return $h }
        } catch {
            $null = $_
        }
        return 38
    }

    # Largo VISIBLE de un string (descarta secuencias ANSI ESC[…m / ESC[…H, etc.)
    static [int] VisibleLength([string]$s) {
        if ([string]::IsNullOrEmpty($s)) { return 0 }
        # Strip ESC[ ... letter (CSI sequences)
        $stripped = $s -replace "`e\[[0-9;?]*[a-zA-Z]", ''
        return $stripped.Length
    }

    # Trunca preservando ANSI escapes. Cuenta sólo chars visibles.
    static [string] TruncateAnsi([string]$s, [int]$visibleMax) {
        if ([string]::IsNullOrEmpty($s) -or $visibleMax -le 0) { return '' }
        $sb = [System.Text.StringBuilder]::new()
        $i = 0
        $visible = 0
        $len = $s.Length
        while ($i -lt $len -and $visible -lt $visibleMax) {
            $c = $s[$i]
            if ($c -eq [char]27 -and ($i + 1) -lt $len -and $s[$i + 1] -eq '[') {
                # CSI: ESC [ params letter — copiar entero, no cuenta como visible
                $j = $i + 2
                while ($j -lt $len -and -not [char]::IsLetter($s[$j])) { $j++ }
                if ($j -lt $len) { $j++ }
                [void]$sb.Append($s.Substring($i, $j - $i))
                $i = $j
            } else {
                [void]$sb.Append($c)
                $visible++
                $i++
            }
        }
        return $sb.ToString()
    }

    # Padding a derecha hasta $width (visibles). Truncate ANSI-safe con ellipsis si excede.
    # Garantiza ancho exacto y emite reset al final para que el siguiente token no herede color.
    static [string] PadRight([string]$s, [int]$width) {
        $vlen = [Renderer]::VisibleLength($s)
        if ($vlen -le $width) {
            return $s + [AnsiService]::Reset + (' ' * ($width - $vlen))
        }
        $room = [Math]::Max(0, $width - 1)   # 1 char para …
        $truncated = [Renderer]::TruncateAnsi($s, $room)
        $reset = [AnsiService]::Reset
        $vNow = [Renderer]::VisibleLength($truncated) + 1   # + ellipsis
        $pad = if ($vNow -lt $width) { ' ' * ($width - $vNow) } else { '' }
        return $truncated + '…' + $reset + $pad
    }

    static [string] PadLeft([string]$s, [int]$width) {
        $vlen = [Renderer]::VisibleLength($s)
        if ($vlen -le $width) {
            return (' ' * ($width - $vlen)) + $s + [AnsiService]::Reset
        }
        # Trunca por izquierda preservando escapes — uso TruncateAnsi (que trunca por derecha)
        # como fallback razonable.
        $truncated = [Renderer]::TruncateAnsi($s, [Math]::Max(0, $width - 1))
        return $truncated + '…' + [AnsiService]::Reset
    }

    # Humaniza un delta de tiempo: "hace 5 s", "hace 12 min", "hace 3 h",
    # "hace 2 d", "nunca". Pensado para hints sutiles de "fetch hace X" en
    # filas de la lista. Mantiene los strings cortos para no romper layout.
    static [string] HumanizeAgo([object]$utcDateTime) {
        if ($null -eq $utcDateTime) { return 'nunca' }
        $now = [DateTime]::UtcNow
        $delta = $now - [DateTime]$utcDateTime
        $totalSec = [int]$delta.TotalSeconds
        if ($totalSec -lt 0)        { return 'recién' }
        if ($totalSec -lt 60)       { return 'hace ' + $totalSec + ' s' }
        $totalMin = [int][Math]::Floor($delta.TotalMinutes)
        if ($totalMin -lt 60)       { return 'hace ' + $totalMin + ' min' }
        $totalHr = [int][Math]::Floor($delta.TotalHours)
        if ($totalHr -lt 24)        { return 'hace ' + $totalHr + ' h' }
        $totalDay = [int][Math]::Floor($delta.TotalDays)
        if ($totalDay -lt 30)       { return 'hace ' + $totalDay + ' d' }
        $totalMonth = [int][Math]::Floor($delta.TotalDays / 30)
        if ($totalMonth -lt 12)     { return 'hace ' + $totalMonth + ' mes' }
        $totalYear = [int][Math]::Floor($delta.TotalDays / 365)
        return 'hace ' + $totalYear + ' año'
    }

    # Horizontal rule del ancho actual con color line.
    [string] HRule() {
        $color = $this.Theme.Fg('line')
        $reset = [AnsiService]::Reset
        return $color + ('─' * $this.Width()) + $reset
    }

    [string] HRuleStrong() {
        $color = $this.Theme.Fg('lineStrong')
        $reset = [AnsiService]::Reset
        return $color + ('─' * $this.Width()) + $reset
    }

    # Helper: aplicar color FG a un texto y resetear.
    [string] Colored([string]$themeKey, [string]$text) {
        return $this.Theme.Fg($themeKey) + $text + [AnsiService]::Reset
    }

    # Escribe líneas al host (preserva ANSI escapes).
    [void] Write([string]$line) {
        Write-Host $line
    }
}