src/UI/Screens/GraphScreen.ps1

# GraphScreen — Render del git log --graph con scroll H/V (réplica de
# BranchGraphView del v2).
#
# Read-only — solo muestra. Sin acciones que muten estado.
# Bindings:
# ↑↓ / jk scroll 1 línea vertical
# PgUp/PgDn scroll página
# g / Home ir al inicio
# G / End ir al final
# ←→ / hl scroll horizontal 8 chars
# ESC / q volver

class GraphScreen {
    [object] $Theme
    [object] $Renderer
    [object] $Primitives
    [object] $Frame
    [object] $AppHeader
    [object] $StatusBar
    [object] $Git
    [object] $I18n = $null   # Inyectable post-construct.

    [object]   $Repo
    [string[]] $Lines = @()
    [int]      $ScrollY = 0
    [int]      $ScrollX = 0
    [int]      $HScrollStep = 8
    [bool]     $QuitRequested = $false
    [bool]     $UseAltBuffer = $true

    [string]   $StatusMessage = ''
    [string]   $StatusKind = ''

    GraphScreen($theme, $renderer, $primitives, $frame, $appHeader, $statusBar, $git) {
        $this.Theme       = $theme
        $this.Renderer    = $renderer
        $this.Primitives  = $primitives
        $this.Frame       = $frame
        $this.AppHeader   = $appHeader
        $this.StatusBar   = $statusBar
        $this.Git         = $git
    }

    [void] Open([object]$repo) {
        if ($null -eq $repo) { throw "GraphScreen.Open: repo no puede ser null." }
        $this.Repo = $repo
        $this.ScrollY = 0
        $this.ScrollX = 0
        $this.StatusMessage = ''
        $this.Lines = @()

        if ([Console]::IsInputRedirected) {
            $this.Lines = @($this.Git.GetGraph($repo.Path, 200))
            $rendered = $this.BuildLines()
            foreach ($l in $rendered) { Write-Host $l }
            return
        }

        # Pre-frame con 'cargando…' antes del git call (que puede tomar 100-300ms
        # en repos con history grande).
        $errOut = [Console]::Error
        $this.SetStatus('cargando graph…', 'ok')
        $this.Render($errOut)

        # Carga real
        $this.Lines = @($this.Git.GetGraph($repo.Path, 200))
        if ($this.Lines.Count -eq 0) {
            $this.SetStatus('no hay commits o el path no es git', 'error')
        } else {
            $this.SetStatus('', '')
        }
        $this.Render($errOut)

        try {
            while ($true) {
                $key = [Console]::ReadKey($true)
                $exit = $this.HandleKey($key)
                if ($exit -eq 'back') { return }
                if ($exit -eq 'quit') { $this.QuitRequested = $true; return }
                $this.Render($errOut)
            }
        }
        finally { }
    }

    hidden [void] Render([object]$errOut) {
        if ([Console]::IsInputRedirected) { return }
        # `$rendered` en lugar de `$lines`/`$frame` — chocan con properties.
        $rendered = $this.BuildLines()
        $framePayload = [AnsiService]::BeginSync() + [AnsiService]::MoveTo(1, 1) + ($rendered -join "`e[K`n") + "`e[K`e[J" + [AnsiService]::EndSync()
        $errOut.Write($framePayload)
        $errOut.Flush()
    }

    hidden [string] HandleKey([System.ConsoleKeyInfo]$key) {
        $k = $key.Key
        $c = $key.KeyChar

        if ($c -eq 'q' -or $c -eq 'Q') { return 'quit' }
        if ($k -eq 'Escape' -or $c -eq 'h' -and $false) { return 'back' }  # h queda para horizontal scroll
        if ($k -eq 'Escape') { return 'back' }

        $contentH = $this.ContentHeight()
        $maxY = [Math]::Max(0, $this.Lines.Count - $contentH)
        $maxX = $this.MaxScrollX()

        if ($k -eq 'UpArrow' -or $c -eq 'k') { $this.ScrollY = [Math]::Max(0, $this.ScrollY - 1); return '' }
        if ($k -eq 'DownArrow' -or $c -eq 'j') { $this.ScrollY = [Math]::Min($maxY, $this.ScrollY + 1); return '' }
        if ($k -eq 'PageUp')   { $this.ScrollY = [Math]::Max(0, $this.ScrollY - ($contentH - 1)); return '' }
        if ($k -eq 'PageDown') { $this.ScrollY = [Math]::Min($maxY, $this.ScrollY + ($contentH - 1)); return '' }
        if ($k -eq 'Home' -or $c -eq 'g') { $this.ScrollY = 0; return '' }
        if ($k -eq 'End'  -or $c -eq 'G') { $this.ScrollY = $maxY; return '' }
        if ($k -eq 'LeftArrow') { $this.ScrollX = [Math]::Max(0, $this.ScrollX - $this.HScrollStep); return '' }
        if ($k -eq 'RightArrow') { $this.ScrollX = [Math]::Min($maxX, $this.ScrollX + $this.HScrollStep); return '' }

        return ''
    }

    hidden [void] SetStatus([string]$msg, [string]$kind) {
        $this.StatusMessage = $msg
        $this.StatusKind = $kind
    }

    # Cuántas líneas de contenido caben. Chrome = title + appheader + hr + status
    # + sectionTitle + hr + 2hr (statusbar 2 líneas) = 8.
    hidden [int] ContentHeight() {
        return [Math]::Max(3, [Console]::WindowHeight - 8)
    }

    # Longitud máxima de línea para clamp del scroll horizontal.
    hidden [int] MaxScrollX() {
        $maxLen = 0
        foreach ($l in $this.Lines) {
            if ($l.Length -gt $maxLen) { $maxLen = $l.Length }
        }
        $width = $this.Renderer.Width()
        return [Math]::Max(0, $maxLen - $width + 4)
    }

    [string[]] BuildLines() {
        $reset = [AnsiService]::Reset
        $r = $this.Renderer
        # `$out` en lugar de `$lines` — choca con property $this.Lines (PS class CI).
        $out = [System.Collections.Generic.List[string]]::new()

        # Title
        $repoName = if ($this.Repo) { $this.Repo.Name } else { '—' }
        $out.Add($this.Frame.TitleBar('repo-nav', "Graph · $repoName", '1.0.0'))

        # AppHeader
        $bc = [BreadcrumbBuilder]::Build($this.Repo.Path)
        $segs = @($bc.Segs) + @($bc.Current) + @('Branches')
        $out.Add($this.AppHeader.Render($segs, 'Graph', @()))
        $out.Add($r.HRule())

        # Status / prompt line
        if ($this.StatusMessage) {
            $color = if ($this.StatusKind -eq 'error') { 'gitConflict' } else { 'gitClean' }
            $out.Add(' ' + $this.Theme.Fg($color) + $this.StatusMessage + $reset)
        } else {
            $out.Add('')
        }

        # Section title con info de scroll
        $totalLines = $this.Lines.Count
        $contentH = $this.ContentHeight()
        $titleText = ' Commits · ' + $totalLines
        if ($totalLines -gt $contentH) {
            $start = $this.ScrollY + 1
            $end = [Math]::Min($totalLines, $this.ScrollY + $contentH)
            $titleText += " · viendo $start–$end"
        }
        if ($this.ScrollX -gt 0) {
            $titleText += " · ←$($this.ScrollX) chars"
        }
        $out.Add($this.Theme.Fg('fg2') + $titleText + $reset)
        $out.Add($r.HRule())

        # Slice vertical + horizontal
        $sliceStart = $this.ScrollY
        $sliceEnd = [Math]::Min($totalLines, $this.ScrollY + $contentH)
        if ($totalLines -eq 0) {
            $out.Add($this.Theme.Fg('fg3') + ' (sin commits)' + $reset)
        } else {
            $width = $r.Width()
            $contentWidth = [Math]::Max(10, $width - 2)
            for ($i = $sliceStart; $i -lt $sliceEnd; $i++) {
                $line = $this.Lines[$i]
                # Slice horizontal con safe substring
                $line = if ($this.ScrollX -lt $line.Length) {
                    $line.Substring($this.ScrollX)
                } else {
                    ''
                }
                # Trunca al ancho disponible para evitar wrap
                if ($line.Length -gt $contentWidth) {
                    $line = $line.Substring(0, $contentWidth)
                }
                $out.Add(' ' + $this.Theme.Fg('fg1') + $line + $reset)
            }
        }

        # StatusBar
        $tHelper = if ($null -ne $this.I18n) { $this.I18n } else { [I18nService]::new('es') }
        $hints = @(
            @{ k = '↑↓';        label = 'Scroll' }
            @{ k = '←→';        label = 'Horiz' }
            @{ k = 'PgUp/PgDn'; label = 'Page' }
            @{ k = 'g/G';       label = 'Top/End' }
            @{ k = 'Esc';       label = $tHelper.T('hint.back') }
            @{ k = 'Q';         label = $tHelper.T('hint.quit') }
        )
        $out.Add($r.HRule())
        return @($out.ToArray()) + $this.StatusBar.RenderAnchored($hints, '', $out.Count)
    }
}