#!/bin/bash
# subir - Publica un repositorio apt local en un servidor por SFTP
#         y, opcionalmente, ejecuta un comando de limpieza en el servidor.
# Uso: subir [-n|--dry-run] [-c|--config]
#   -n  simulación (no sube ni borra nada)
#   -c  vuelve a preguntar todas las variables y reescribe /opt/subir/var
#
# Orden de trabajo (pensado para que el repo nunca quede roto en el servidor):
#   0) Comprueba en LOCAL que todo .deb que citan los índices (Packages) existe.
#   1) Sube las carpetas "solo cambios" (pool). Cualquier error aborta.
#   2) Verifica contra el servidor que el pool está completo. Si falta o difiere
#      un solo fichero, NO publica los índices.
#   3) Sube las carpetas "siempre" (conf, db, dists). En dists, Release,
#      InRelease y Release.gpg van los últimos.
#   4) Ejecuta el comando de limpieza (solo si todo lo anterior fue bien).

CONF_DIR="/opt/subir"
CONF_FILE="$CONF_DIR/var"

set -u

DRY=0
RECONF=0
for arg in "$@"; do
    case "$arg" in
        -n|--dry-run) DRY=1 ;;
        -c|--config)  RECONF=1 ;;
        *) echo "Uso: subir [-n|--dry-run] [-c|--config]"; exit 1 ;;
    esac
done

# --- Creación de la carpeta de configuración (si hace falta) ---
if [ ! -d "$CONF_DIR" ]; then
    if ! mkdir -p "$CONF_DIR" 2>/dev/null; then
        echo "Creando $CONF_DIR (se necesita sudo)..."
        sudo mkdir -p "$CONF_DIR" && sudo chown "$(id -un):$(id -gn)" "$CONF_DIR" \
            || { echo "ERROR: no se pudo crear $CONF_DIR"; exit 1; }
    fi
fi

# --- Configuración inicial (o reconfiguración): sin valores predeterminados ---
if [ ! -f "$CONF_FILE" ] || [ $RECONF -eq 1 ]; then
    echo "=== Configuración de subir (se guardará en $CONF_FILE) ==="

    read -rp "Carpeta local del repositorio (la que contiene las carpetas a subir): " N_LOCAL_REPO
    read -rp "Servidor (IP o nombre): " N_REMOTE_HOST
    read -rp "Usuario SFTP/SSH: " N_REMOTE_USER
    read -rp "Carpeta remota de destino (ruta absoluta): " N_REMOTE_DIR
    read -rp "Carpetas a subir SOLO si cambiaron, separadas por espacios (vacío = ninguna): " N_CARPETAS_CAMBIOS
    read -rp "Carpetas a subir SIEMPRE sobrescribiendo, separadas por espacios (vacío = ninguna): " N_CARPETAS_SIEMPRE
    read -rp "Comando de limpieza a ejecutar en el servidor (vacío = ninguno): " N_REMOTE_CLEAN_CMD
    read -rp "URL pública del repo para verificar lo publicado (ej. https://repo.quirinux.org; vacío = no verificar): " N_PUBLIC_URL
    read -rsp "Contraseña (vacía = que te la pida en cada ejecución): " N_REMOTE_PASS
    echo

    if [ -z "$N_LOCAL_REPO" ] || [ -z "$N_REMOTE_HOST" ] || [ -z "$N_REMOTE_USER" ] \
       || [ -z "$N_REMOTE_DIR" ] || { [ -z "$N_CARPETAS_CAMBIOS" ] && [ -z "$N_CARPETAS_SIEMPRE" ]; }; then
        echo "ERROR: faltan datos obligatorios (solo pueden quedar vacíos el comando de limpieza,"
        echo "       la contraseña y UNA de las dos listas de carpetas)."
        exit 1
    fi

    # Se escribe con permisos restrictivos desde el principio (puede contener la contraseña)
    umask 077
    {
        echo "# Configuración de subir - generada el $(date '+%F %T')"
        printf 'LOCAL_REPO=%q\n'        "$N_LOCAL_REPO"
        printf 'REMOTE_HOST=%q\n'       "$N_REMOTE_HOST"
        printf 'REMOTE_USER=%q\n'       "$N_REMOTE_USER"
        printf 'REMOTE_DIR=%q\n'        "$N_REMOTE_DIR"
        printf 'CARPETAS_CAMBIOS=%q\n'  "$N_CARPETAS_CAMBIOS"
        printf 'CARPETAS_SIEMPRE=%q\n'  "$N_CARPETAS_SIEMPRE"
        printf 'REMOTE_CLEAN_CMD=%q\n'  "$N_REMOTE_CLEAN_CMD"
        printf 'PUBLIC_URL=%q\n'        "$N_PUBLIC_URL"
        printf 'REMOTE_PASS=%q\n'       "$N_REMOTE_PASS"
    } > "$CONF_FILE"
    chmod 600 "$CONF_FILE"
    echo "Configuración guardada en $CONF_FILE (permisos 600)."
    echo
fi

# --- Carga de la configuración ---
chmod 600 "$CONF_FILE" 2>/dev/null
. "$CONF_FILE"

if [ -z "${CARPETAS_CAMBIOS+x}" ] || [ -z "${CARPETAS_SIEMPRE+x}" ]; then
    echo "AVISO: la configuración es de una versión anterior de subir."
    echo "Ejecuta 'subir -c' para volver a configurarla."
    exit 1
fi

# URL pública del repo (para verificar de extremo a extremo lo que sirve la web).
# Si la configuración es anterior a esta opción, se pregunta una vez y se guarda.
if [ -z "${PUBLIC_URL+x}" ]; then
    PUBLIC_URL=""
    if [ -t 0 ]; then
        read -rp "URL pública del repo para verificar lo publicado (ej. https://repo.quirinux.org; vacío = no verificar): " PUBLIC_URL
        printf 'PUBLIC_URL=%q\n' "$PUBLIC_URL" >> "$CONF_FILE"
    fi
fi
PUBLIC_URL="${PUBLIC_URL%/}"

read -ra CAMBIOS_ARR <<< "$CARPETAS_CAMBIOS"
read -ra SIEMPRE_ARR <<< "$CARPETAS_SIEMPRE"

for c in "${CAMBIOS_ARR[@]}" "${SIEMPRE_ARR[@]}"; do
    [ -d "$LOCAL_REPO/$c" ] || { echo "ERROR: no existe $LOCAL_REPO/$c (revisa con: subir -c)"; exit 1; }
done

# --- 0a) Comprobación local: todo .deb que citan los índices debe existir ---
# Si un Packages cita un fichero que no está en el pool local, el repo
# publicado quedaría roto (404 en apt). Se detecta antes de subir nada.
FALTAN_LOCAL=0
IDX_FILES=()
declare -A IDX_FN=()      # rutas (Filename:) que algún índice declara
while IFS= read -r -d '' idx; do
    IDX_FILES+=("$idx")
    case "$idx" in
        *.gz) lector=(zcat) ;;
        *)    lector=(cat) ;;
    esac
    while IFS= read -r fn; do
        [ -n "$fn" ] || continue
        IDX_FN["$fn"]=1
        if [ ! -f "$LOCAL_REPO/$fn" ]; then
            echo "ERROR: el índice ${idx#"$LOCAL_REPO"/} cita $fn y no existe en local."
            FALTAN_LOCAL=1
        fi
    done < <("${lector[@]}" "$idx" 2>/dev/null | awk '/^Filename:/ {sub(/^Filename:[ \t]*/, ""); print}')
done < <(find "$LOCAL_REPO" -path '*/dists/*' \( -name Packages -o -name Packages.gz \) -print0 2>/dev/null)

if [ $FALTAN_LOCAL -ne 0 ]; then
    echo "Corrige el repositorio local (reprepro/dpkg-scanpackages) y vuelve a ejecutar."
    exit 1
fi

# --- 0a-bis) Aviso: .deb del pool MÁS NUEVO que lo que declara el índice ---
# Caso típico: se copia un .deb nuevo al pool pero no se regenera el índice
# (reprepro includedeb / reprepro export). El servidor seguiría ofreciendo la
# versión vieja, y si esa se borra del pool, apt da 404.
AVISOS=()
if [ ${#IDX_FILES[@]} -gt 0 ] && command -v dpkg >/dev/null 2>&1; then
    declare -A IDX_MAX=() POOL_MAX=()
    # Solo se comparan los .deb del pool cuya ruta NINGÚN índice cita: si el
    # índice ya los declara (aunque con otro nombre de paquete), están indexados.
    for idx in "${IDX_FILES[@]}"; do
        case "$idx" in *.gz) lector=(zcat) ;; *) lector=(cat) ;; esac
        while read -r pn pv; do
            [ -n "$pn" ] || continue
            if [ -z "${IDX_MAX[$pn]:-}" ] || dpkg --compare-versions "$pv" gt "${IDX_MAX[$pn]}"; then
                IDX_MAX[$pn]="$pv"
            fi
        done < <("${lector[@]}" "$idx" 2>/dev/null | awk '/^Package:/ {sub(/^Package:[ \t]*/, ""); p=$0} /^Version:/ {sub(/^Version:[ \t]*/, ""); print p, $0}')
    done
    while IFS= read -r -d '' f; do
        [ -n "${IDX_FN[${f#"$LOCAL_REPO"/}]:-}" ] && continue
        b="${f##*/}"; b="${b%.deb}"
        pn="${b%%_*}"; pv="${b#*_}"; pv="${pv%_*}"; pv="${pv//%3a/:}"
        if [ -z "${POOL_MAX[$pn]:-}" ] || dpkg --compare-versions "$pv" gt "${POOL_MAX[$pn]}"; then
            POOL_MAX[$pn]="$pv"
        fi
    done < <(find "$LOCAL_REPO" -path '*/pool/*' -name '*.deb' -print0)
    for pn in "${!POOL_MAX[@]}"; do
        if [ -z "${IDX_MAX[$pn]:-}" ]; then
            AVISOS+=("$pn ${POOL_MAX[$pn]}: está en el pool pero no figura en ningún índice")
        elif dpkg --compare-versions "${POOL_MAX[$pn]}" gt "${IDX_MAX[$pn]}"; then
            AVISOS+=("$pn: el pool tiene ${POOL_MAX[$pn]} pero el índice declara ${IDX_MAX[$pn]}")
        fi
    done
fi

if [ ${#AVISOS[@]} -gt 0 ]; then
    echo "AVISO: el pool local tiene versiones que los índices NO anuncian (apt no las ofrecerá):"
    printf '  - %s\n' "${AVISOS[@]}" | sort | head -n 30
    [ ${#AVISOS[@]} -gt 30 ] && echo "  ... y $(( ${#AVISOS[@]} - 30 )) más"
    echo "Regenera el índice (por ejemplo: reprepro includedeb / reprepro export) antes de publicar."
    if [ $DRY -eq 0 ] && [ -t 0 ]; then
        read -rp "¿Publicar de todos modos? [s/N]: " RESP
        case "$RESP" in s|S|si|SI|sí) ;; *) echo "Cancelado."; exit 1 ;; esac
    fi
fi

# --- Contraseña: la del archivo o, si está vacía, la pide ---
PASS="${REMOTE_PASS:-}"
if [ -z "$PASS" ]; then
    read -rsp "Contraseña de $REMOTE_USER@$REMOTE_HOST: " PASS
    echo
fi

# --- 0b) Comprobación de dependencias ---
FALTAN=()
command -v lftp >/dev/null 2>&1 || FALTAN+=("lftp")
if [ -n "$PUBLIC_URL" ]; then
    command -v curl >/dev/null 2>&1 || FALTAN+=("curl")
fi
if [ -n "$REMOTE_CLEAN_CMD" ]; then
    command -v sshpass >/dev/null 2>&1 || FALTAN+=("sshpass")
    command -v ssh     >/dev/null 2>&1 || FALTAN+=("openssh-client")
fi

if [ ${#FALTAN[@]} -gt 0 ]; then
    unset PASS
    echo
    echo "AVISO: faltan paquetes necesarios: ${FALTAN[*]}"
    echo "Instálalos con:"
    echo "    sudo apt install ${FALTAN[*]}"
    echo "y vuelve a ejecutar: subir"
    exit 1
fi

# --no-perms: no copia los permisos locales; los archivos nuevos usan los del servidor
MIRROR_OPTS="--verbose --parallel=3 --no-perms"
CLEAN_ARGS=""
if [ $DRY -eq 1 ]; then
    MIRROR_OPTS="$MIRROR_OPTS --dry-run"
    CLEAN_ARGS="--dry-run"
    echo "*** MODO SIMULACIÓN: no se sube ni se borra nada ***"
fi

# Escapa \ y " para que lftp interprete bien rutas y contraseña
esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '%s' "$s"; }

# Cabecera común de cada sesión lftp.
# cmd:fail-exit: si un comando falla, lftp se detiene con código distinto de 0.
# Sin esto, lftp -f sigue con el resto y termina con código 0 aunque un fichero
# no se haya subido, y el índice acabaría citando ficheros que no están.
lftp_cabecera() {
    echo "set sftp:auto-confirm yes"
    echo "set net:max-retries 3"
    echo "set net:reconnect-interval-base 5"
    echo "set cmd:fail-exit yes"
    echo "open -u \"$(esc "$REMOTE_USER"),$(esc "$PASS")\" sftp://$REMOTE_HOST"
}

# Genera los comandos para subir una carpeta SIEMPRE sobrescribiendo (put).
# modo "normal":  todo salvo Release/InRelease/Release.gpg de dists (y crea carpetas)
# modo "release": solo Release/InRelease/Release.gpg de dists
sobrescribir() {
    local origen="$1" destino="$2" modo="$3" d f rel dir rd es_rel
    if [ "$modo" = "normal" ]; then
        echo "mkdir -p -f \"$(esc "$destino")\""
        while IFS= read -r -d '' d; do
            echo "mkdir -p -f \"$(esc "$destino/${d#"$origen"/}")\""
        done < <(find "$origen" -mindepth 1 -type d -print0)
    fi
    while IFS= read -r -d '' f; do
        es_rel=0
        case "$f" in
            */dists/*/Release|*/dists/*/InRelease|*/dists/*/Release.gpg) es_rel=1 ;;
        esac
        [ "$modo" = "normal"  ] && [ $es_rel -eq 1 ] && continue
        [ "$modo" = "release" ] && [ $es_rel -eq 0 ] && continue
        rel="${f#"$origen"/}"; dir="$(dirname "$rel")"
        if [ "$dir" = "." ]; then rd="$destino"; else rd="$destino/$dir"; fi
        if [ $DRY -eq 1 ]; then
            echo "echo \"[simulación] subiría: $(esc "$rel")\""
        else
            echo "put -O \"$(esc "$rd")\" \"$(esc "$f")\""
        fi
    done < <(find "$origen" -type f -print0)
}

fallar() {
    unset PASS
    echo "ERROR: $1"
    echo "No se ha publicado nada que pueda romper el repositorio del servidor."
    exit "${2:-1}"
}

# Comprueba por HTTP(S) que cada .deb que citan los índices existe en la web
# pública (lo que apt va a pedir). Se codifica como lo hace apt: % -> %25, + -> %2b.
# Imprime los que no responden 200 y devuelve 1 si hay alguno.
verificar_web_pool() {
    local malos
    malos=$(printf '%s\n' "${!IDX_FN[@]}" \
        | sed 's/%/%25/g; s/+/%2b/g' \
        | xargs -r -P 8 -I{} curl -sIL --max-time 30 -o /dev/null -w '%{http_code} %{url_effective}\n' "$PUBLIC_URL/{}" \
        | awk '$1 != 200')
    if [ -n "$malos" ]; then
        printf '%s\n' "$malos" | head -n 20
        return 1
    fi
    return 0
}

# Comprueba que la web pública sirve índices idénticos a los locales.
verificar_web_indices() {
    local f rel malos=0
    while IFS= read -r -d '' f; do
        rel="${f#"$LOCAL_REPO"/}"
        if ! curl -fsSL --max-time 60 "$PUBLIC_URL/$rel" 2>/dev/null | cmp -s - "$f"; then
            echo "  - $rel"
            malos=1
        fi
    done < <(find "$LOCAL_REPO" -path '*/dists/*' \( -name InRelease -o -name Release -o -name Packages.gz \) -print0)
    return $malos
}

echo "Origen:  $LOCAL_REPO"
echo "Destino: $REMOTE_USER@$REMOTE_HOST:$REMOTE_DIR"
[ -n "$PUBLIC_URL" ] && echo "Web pública a verificar: $PUBLIC_URL"

# --- 1) Subida del pool (carpetas "solo cambios") ---
if [ ${#CAMBIOS_ARR[@]} -gt 0 ]; then
    {
        lftp_cabecera
        for c in "${CAMBIOS_ARR[@]}"; do
            echo "mirror -R $MIRROR_OPTS \"$(esc "$LOCAL_REPO/$c")\" \"$(esc "$REMOTE_DIR/$c")\""
        done
        echo "bye"
    } | lftp -f /dev/stdin
    RC=$?
    [ $RC -eq 0 ] || fallar "falló la subida de ${CARPETAS_CAMBIOS} (código $RC). No se publican los índices." "$RC"

    # --- 2) Verificación: el pool del servidor debe coincidir con el local ---
    # Un mirror --dry-run que no tiene nada que transferir confirma que cada
    # fichero local existe en el servidor con el mismo tamaño.
    if [ $DRY -eq 0 ]; then
        echo "Verificando que el pool del servidor está completo..."
        SALIDA=$({
            lftp_cabecera
            for c in "${CAMBIOS_ARR[@]}"; do
                echo "mirror -R --dry-run --verbose --no-perms \"$(esc "$LOCAL_REPO/$c")\" \"$(esc "$REMOTE_DIR/$c")\""
            done
            echo "bye"
        } | lftp -f /dev/stdin 2>&1)
        RC=$?
        [ $RC -eq 0 ] || { echo "$SALIDA" | tail -n 5; fallar "no se pudo verificar el pool (código $RC)." "$RC"; }

        PENDIENTES=$(printf '%s\n' "$SALIDA" | grep -E '^Transferring file' || true)
        if [ -n "$PENDIENTES" ]; then
            echo "$PENDIENTES"
            fallar "faltan o difieren ficheros en el servidor (lista arriba). Vuelve a ejecutar subir."
        fi
        echo "Pool verificado."
    fi
fi

# --- 2b) Verificación en la web pública: todo .deb que citan los índices debe servirse ---
if [ -n "$PUBLIC_URL" ] && [ $DRY -eq 0 ] && [ ${#IDX_FN[@]} -gt 0 ]; then
    echo "Comprobando en $PUBLIC_URL que los ${#IDX_FN[@]} .deb de los índices responden 200..."
    if ! verificar_web_pool; then
        fallar "la web pública NO sirve .deb que citan los índices (lista arriba): es el 404 que vería apt. Revisa que REMOTE_DIR ($REMOTE_DIR en $REMOTE_HOST) sea la carpeta que publica la web."
    fi
    echo "Web pública: todos los .deb responden."
fi

# --- 3) Subida de índices y configuración (carpetas "siempre") ---
if [ ${#SIEMPRE_ARR[@]} -gt 0 ]; then
    {
        lftp_cabecera
        for c in "${SIEMPRE_ARR[@]}"; do
            sobrescribir "$LOCAL_REPO/$c" "$REMOTE_DIR/$c" normal
        done
        for c in "${SIEMPRE_ARR[@]}"; do
            sobrescribir "$LOCAL_REPO/$c" "$REMOTE_DIR/$c" release
        done
        echo "bye"
    } | lftp -f /dev/stdin
    RC=$?
    if [ $RC -ne 0 ]; then
        unset PASS
        echo "ERROR en la subida de ${CARPETAS_SIEMPRE} (código $RC). No se ejecuta la limpieza."
        echo "ATENCIÓN: los índices pueden haber quedado a medias en el servidor. Vuelve a ejecutar subir."
        exit $RC
    fi
fi

# --- 3b) Verificación en la web pública: lo publicado debe ser idéntico al local ---
if [ -n "$PUBLIC_URL" ] && [ $DRY -eq 0 ] && [ ${#SIEMPRE_ARR[@]} -gt 0 ]; then
    echo "Comprobando que $PUBLIC_URL sirve los mismos índices que el local..."
    if ! SALIDA_WEB=$(verificar_web_indices); then
        unset PASS
        echo "ERROR: la web pública NO sirve los mismos ficheros que acabas de subir:"
        echo "$SALIDA_WEB"
        echo "Posibles causas: REMOTE_DIR distinto de la carpeta publicada, o una caché/proxy con la versión antigua."
        exit 3
    fi
    echo "Web pública: índices idénticos al local."
fi

# --- 4) Limpieza en el servidor (si hay comando configurado) ---
if [ -n "$REMOTE_CLEAN_CMD" ]; then
    echo "Ejecutando $REMOTE_CLEAN_CMD $CLEAN_ARGS en el servidor..."
    SSHPASS="$PASS" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
        "$REMOTE_USER@$REMOTE_HOST" "$REMOTE_CLEAN_CMD $CLEAN_ARGS"
    RC=$?
else
    echo "Sin comando de limpieza configurado; se omite."
    RC=0
fi

unset PASS
[ $RC -eq 0 ] && echo "Listo." || echo "ERROR en la limpieza (código $RC)."
exit $RC
