#!/usr/bin/env bash
# monitor_listening_ports — TUI ligera que refresca una tabla de sockets TCP en
# escucha (LISTEN) del equipo local, ordenada por tiempo de vida del proceso
# dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
#
# Funcion impura: lee estado del sistema (sockets via `ss`, uptime de procesos
# via `ps`). Sin --once corre en bucle infinito refrescando cada N segundos.
#
# Rendimiento: cada frame hace UNA sola llamada a `ss` y UNA sola a `ps`
# (mapa pid->etimes en memoria). El parseo de cada socket es bash puro y SIN
# command substitution por fila: las cadenas se construyen con `printf -v`
# (builtin, cero forks) y el formato de tiempo se devuelve en una variable
# global. El modo bucle usa doble-buffer ANSI (cursor home + clear-to-end) en
# lugar de limpiar la pantalla antes de computar, para que nunca se vea vacia
# entre refrescos.

# No usamos -e a proposito: una carrera donde un pid muere entre `ss` y `ps`
# no debe matar el bucle entero. -u y pipefail se mantienen para robustez.
set -uo pipefail

# Formatea segundos a texto humano legible y lo deja en la global _mlp_human.
# Se evita `$( )` (un fork por fila) usando una variable de retorno.
#   <1h   -> MM:SS        ej. 12:45
#   <1d   -> HH:MM:SS     ej. 03:12:45
#   >=1d  -> Nd HH:MM:SS  ej. 1d 03:12:45
_mlp_human=""
_mlp_fmt_etime() {
    local secs="$1"
    # Si no es un numero entero valido, devolver tal cual (ej. "?").
    if ! [[ "$secs" =~ ^[0-9]+$ ]]; then
        _mlp_human="$secs"
        return 0
    fi
    local days=$(( secs / 86400 ))
    local rem=$(( secs % 86400 ))
    local hours=$(( rem / 3600 ))
    local mins=$(( (rem % 3600) / 60 ))
    local s=$(( rem % 60 ))
    if (( days > 0 )); then
        printf -v _mlp_human '%dd %02d:%02d:%02d' "$days" "$hours" "$mins" "$s"
    elif (( hours > 0 )); then
        printf -v _mlp_human '%02d:%02d:%02d' "$hours" "$mins" "$s"
    else
        printf -v _mlp_human '%02d:%02d' "$mins" "$s"
    fi
}

# Imprime un unico frame de la tabla a stdout.
# Estrategia de rendimiento (cero forks por fila):
#   1. Un solo `ps -eo pid=,etimes=` construye un mapa pid -> segundos vivo.
#   2. Un solo `ss -H -tlnp` lista los sockets en escucha.
#   3. Cada linea se parsea con bash puro: IP/puerto por parameter expansion,
#      (nombre,pid) del campo users:(...) iterando con BASH_REMATCH, y cada
#      fila se arma con `printf -v` (builtin). El uptime se resuelve por lookup
#      O(1) en el mapa.
#   4. Se ordena por segundos vivo descendente con un unico `sort`.
_mlp_render_frame() {
    # Mapas pid -> etimes (segundos vivo) y pid -> cmdline completa. Una sola
    # invocacion de ps por frame. `args=` va al ultimo porque lleva espacios,
    # asi `read` lo captura entero en la tercera variable.
    local -A etmap=() argmap=()
    local _pid _et _args
    while read -r _pid _et _args; do
        [[ -z "$_pid" ]] && continue
        etmap["$_pid"]="$_et"
        argmap["$_pid"]="$_args"
    done < <(ps -eo pid=,etimes=,args= 2>/dev/null)

    # Cada fila intermedia: "<etimes>\t<ip>\t<puerto>\t<proceso>\t<pid>\t<humano>"
    local -a rows=()
    local line row
    while IFS= read -r line; do
        [[ -z "$line" ]] && continue

        # Campos de `ss -H -tlnp`: State Recv-Q Send-Q Local:Port Peer:Port users:(...)
        # Local:Port es el 4o token. Lo extraemos sin fork con read en array.
        local -a F=()
        read -ra F <<<"$line"
        local local_addr="${F[3]:-}"
        [[ -z "$local_addr" ]] && continue

        # Separar IP y PUERTO partiendo por el ULTIMO ':'.
        local ip port
        port="${local_addr##*:}"
        ip="${local_addr%:*}"
        # Quitar corchetes de IPv6: [::] -> ::  ,  [::1] -> ::1
        ip="${ip#[}"
        ip="${ip%]}"
        # Caso de bind sin direccion explicita (raro): dejar marcador.
        [[ -z "$ip" ]] && ip="*"

        # Extraer el bloque users:(...) del final de la linea (si existe).
        local users=""
        [[ "$line" == *"users:("* ]] && users="${line#*users:(}"

        if [[ -z "$users" ]]; then
            # Socket sin info de proceso (pertenece a otro usuario y no corremos
            # como root). Para verlo, lanzar la TUI como root (ver Gotchas).
            printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
            rows+=("$row")
            continue
        fi

        # Dentro de users puede haber varios ("nombre",pid=N,fd=M). Una fila por
        # pid. Iteramos con BASH_REMATCH avanzando sobre el string (cero forks).
        local s="$users" pname pid etimes needle prev_s cmd found_any=0
        while [[ "$s" =~ \"([^\"]*)\",pid=([0-9]+) ]]; do
            # IMPORTANTE: capturar nombre/pid/needle ANTES de cualquier otra
            # comparacion `[[ =~ ]]` (p.ej. dentro de _mlp_fmt_etime), porque
            # cada `=~` SOBREESCRIBE BASH_REMATCH. Si se usara BASH_REMATCH[0]
            # despues, contendria el match del ultimo `=~` y el recorte de `s`
            # no avanzaria -> bucle infinito.
            pname="${BASH_REMATCH[1]}"
            pid="${BASH_REMATCH[2]}"
            needle="${BASH_REMATCH[0]}"
            found_any=1

            # Lookup O(1) en el mapa. Si el pid ya no esta (carrera), marcar "?".
            etimes="${etmap[$pid]:-}"
            if [[ -z "$etimes" || ! "$etimes" =~ ^[0-9]+$ ]]; then
                etimes="-1"
                _mlp_human="?"
            else
                _mlp_fmt_etime "$etimes"
            fi

            # Comando real (cmdline completa) del pid; dice QUE es realmente un
            # "python3"/"node" generico. Se recorta para no romper la tabla.
            cmd="${argmap[$pid]:-}"
            [[ -z "$cmd" ]] && cmd="-"
            cmd="${cmd:0:90}"

            printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "$etimes" "$ip" "$port" "$pname" "$pid" "$_mlp_human" "$cmd"
            rows+=("$row")

            # Avanzar mas alla del match actual para no repetir el primer pid.
            # Guard: si el recorte no cambia `s`, cortar para no colgar nunca.
            prev_s="$s"
            s="${s#*"$needle"}"
            [[ "$s" == "$prev_s" ]] && break
        done

        # Si el formato fue inesperado y no se parseo ningun par, fila placeholder.
        if (( found_any == 0 )); then
            printf -v row '%s\t%s\t%s\t%s\t%s\t%s\t%s' "-1" "$ip" "$port" "-" "-" "?" "-"
            rows+=("$row")
        fi
    done < <(ss -H -tlnp 2>/dev/null)

    # Estilo de cabecera (negrita) si la terminal lo soporta.
    local bold="" reset=""
    if [[ -t 1 ]] && command -v tput >/dev/null 2>&1; then
        bold=$(tput bold 2>/dev/null || true)
        reset=$(tput sgr0 2>/dev/null || true)
    fi

    # Anchos fijos para alineacion estable (no usamos column -t). La ultima
    # columna (CMD) es libre: muestra la cmdline real del proceso.
    local fmt='%-26s %-7s %-16s %-8s %-13s %s\n'
    # shellcheck disable=SC2059
    printf "${bold}${fmt}${reset}" "IP" "PUERTO" "PROCESO" "PID" "TIEMPO ACTIVO" "CMD"

    if (( ${#rows[@]} == 0 )); then
        printf '(sin sockets TCP en escucha)\n'
        return 0
    fi

    # Ordenar por la primera columna (etimes) numerica descendente y emitir las
    # 5 columnas visibles (descartando la columna de orden).
    printf '%s\n' "${rows[@]}" \
        | sort -t$'\t' -k1,1nr \
        | while IFS=$'\t' read -r _etimes ip port pname pid human cmd; do
            # shellcheck disable=SC2059
            printf "$fmt" "$ip" "$port" "$pname" "$pid" "$human" "$cmd"
          done
}

monitor_listening_ports() {
    local interval=1
    local once=0

    # Parseo de flags.
    while (( $# > 0 )); do
        case "$1" in
            --interval)
                interval="${2:-1}"
                shift 2
                ;;
            --interval=*)
                interval="${1#*=}"
                shift
                ;;
            --once)
                once=1
                shift
                ;;
            -h|--help)
                cat <<'USAGE'
monitor_listening_ports [--interval N] [--once]

  --interval N   Segundos entre refrescos (default: 1, acepta decimales).
  --once         Imprime un solo frame de la tabla y termina (exit 0).

Tabla de sockets TCP en escucha (LISTEN) ordenada por tiempo de vida del
proceso dueño (descendente). Columnas: IP | PUERTO | PROCESO | PID | TIEMPO ACTIVO.
USAGE
                return 0
                ;;
            *)
                printf 'monitor_listening_ports: argumento desconocido: %s\n' "$1" >&2
                return 1
                ;;
        esac
    done

    # Dependencias minimas.
    if ! command -v ss >/dev/null 2>&1; then
        printf 'monitor_listening_ports: requiere `ss` (paquete iproute2)\n' >&2
        return 1
    fi
    if ! command -v ps >/dev/null 2>&1; then
        printf 'monitor_listening_ports: requiere `ps` (paquete procps)\n' >&2
        return 1
    fi

    # Modo single-frame: util para tests y para `fn run` sin colgar.
    if (( once == 1 )); then
        _mlp_render_frame
        return 0
    fi

    # Modo bucle interactivo: oculta cursor y lo restaura + limpia al salir.
    local have_tput=0
    command -v tput >/dev/null 2>&1 && have_tput=1

    _mlp_cleanup() {
        if (( have_tput == 1 )); then
            tput cnorm 2>/dev/null || true   # restaurar cursor
            tput sgr0 2>/dev/null || true    # resetear atributos
        fi
        printf '\n'
    }
    trap '_mlp_cleanup; trap - INT TERM EXIT; return 0 2>/dev/null || exit 0' INT TERM EXIT

    (( have_tput == 1 )) && tput civis 2>/dev/null || true   # ocultar cursor

    # Limpiamos la pantalla UNA sola vez al entrar. A partir de aqui cada frame
    # se computa COMPLETO en una variable y luego se pinta con doble-buffer:
    # cursor a home (\033[H), volcado del frame, y clear-to-end (\033[J) para
    # borrar restos de un frame anterior mas largo. Asi nunca hay un instante
    # con la pantalla vacia mientras se recolectan los datos.
    printf '\033[2J'

    local frame
    while true; do
        frame=$(
            printf 'monitor_listening_ports  —  %s  —  intervalo %ss  —  orden: TIEMPO ACTIVO desc  (Ctrl-C para salir)\n\n' \
                "$(date '+%d/%m/%Y %H:%M:%S')" "$interval"
            _mlp_render_frame
        )
        printf '\033[H'        # cursor al inicio (sin borrar todavia)
        printf '%s\n' "$frame" # volcar el frame ya calculado de golpe
        printf '\033[J'        # borrar de aqui al final (restos del frame previo)
        sleep "$interval" || break
    done
}

# Auto-invocacion cuando se ejecuta como script (no al hacer source).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    monitor_listening_ports "$@"
fi
