src/UI/Components/FilteredListPicker.ps1

# FilteredListPicker — Componente reusable para listas filtrables inline.
#
# Antes de este componente, 6 screens replicaron el mismo patrón con variaciones
# cosméticas (~570 LoC repetidos). Cada flow nuevo lo replicaba (audit D-1).
# Este componente unifica:
# - State: $Items, $SelectedIndex, $Filter
# - Filter logic: substring case-insensitive sobre LabelFn (default) o MatchFn
# custom. Items 'sticky' (siempre visibles aunque no matcheen) via AlwaysShowFn.
# - Key handling: ↑↓ navega con wrap, Backspace pop char, char printable append.
# Devuelve 'enter' / 'escape' / 'continue' para que la screen decida la acción.
# - Render: filter prompt + title + lista filtrada con slice del viewport.
#
# Cada screen instancia uno (o varios) y le pasa scriptblocks para customizar
# label, match, render. Las teclas Enter/Escape se delegan a la screen.

class FilteredListPicker {
    # ─── Data ───────────────────────────────────────────────────────────────
    [object[]] $Items = @()
    [int]      $SelectedIndex = 0
    [string]   $Filter = ''

    # ─── Display ────────────────────────────────────────────────────────────
    [string]   $Title = 'Filtrar'           # label del prompt
    [string]   $EmptyMessage = '(sin items)'
    [string]   $NoMatchMessage = '(sin matches)'

    # ─── Customization (scriptblocks) ───────────────────────────────────────
    # (item) → string. Plain text para el filter default. Default: $_.ToString()
    [scriptblock] $LabelFn

    # (item, needleLower) → bool. Si null, usa LabelFn + ToLower + Contains.
    [scriptblock] $MatchFn

    # (item) → bool. Items que se muestran siempre, aun sin matchear el filter.
    # Útil para parent '..' del browser.
    [scriptblock] $AlwaysShowFn

    # (item, isSelected, theme, reset) → string. Cómo renderear cada row.
    # Si null, usa " · {label}" / "▎ ▶ {label}" simple.
    [scriptblock] $RenderRowFn

    FilteredListPicker() { }

    # ─── Filter logic ───────────────────────────────────────────────────────

    # Default match: usa LabelFn -> ToLower -> Contains. Si LabelFn no se setea,
    # cae a $_.ToString(). $needleLower viene pre-lowercase.
    hidden [bool] DefaultMatch([object]$item, [string]$needleLower) {
        $label = if ($null -ne $this.LabelFn) {
            [string](& $this.LabelFn $item)
        } else {
            [string]$item
        }
        return $label.ToLowerInvariant().Contains($needleLower)
    }

    [object[]] FilteredItems() {
        if (-not $this.Filter) { return $this.Items }
        $needle = $this.Filter.ToLowerInvariant()
        # PS class case-insensitive: $matchFn chocaría con $this.MatchFn.
        $matcher = $this.MatchFn
        $stickyFn = $this.AlwaysShowFn

        $matched = @()
        foreach ($item in $this.Items) {
            $sticky = $false
            if ($null -ne $stickyFn) {
                $sticky = [bool](& $stickyFn $item)
            }
            if ($sticky) {
                $matched += $item
                continue
            }

            $hit = if ($null -ne $matcher) {
                [bool](& $matcher $item $needle)
            } else {
                $this.DefaultMatch($item, $needle)
            }
            if ($hit) { $matched += $item }
        }
        return $matched
    }

    [int] FilteredCount() {
        return $this.FilteredItems().Count
    }

    # Item actualmente bajo el cursor (de la lista filtrada).
    [object] Selected() {
        $visible = $this.FilteredItems()
        if ($visible.Count -eq 0) { return $null }
        if ($this.SelectedIndex -lt 0 -or $this.SelectedIndex -ge $visible.Count) {
            return $null
        }
        return $visible[$this.SelectedIndex]
    }

    # ─── Mutations ──────────────────────────────────────────────────────────

    [void] Reset() {
        $this.Filter = ''
        $this.SelectedIndex = 0
    }

    [void] SetItems([object[]]$items) {
        $this.Items = if ($null -eq $items) { @() } else { @($items) }
        # Clamp del SelectedIndex contra la nueva lista.
        $count = $this.FilteredItems().Count
        if ($this.SelectedIndex -ge $count) {
            $this.SelectedIndex = [Math]::Max(0, $count - 1)
        }
    }

    # ─── Key handling ───────────────────────────────────────────────────────
    # Devuelve:
    # 'enter' → user picked. Caller debe leer Selected() y actuar.
    # 'escape' → user canceló. Caller decide si volver, limpiar filter, etc.
    # 'continue' → key consumida (navegación, filter edit). Caller no hace nada.
    #
    # NO maneja Tab — eso es del flow de la screen (cycling de focus).
    [string] HandleKey([System.ConsoleKeyInfo]$key) {
        $k = $key.Key
        $c = $key.KeyChar
        $visible = $this.FilteredItems()

        if ($k -eq 'Enter')  { return 'enter' }
        if ($k -eq 'Escape') { return 'escape' }

        if ($k -eq 'UpArrow') {
            if ($visible.Count -gt 0) {
                $this.SelectedIndex = if ($this.SelectedIndex -le 0) { $visible.Count - 1 } else { $this.SelectedIndex - 1 }
            }
            return 'continue'
        }
        if ($k -eq 'DownArrow') {
            if ($visible.Count -gt 0) {
                $this.SelectedIndex = if ($this.SelectedIndex -ge $visible.Count - 1) { 0 } else { $this.SelectedIndex + 1 }
            }
            return 'continue'
        }
        if ($k -eq 'Home') { $this.SelectedIndex = 0; return 'continue' }
        if ($k -eq 'End')  { $this.SelectedIndex = [Math]::Max(0, $visible.Count - 1); return 'continue' }
        if ($k -eq 'PageUp')   { $this.SelectedIndex = [Math]::Max(0, $this.SelectedIndex - 10); return 'continue' }
        if ($k -eq 'PageDown') { $this.SelectedIndex = [Math]::Min([Math]::Max(0, $visible.Count - 1), $this.SelectedIndex + 10); return 'continue' }

        if ($k -eq 'Backspace') {
            if ($this.Filter.Length -gt 0) {
                $this.Filter = $this.Filter.Substring(0, $this.Filter.Length - 1)
                $this.SelectedIndex = 0
            }
            return 'continue'
        }

        if (-not [char]::IsControl($c)) {
            $this.Filter += $c
            $this.SelectedIndex = 0
            return 'continue'
        }

        return 'continue'
    }

    # ─── Render ─────────────────────────────────────────────────────────────

    # Construye las líneas del body. Devuelve array para que el caller las
    # appendee a su frame.
    # $maxVisible = cuántos items entran. Si la screen tiene poco espacio, baja.
    [string[]] BuildBody([object]$theme, [object]$renderer, [int]$maxVisible) {
        $reset = [AnsiService]::Reset
        $visible = $this.FilteredItems()
        $out = @()

        # Línea 1: filter prompt destacado
        $bg = $theme.Bg('acc')
        $fgInv = $theme.Fg('bg0')
        $pill = "${bg}${fgInv} $($this.Title) ${reset}"
        $cursor = $theme.Fg('acc') + '|' + $reset
        $buf = $theme.Fg('fg0') + $this.Filter + $reset
        $out += ' ' + $pill + ' ' + $buf + $cursor

        # Línea 2: section title con count
        $countText = ' Resultados · ' + $visible.Count
        if ($this.Filter -and $visible.Count -ne $this.Items.Count) {
            $countText += ' de ' + $this.Items.Count
        }
        if ($this.Filter) {
            $countText += " · /$($this.Filter)/"
        }
        $out += $theme.Fg('fg2') + $countText + $reset
        $out += $renderer.HRule()

        # Lista
        if ($visible.Count -eq 0) {
            $msg = if ($this.Filter) { ' ' + $this.NoMatchMessage } else { ' ' + $this.EmptyMessage }
            $out += $theme.Fg('fg3') + $msg + $reset
            return $out
        }

        # Slice del viewport — centrado en SelectedIndex.
        $effectiveMax = [Math]::Max(3, $maxVisible)
        $sliceStart = [Math]::Max(0, $this.SelectedIndex - [Math]::Floor($effectiveMax / 2))
        $sliceEnd = [Math]::Min($visible.Count, $sliceStart + $effectiveMax)
        # Re-clamp si el end llegó al final pero el start podría adelantarse.
        if ($sliceEnd -eq $visible.Count) {
            $sliceStart = [Math]::Max(0, $sliceEnd - $effectiveMax)
        }

        for ($i = $sliceStart; $i -lt $sliceEnd; $i++) {
            $item = $visible[$i]
            $sel = ($i -eq $this.SelectedIndex)
            if ($null -ne $this.RenderRowFn) {
                $out += [string](& $this.RenderRowFn $item $sel $theme $reset)
            } else {
                $marker = if ($sel) { $theme.Fg('acc') + '▎ ▶ ' + $reset } else { ' · ' }
                $label = if ($null -ne $this.LabelFn) { [string](& $this.LabelFn $item) } else { [string]$item }
                $color = if ($sel) { 'acc' } else { 'fg1' }
                $out += $marker + $theme.Fg($color) + $label + $reset
            }
        }
        return $out
    }
}