#!/usr/bin/env bash
# ============================================================================
#
#    ███████╗██████╗ ██╗   ██╗███████╗████████╗
#    ██╔════╝██╔══██╗██║   ██║██╔════╝╚══██╔══╝       ⇄ NET
#    ███████╗██████╔╝██║   ██║█████╗      ██
#    ╚════██║██╔═══╝ ╚██╗ ██╔╝██╔══╝      ██          sdtransfer.sh
#    ███████║██║      ╚████╔╝ ███████╗    ██    Transfert de photos SD → SSH
#    ╚══════╝╚═╝       ╚═══╝  ╚══════╝    ╚═╝
#
#  Assistant plein écran dans le terminal (TUI) pour copier des photos depuis
#  une carte SD branchée SUR CETTE MACHINE vers un PC distant en SSH.
#  Scénario typique : petit laptop avec lecteur SD ⇄ gros PC fixe sans lecteur.
#
#  UTILISATION
#  -----------
#    bash <(curl -fsSL https://TON_DOMAINE/sdtransfer.sh)
#  La forme « curl -fsSL https://TON_DOMAINE/sdtransfer.sh | bash » fonctionne
#  aussi : le script rebascule tout seul son clavier vers le vrai terminal.
#
#  PARCOURS
#  --------
#    1. Choix de la carte SD (volumes amovibles détectés, ou chemin manuel)
#    2. Navigation dans les dossiers de la carte → dossier photos
#    3. Sélection des photos :
#         · par plage de dates (« toutes les photos du X au Y »),
#         · ou manuellement (liste navigable : nom, date, taille, format).
#    4. Destination user@ip[:port] — clé SSH si disponible, sinon mot de passe
#       demandé une fois puis injecté automatiquement (SSH_ASKPASS intégré à
#       OpenSSH ≥ 8.4 ; sshpass n'est PAS nécessaire).
#    5. Dossier distant (navigation SSH, création de dossier possible), et
#       organisation : vrac, ou motif par DATE PHOTO, ex. Shooting_{mois}_{jour}
#       Jetons : {annee} {mois} {jour} {heure} {minute} {seconde}
#                {mois_nom} {jour_nom}   (+ alias {aaaa})
#    6. Transfert avec progression (rsync si possible, sinon scp),
#       récapitulatif final + bip de confirmation.
#
#  DÉPENDANCES REQUISES : bash ≥ 4.3, coreutils (date, sort, awk, base64…),
#  findutils (find), OpenSSH. CONSEILLÉE des deux côtés : rsync.
#
#  LIMITES : la « date photo » est la mtime du fichier ; noms contenant
#  un saut de ligne non gérés. Le mot de passe ne vit que dans /tmp (chmod
#  700) et est effacé à la sortie. Préférences légères (dernière cible,
#  dernier motif) dans ~/.config/sdtransfer.conf.
# ============================================================================

# ---- Mode « curl ... | bash » ----------------------------------------------
# Si stdin n'est pas un terminal, le script arrive par un pipe et le clavier
# serait avalé par bash. On copie le reste du flux dans un fichier temporaire
# puis on se ré-exécute dessus avec /dev/tty comme entrée.
if [[ ! -t 0 && ( "$0" == "bash" || "$0" == "/usr/bin/bash" || ! -r "$0" ) ]]; then
  __sdt_self="$(mktemp "${TMPDIR:-/tmp}/sdtransfer.XXXXXXXX")" || exit 1
  cat >"${__sdt_self}"
  if { :; } </dev/tty 2>/dev/null; then
    exec bash "${__sdt_self}" </dev/tty
  fi
  rm -f "${__sdt_self}"
  printf '\nsdtransfer : terminal interactif introuvable.\n' \
      'Lance :  bash <(curl -fsSL <url>)\n  depuis un vrai terminal.\n' >&2
  exit 1
fi
# NOTE MAINTENANCE : ce bloc doit rester au sommet. En entrée pipe, bash peut
# lire quelques Ko en avance dans le flux : tout ce qui suit immédiatement est
# donc volontairement inertiel (documentation/commentaires) et sert de tampon.
# ----------------------------------------------------------------------------
# GUIDE DES TOUCHES : ↑↓/kj déplacer · Entrée valider · Espace cocher
#   a tout cocher · u rien · i inverser · ← gauche/retour · Échap annuler
#
# MOT DE PASSE SANS sshpass : OpenSSH exécute le programme indiqué par
#   SSH_ASKPASS pour obtenir le mot de passe dès lors que
#   SSH_ASKPASS_REQUIRE=force (OpenSSH ≥ 8.4). On écrit un mini-exécutable
#   dans mktemp -d chmod 700 qui recharge le mot de passe stocké en base64
#   (aucun risque d'injection de guillemets), supprimé au départ (trap EXIT).
#
# RANGEMENT PAR MOTIF : le nom de sous-dossier est calculé par photo depuis
#   SA date (mtime). Toutes les photos d'un même jour partagent le dossier ;
#   les groupes sont créés côté distant par lots (mkdir -p). Les basenames
#   en collision dans un même dossier cible sont auto-renommés nom-1.ext…
# ============================================================================

# ============================ FIN DU TAMPOIN ================================

set +e
shopt -s nocaseglob nullglob extglob

RST=$'\e[0m'; BOLD=$'\e[1m'; DIM=$'\e[2m'; REV=$'\e[7m'
C_ACC=$'\e[95m'; C_CYA=$'\e[96m'; C_OK=$'\e[92m'; C_WARN=$'\e[93m'
C_ERR=$'\e[91m'; C_DIM=$'\e[90m'; C_TXT=$'\e[97m'

MOIS_NOM=(janvier février mars avril mai juin juillet août septembre octobre novembre décembre)
JOUR_NOM=(lundi mardi mercredi jeudi vendredi samedi dimanche)

IMG_EXTS=(jpg jpeg jpe jfif png tif tiff webp heic heif avif cr2 cr3 nef arw dng rw2 orf raf srw erf kdc)

SDT_VERSION="1.1.0"
SDT_CONF="${XDG_CONFIG_HOME:-$HOME/.config}/sdtransfer.conf"
export LC_ALL=C   # parsing déterministe ; textes FR gérés à la main

###############################################################################
# Conf minimale
###############################################################################
conf_load() {
  LAST_TARGET=""; CUSTOM_PATTERN=""
  [[ -r $SDT_CONF ]] || return 0
  local k v
  while IFS='=' read -r k rest_v; do
    v="$rest_v"
    case "$k" in
      LAST_TARGET)    LAST_TARGET="$v" ;;
      CUSTOM_PATTERN) CUSTOM_PATTERN="$v" ;;
    esac
  done <"$SDT_CONF"
}
conf_save_key() {
  local key="$1" val="$2" tmp; tmp="$(mktemp)" || return 0
  { [[ -r $SDT_CONF ]] && grep -v "^${key}=" "$SDT_CONF"
    printf '%s=%s\n' "$key" "$val"; } >"$tmp"
  mkdir -p -- "$(dirname "$SDT_CONF")" 2>/dev/null
  mv "$tmp" "$SDT_CONF"
}

###############################################################################
# Briques
###############################################################################
have() { command -v "$1" >/dev/null 2>&1; }
sdt_dbg() { [[ -n ${SDT_DEBUG:-} ]] && printf '%s\n' "$*" >>"$SDT_DEBUG" || true; return 0; }

human_size() {
  local b=${1:-0}
  if (( b < 1024 )); then printf '%d o' "$b"; return; fi
  local v units=(Ko Mo Go To) i=-1
  v=$(awk -v x="$b" 'BEGIN{printf "%.1f", x/1024}')
  while awk -v x="$v" 'BEGIN{exit !(x>=1024)}' && (( i < 2 )); do
    v=$(awk -v x="$v" 'BEGIN{printf "%.1f", x/1024}'); ((i++))
  done
  v=${v%.0}; v=${v/./,}
  printf '%s %s' "$v" "${units[$((i+1))]}"
}

sq() { local s=${1//\'/\'\\\'\'}; printf "'%s'" "$s"; }

trunc_str() {
  local s="$1" w=$2
  (( w < 1 )) && w=1
  (( ${#s} <= w )) && { printf '%s' "$s"; return; }
  (( w == 1 )) && { printf '%s' "${s:0:1}"; return; }
  printf '%s…' "${s:0:w-1}"
}

apply_pattern() { # $1 motif, $2 epoch (UTC mtime) → STDOUT
  local pat="$1" ep="$2" out d
  d="$(date    -d "@${ep}" '+%Y|%m|%d|%H|%M|%S|%w')"
  IFS='|' read -r Y M DAY H MIN S WD <<<"$d"
  out="$pat"
  out=${out//'{annee}'/"$Y"};   out=${out//'{aaaa}'/"$Y"}
  out=${out//'{mois}'/"$M"};    out=${out//'{jour}'/"$DAY"}
  out=${out//'{heure}'/"$H"};   out=${out//'{minute}'/"$MIN"}
  out=${out//'{seconde}'/"$S"}
  out=${out//'{mois_nom}'/"${MOIS_NOM[$((10#$M-1))]}"}
  out=${out//'{jour_nom}'/"${JOUR_NOM[$((WD-1))]}"}
  # normalisation : composants vides supprimés, pas de slash multiple/trailing
  local oifs=$IFS comp res=""
  set -f
  IFS='/' read -ra _comps <<<"$out"
  IFS=$oifs
  for comp in "${_comps[@]}"; do
    [[ -z $comp ]] && continue
    res+="${comp}/"
  done
  set +f
  printf '%s' "${res%/}"
}

build_ext_args() { # remplit EXT_ARGS avec les prédicats -iname joints par -o
  EXT_ARGS=()
  local p
  for p in "${IMG_EXTS[@]}"; do EXT_ARGS+=(-iname "*.${p}" -o); done
  unset 'EXT_ARGS[-1]'   # retire le dernier -o orphelin
}

scan_photos() { # $1 racine de scan → tableaux P_*
  local root="$1" tmpf line ep sz rel fd
  build_ext_args
  tmpf="$(mktemp)" || return 1
  ( cd "$root" 2>/dev/null &&
    find . -type f \( "${EXT_ARGS[@]}" \) -printf '%T@\001%s\001%P\n' 2>/dev/null |
    sort -t$'\x01' -k1,1n ) >"$tmpf"
  P_PATH=(); P_EPOCH=(); P_SIZE=(); P_HDATE=(); P_HSIZE=(); P_FMT=()
  while IFS= read -r line; do
    [[ -n $line ]] || continue
    ep="${line%%$'\x01'*}"; line="${line#*$'\x01'}"
    sz="${line%%$'\x01'*}"; rel="${line#*$'\x01'}"
    ep="${ep%.*}"
    fd="$(date    -d "@${ep}" '+%Y-%m-%d %H:%M')"
    P_PATH+=("$rel"); P_EPOCH+=("$ep"); P_SIZE+=("$sz")
    P_HDATE+=("$fd"); P_HSIZE+="$(human_size "$sz")"
    P_FMT+=("$(printf '%s' "${rel##*.}" | tr '[:lower:]' '[:upper:]')")
  done <"$tmpf"
  rm -f "$tmpf"
}

###############################################################################
# TTY / écran
###############################################################################
TERM_SAVED="" ASKPASS_DIR="" SCR_LINES=24 SCR_COLS=80 BD_TOP=5 BD_H=10
SDT_HEADLESS=0
case "${1:-}" in
  --selftest|--version|-V|-h|--help) SDT_HEADLESS=1 ;;
esac
cleanup_exit() {
  local rc=${1:-$?}
  (( SDT_HEADLESS )) && { trap - EXIT INT TERM; exit "$rc"; }
  [[ -n $TERM_SAVED ]] && stty "$TERM_SAVED" </dev/tty 2>/dev/null
  printf '%s\e[?25h\e[?1049l' "$RST" 2>/dev/null
  [[ -n $ASKPASS_DIR && -d $ASKPASS_DIR ]] && rm -rf -- "$ASKPASS_DIR"
  trap - EXIT INT TERM
  exit "$rc"
}
trap 'cleanup_exit $? ' INT TERM
trap 'rc=$?; cleanup_exit $rc' EXIT

tty_init() {
  [[ -t 0 ]] || { printf 'sdtransfer doit tourner dans un terminal.\n' >&2; exit 2; }
  TERM_SAVED="$(stty -g </dev/tty)" || { printf 'stty indisponible.\n' >&2; exit 2; }
  stty -icanon -echo min 1 time 0 </dev/tty
  printf '\e[?1049h\e[?25h'
}

size_screen() {
  local l c
  l=$(tput lines 2>/dev/null); c=$(tput cols 2>/dev/null)
  SCR_LINES=${l:-24}; SCR_COLS=${c:-80}
  (( SCR_LINES < 12 )) && SCR_LINES=12
  (( SCR_COLS < 46 ))  && SCR_COLS=46
}

scr_clear() { printf '\e[H\e[2J\e[3J'; }

scr_frame() { # $1 titre  $2 sous-titre  $3 aide-pied
  scr_clear; size_screen
  local W=$((SCR_COLS-2)) pad tl tsub fs
  printf '\e[1;1H\e[K'
  printf '╭%s╮' "$(printf '%*s' "$W" '' | tr ' ' '─')"$'\r\n'
  tl=${#1}; pad=$(( (W-tl)/2 ))
  printf '│%*s%s%s%s%s%*s│'$'\r\n' "$pad" '' "$C_ACC$BOLD" "$1" "$RST" '' $((W-pad-tl)) ''
  if [[ -n $2 ]]; then
    tsub=$(trunc_str "$2" "$W")
    printf '│%s%s%s%*s│'$'\r\n' "$C_DIM" "$tsub" "$RST" $((W-${#tsub})) ''
  else
    printf '│%*s│'$'\r\n' "$W" ''
  fi
  printf '├%s┤'$'\r\n' "$(printf '%*s' "$W" '' | tr ' ' '─')"
  BD_TOP=5; BD_H=$((SCR_LINES-BD_TOP-3))
  # corps : lignes 5 .. SCR_LINES-3   ; aide sur SCR_LINES-2 ; bord bas SCR_LINES
  printf '\e[%d;1H' "$((SCR_LINES-1))"
  printf '│%*s│' "$W" ''
  printf '\e[%d;1H╰%s╯' "$SCR_LINES" "$(printf '%*s' "$W" '' | tr ' ' '─')"
  if [[ -n $3 ]]; then
    fs=$(trunc_str "$3" "$W")
    printf '\e[%d;2H%s%s%s' "$((SCR_LINES-1))" "$C_DIM" "$fs" "$RST"
  fi
}

body_line() { # $1 numéro de ligne (≥1) dans le corps ; $2 contenu colorisé
  printf '\e[%d;2H\e[K%s' "$((BD_TOP+$1-1))" "$2"
}

read_key() {
  local c1 ch seq=""
  K=""
  IFS= read -rsn1 c1 || { K=esc; return; }
  case "$c1" in
    $'\x1b')
      while IFS= read -rsn1 -t 0.06 ch; do
        seq+="$ch"; ((${#seq} >= 3)) && break
      done
      case "$seq" in
        '[A'|'OA') K=up;;   '[B'|'OB') K=down;;
        '[C'|'OC') K=right;;'[D'|'OD') K=left;;
        '[H'|'[1~'|'OH') K=home;; '[F'|'[4~'|'OF') K=end;;
        '[5~') K=pgup;; '[6~') K=pgdn;;
        *) K=esc;;
      esac ;;
    '')              K=enter;;
    ' ')             K=space;;
    $'\x08'|$'\x7f') K=bs;;
    $'\x03'|$'\x04') K=esc;;
    *)               K="$c1";;
  esac
  return 0
}

###############################################################################
# Widget menu : → RET_IDX (255 si Échap/q)
###############################################################################
ui_menu() {
  local title="$1" sub="$2" foot="$3"; shift 3
  [[ ${1:-} == "--" ]] && shift
  local opts=("$@") n=$#
  (( n == 0 )) && { RET_IDX=255; return 0; }
  local cur=0 top=0 i vis row lbl len pad marks
  local FOOT="↑↓ Déplacer · Entrée Valider · Échap Retour"
  while :; do
    scr_frame "$title" "$sub" "${foot:-$FOOT}"
    (( cur < top )) && top=$cur
    (( cur >= top+BD_H )) && { top=$((cur-BD_H+1)); }
    vis=$(( n-top < BD_H ? n-top : BD_H ))
    for ((i=0;i<vis;i++)); do
      row=top+i; lbl=${opts[row]}
      local plain hl="" pre="  "
      plain=$(sed $'s/\x1b\\[[0-9;]*m//g' <<<"$lbl")
      (( row == cur )) && { pre="${C_ACC}${BOLD}▸ "; hl="$REV"; }
      (( ${#plain} > SCR_COLS-7 )) && plain=$(trunc_str "$plain" $((SCR_COLS-9)))
      printf -v pad '%*s' $((SCR_COLS-7-${#plain})) ''
      body_line $((i+1)) "$pre${hl}${lbl}$RST$pad$hl $RST"
    done
    if (( n > BD_H )); then
      printf '\e[%d;%dH%s▾%s' "$((SCR_LINES-3))" "$((SCR_COLS-1))" "$C_DIM" "$RST"
    fi
    read_key
    case "$K" in
      up|k)     cur=$(( (cur-1+n)%n ));;
      down|j)   cur=$(( (cur+1)%n ));;
      home)     cur=0;;
      end)      cur=$((n-1));;
      pgup)     cur=$(( cur-BD_H < 0 ? 0 : cur-BD_H ));;
      pgdn)     cur=$(( cur+BD_H >= n ? n-1 : cur+BD_H ));;
      enter|right|space) RET_IDX=$cur; return 0;;
      esc|left|q)        RET_IDX=255; return 0;;
    esac
  done
}

###############################################################################
# Widget saisie : → RET_VAL ; RET_CANCEL=1 si Échap
###############################################################################
ui_input() {
  local title="$1" label="$2" init="${3:-}" mask="${4:-}"
  RET_CANCEL=0; RET_VAL=""
  local buf="$init" pos=${#init} shown head
  while :; do
    scr_frame "$title" "$label" "Entrée Valider · Échap Annuler"
    shown="$buf"
    [[ -n $mask ]] && shown="$(printf '%*s' "${#buf}" '' | tr ' ' '*')"
    head="${shown:0:pos}"
    body_line 1 "▸ ${head}${C_ACC}█${RST}${shown:pos}"
    read_key
    case "$K" in
      enter) RET_VAL="$buf"; return 0;;
      esc)   RET_CANCEL=1; return 0;;
      bs)    if (( pos > 0 )); then buf="${buf:0:pos-1}${buf:pos}"; ((pos--)); fi ;;
      left)  (( pos > 0 )) && ((pos--)) ;;
      right) (( pos < ${#buf} )) && ((pos++)) ;;
      home)  pos=0 ;;
      end)   pos=${#buf} ;;
      space) buf="${buf:0:pos} ${buf:pos}"; ((pos++)) ;;
      ?)     # joker (doit rester en dernier) : tout caractère imprimable isolé
             if [[ $K =~ ^[[:print:]]$ ]]; then
               buf="${buf:0:pos}${K}${buf:pos}"; ((pos++))
             fi ;;
    esac
  done
}

###############################################################################
# Widget info / pause
###############################################################################
ui_info() { # $1 titre $2 texte (\n séparé, séquences \n interprétées) $3 pied optionnel
  local i=1 ln
  scr_frame "$1" "" "${3:-Entrée Continuer · Échap Retour}"
  while IFS= read -r ln; do
    (( i > BD_H )) && break
    body_line $i "$(trunc_str "$ln" $((SCR_COLS-4)))"
    ((i++))
  done < <(printf '%b' "$2")
  while :; do
    read_key
    case "$K" in enter|space|esc) return 0;; esac
  done
}

###############################################################################
# Widget multi-sélection
# Pré-requis : tableaux P_* remplis ; opère sur le tableau global SEL
###############################################################################
ui_multiselect() {
  local title="$1"
  local n=${#P_PATH[@]} cur=0 top=0 i vis row selcnt=0 totbytes=0
  for ((i=0;i<n;i++)); do
    [[ ${SEL[i]:-0} == 1 ]] && { ((selcnt++)); ((totbytes+=${P_SIZE[i]})); }
  done
  local NW
  recalc_row() {
    NW=$((SCR_COLS-42)); (( NW < 14 )) && NW=14
  }
  while :; do
    recalc_row
    scr_frame "$title" \
      "${C_CYA}${selcnt}/${n} cochées · $(human_size "$totbytes") sélectionnés${RST}" \
      "↑↓ Parcourir · Espace Cocher · a TOUT · u RIEN · i INVERSER · Entrée OK · Échap Retour"
    (( cur < top )) && top=$cur
    (( cur >= top+BD_H )) && { top=$((cur-BD_H+1)); }
    vis=$(( n-top < BD_H ? n-top : BD_H ))
    local dt hs fm nm pad flag
    for ((i=0;i<vis;i++)); do
      row=top+i
      flag=$([[ ${SEL[row]:-0} == 1 ]] && echo "✔" || echo "·")
      nm=$(trunc_str "${P_PATH[row]}" "$NW")
      printf -v pad '%*s' $((NW-${#nm})) ''
      dt="${P_HDATE[row]}"; hs="${P_HSIZE[row]}"; fm="${P_FMT[row]}"
      local selcol="" curcol=""
      [[ ${SEL[row]:-0} == 1 ]] && selcol="$C_CYA"
      (( row == cur )) && curcol="$REV"
      body_line $((i+1)) "$curcol  $flag $nm$pad${C_DIM} $dt  $(printf '%13s' "$hs")  $(printf '%-4s' "$fm")$RST$curcol $RST"
      unset selcol curcol
    done
    read_key
    case "$K" in
      up|k)   cur=$(( (cur-1+n)%n ));;
      down|j) cur=$(( (cur+1)%n ));;
      home)   cur=0;;
      end)    cur=$((n-1));;
      pgup)   cur=$(( cur-BD_H < 0 ? 0 : cur-BD_H ));;
      pgdn)   cur=$(( cur+BD_H >= n ? n-1 : cur+BD_H ));;
      space)
        if [[ ${SEL[cur]:-0} == 1 ]]; then SEL[cur]=0; ((selcnt--)); ((totbytes-=P_SIZE[cur]))
        else SEL[cur]=1; ((selcnt++)); ((totbytes+=P_SIZE[cur])); fi
        cur=$(( (cur+1)%n ));;
      a) for ((i=0;i<n;i++)); do SEL[i]=1; done
         selcnt=$n; totbytes=0; for ((i=0;i<n;i++)); do ((totbytes+=P_SIZE[i])); done;;
      u) for ((i=0;i<n;i++)); do SEL[i]=0; done; selcnt=0; totbytes=0;;
      i) for ((i=0;i<n;i++)); do
           if [[ ${SEL[i]:-0} == 1 ]]; then SEL[i]=0; ((selcnt--, totbytes-=P_SIZE[i]))
           else SEL[i]=1; ((selcnt++, totbytes+=P_SIZE[i])); fi
         done;;
      enter) SELECT_COUNT=$selcnt; SELECT_BYTES=$totbytes; return 0;;
      esc)   return 1;;
    esac
  done
}

###############################################################################
# Explorateur local ou distant
# ui_browser MODE(local|remote) CHEMIN_INITIAL
# → RET_PATH ; RET_CANCEL=1
###############################################################################
list_subdirs_local() { # $1 dir, $2 hidden 0/1
  local fa=(. -mindepth 1 -maxdepth 1 -type d)
  (( ${2:-0} )) || fa+=(-not -path ".*")
  ( cd "$1" 2>/dev/null &&
    find "${fa[@]}" -printf '%P/\n' 2>/dev/null |
    LC_ALL=C sort )
}
list_subdirs_remote() {
  run_remote "cd $(sq "$1") 2>/dev/null && ls -1dp $([[ ${2:-0} == 0 ]] && echo -A || echo -a) |
    grep '/\$' | grep -v '^\./\$'"
}
ui_browser() {
  local mode="$1" path="$2"
  RET_CANCEL=0
  local cur=0 top=0 hid=0 items subs e i n vis
  while :; do
    items=("⤓ Utiliser CE dossier")
    if [[ $mode == local ]]; then
      mapfile -t subs < <(list_subdirs_local "$path" "$hid")
    else
      mapfile -t subs < <(list_subdirs_remote "$path" "$hid")
    fi
    [[ $path != "/" ]] && items+=("↩ ..")
    for e in "${subs[@]}"; do
      e="${e%/}"; [[ -z $e ]] && continue
      items+=("▶ $e")
    done
    n=${#items[@]}
    scr_frame "Navigation $mode" "Dossier courant : $path" \
      "Entrée Ouvrir / ⤓ Valider ici · ← Remonter · m Nouveau · h Fichiers cachés · Échap Annuler"
    (( cur >= n )) && cur=$((n-1))
    (( cur < top )) && top=$cur
    (( cur >= top+BD_H )) && { top=$((cur-BD_H+1)); }
    vis=$(( n-top < BD_H ? n-top : BD_H ))
    for ((i=0;i<vis;i++)); do
      local row=$((top+i)) t="${items[row]}" hl="" col="$C_CYA"
      (( row == cur )) && hl="$REV"
      local colored
      colored="$col$t$RST"
      local plain_len=${#t}
      printf -v pad '%*s' $(( SCR_COLS-8-plain_len > 0 ? SCR_COLS-8-plain_len : 1 )) ''
      body_line $((i+1)) "  ${hl}${colored}$pad${hl} $RST"
    done
    read_key
    case "$K" in
      up|k)    cur=$(( (cur-1+n)%n ));;
      down|j)  cur=$(( (cur+1)%n ));;
      pgup)    cur=$(( cur-BD_H < 0 ? 0 : cur-BD_H ));;
      pgdn)    cur=$(( cur+BD_H >= n ? n-1 : cur+BD_H ));;
      h) hid=$((1-hid));;
      left) [[ $path != "/" ]] && { path="$(dirname "$path")"; cur=0; };;
      m)
        ui_input "Nouveau dossier (ici)" "Nom du sous-dossier à créer : "
        [[ $RET_CANCEL == 1 || -z $RET_VAL ]] && continue
        if [[ $mode == local ]]; then
          mkdir -p -- "$path/$RET_VAL" 2>/dev/null
        else
          run_remote "mkdir -p -- $(sq "$path/$RET_VAL")" >/dev/null
        fi;;
      esc) RET_CANCEL=1; return 0;;
      enter|right)
        if (( cur == 0 )); then RET_PATH="$path"; return 0; fi
        local tgt="${items[cur]}"
        tgt="${tgt#* }"                       # retire glyphe
        if [[ $tgt == ".." ]]; then
          path="$(dirname "$path")"; cur=0
        else
          local np="${path%/}/$tgt"
          if [[ $mode == local ]]; then
            [[ -d $np && -x $np ]] && { path="$np"; cur=0; }
          else
            if run_remote "cd $(sq "$np") 2>/dev/null && pwd -P" >/dev/null; then path="$np"; cur=0; fi
          fi
        fi;;
    esac
  done
}

###############################################################################
# Distant : SSH — TARGET/TGT_PORT/AUTH_MODE(pass|key)/ASKPASS_DIR
###############################################################################
auth_env_maybe() { # préfixe la commande avec env askpass si mode mot de passe
  if [[ ${AUTH_MODE:-} == pass && -n ${ASKPASS_DIR:-} ]]; then
    env SSH_ASKPASS="${ASKPASS_DIR}/ap" SSH_ASKPASS_REQUIRE=force DISPLAY=:0 setsid -w "$@"
  else
    "$@"
  fi
}
ssh_args=(-o StrictHostKeyChecking=accept-new -o ConnectTimeout=8 -o NumberOfPasswordPrompts=1)
port_args=()   # rempli selon TGT_PORT

setup_askpass() {
  teardown_askpass
  ASKPASS_DIR="$(umask 077 && mktemp -d)" || return 1
  printf '#!/bin/sh\nprintf "%%s\\n" "$(base64 -d <<'"'"'EOFSDT'"'"'\n%s\nEOFSDT\n)"\n' \
    "$(printf '%s' "$1" | base64 -w0)" >"${ASKPASS_DIR}/ap"
  chmod 700 "${ASKPASS_DIR}/ap"
}
teardown_askpass() {
  [[ -n ${ASKPASS_DIR:-} && -d ${ASKPASS_DIR} ]] && rm -rf -- "$ASKPASS_DIR"
  ASKPASS_DIR=""
}

run_remote() { # stdout capturé dans RUN_OUT, statut via $?
  RUN_OUT=$(auth_env_maybe ssh "${ssh_args[@]}" "${port_args[@]}" "$TARGET" "$*" </dev/null 2>&1)
  RUN_RC=$?
  return $RUN_RC
}
probe_target() {
  local errf; errf="$(mktemp)"
  ssh -o BatchMode=yes "${ssh_args[@]}" "${port_args[@]}" "$TARGET" true </dev/null 2>"$errf"
  local rc=$?
  PROBE_MSG="$(grep -iv '^warning' "$errf" | tail -n1)"
  rm -f "$errf"
  if (( rc == 0 )); then PROBE_RESULT=ok
  elif [[ $PROBE_MSG == *"Permission denied"* || -z $PROBE_MSG ]]; then PROBE_RESULT=needpw
  else PROBE_RESULT=fail; fi
  return 0
}

###############################################################################
# Phase 1 : carte SD source
###############################################################################
src_candidates() {
  VOL_KEYS=(); VOL_DESCS=(); declare -gA _seen=()
  local mp rm fs sz lbl dev
  while IFS=$'\t' read -r mp sz fs rm dev rest_; do
    [[ -n $mp && $mp != *'['* && $rm == 1 && -d $mp ]] || continue
    mp="${mp%/}"
    [[ ${_seen[$mp]:-} ]] && continue
    _seen[$mp]=1
    VOL_KEYS+=("$mp")
    VOL_DESCS+=("$(basename "$mp") · $(df -Ph -- "$mp" 2>/dev/null | awk 'NR==2{print $4}') libres / ${sz} · ${fs}")
  done < <(lsblk -rnpo MOUNTPOINT,SIZE,FSTYPE,RM,NAME 2>/dev/null)
  local d cand
  for cand in "/run/media/$USER" "/media/$USER" "/media" "/mnt"; do
    for d in "$cand"/*; do
      [[ -d $d && ${_seen["${d%/}"]:-} ]] && continue
      [[ -d $d ]] || continue
      mp="${d%/}"
      # déduplique avec realpath
      local rp; rp="$(realpath "$mp" 2>/dev/null || echo "$mp")"
      [[ ${_seen[$rp]:-} ]] && continue
      for k in "${VOL_KEYS[@]}"; do
        [[ "$(realpath "$k" 2>/dev/null)" == "$rp" ]] && continue 2
      done
      _seen[$rp]=1
      VOL_KEYS+=("$mp")
      VOL_DESCS+=("$(basename "$mp") · $(df -Ph -- "$mp" 2>/dev/null | awk 'NR==2{print $4}') libres")
    done
  done
}
phase_source() {
  src_candidates
  local opts=() i
  for ((i=0;i<${#VOL_KEYS[@]};i++)); do
    opts+=("💾  ${VOL_DESCS[i]}   [${VOL_KEYS[i]}]")
  done
  opts+=("✏️  Autre chemin (saisie manuelle)")
  local found_any=$(( ${#VOL_KEYS[@]} > 0 ))
  while :; do
    ui_menu "Carte SD source" \
      "$([ $found_any == 1 ] && echo 'Volumes amovibles détectés :' || echo 'Pas de volume amovible détecté — utilise la saisie manuelle :')" "" -- \
      "${opts[@]}"
    (( RET_IDX == 255 )) && return 1
    if (( RET_IDX == ${#opts[@]}-1 )); then
      ui_input "Chemin du volume photo" "Chemin absolu (ex. /mnt/carte) : " ""
      (( RET_CANCEL )) && continue
      if [[ -d $RET_VAL && -r $RET_VAL ]]; then
        SRC_ROOT="$(realpath "$RET_VAL")"; sdt_dbg "SRC_ROOT=$SRC_ROOT RET_VAL=$RET_VAL"; return 0
      fi
      ui_info "Introuvable" "Ce dossier n'existe pas ou n'est pas lisible :\n${RET_VAL}"
    else
      SRC_ROOT="${VOL_KEYS[RET_IDX]}"; return 0
    fi
  done
}

###############################################################################
# Phase 2 : dossier + scan
###############################################################################
phase_scan_dir() {
  ui_browser local "$SRC_ROOT" || return 1
  SRC_DIR="$RET_PATH"
  ui_info "Analyse de la carte…" "Dossier scanné :\n${SRC_DIR}\n\nRecherche : ${IMG_EXTS[*]}" "Patience…"
  sdt_dbg "SCAN: SRC_DIR=$SRC_DIR"
  scan_photos "$SRC_DIR"
  N_PHOTOS=${#P_PATH[@]}
  sdt_dbg "N_PHOTOS=$N_PHOTOS"
  if (( N_PHOTOS == 0 )); then
    ui_info "Aucune photo" "Aucun fichier image trouvé dans :\n${SRC_DIR}"
    return 2
  fi
  TOT_BYTES=0; local i
  for ((i=0;i<N_PHOTOS;i++)); do ((TOT_BYTES+=${P_SIZE[i]})); done
  return 0
}

###############################################################################
# Sélection : plage de dates + récap
###############################################################################
ui_daterange() {
  local d1 d2
  while :; do
    ui_input "Période — date de début" "AAAA-MM-JJ (incluse) : " "$(date +%Y)-01-01"
    (( RET_CANCEL )) && return 1
    d1="$RET_VAL"
    if [[ $d1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && date -u -d "$d1" >/dev/null 2>&1; then break; fi
    ui_info "Date invalide" "« ${d1} » n'est pas une date AAAA-MM-JJ valide."
  done
  while :; do
    ui_input "Période — date de fin" "AAAA-MM-JJ (incluse, toute la journée) : " "$d1"
    (( RET_CANCEL )) && return 1
    d2="$RET_VAL"
    if [[ $d2 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] && date -u -d "$d2" >/dev/null 2>&1; then break; fi
    ui_info "Date invalide" "« ${d2} » n'est pas une date AAAA-MM-JJ valide."
  done
  DR_FROM=$(date -d "$d1" +%s)
  DR_TO=$(date -d "$d2 + 1 day - 1 sec" +%s)
  DR_LABEL="du ${d1} au ${d2}"
  return 0
}

selection_recap() {
  local sel=0 tot=0 i fe le
  for ((i=0;i<N_PHOTOS;i++)); do
    [[ ${SEL[i]:-0} == 1 ]] || continue
    ((sel++, tot+=${P_SIZE[i]}))
    [[ -z ${fe:-} ]] && fe=${P_EPOCH[i]}
    le=${P_EPOCH[i]}
  done
  if (( sel == 0 )); then ui_info "Sélection vide" "Aucune photo cochée."; return 1; fi
  SPAN_TEXT="entre $(date -u -d "@${fe}" '+%d/%m/%Y') et $(date -u -d "@${le}" '+%d/%m/%Y')"
  while :; do
    ui_menu "Récapitulatif de la sélection" \
      "${sel} photos · $(human_size "$tot") · ${SPAN_TEXT}" "" -- \
      "✔  Valider — choisir la destination" \
      "✎  Reprendre la sélection à la main" \
      "⟲  Refaire le choix du mode (dates / manuel)"
    local r=$RET_IDX
    (( r == 255 )) && return 1
    case $r in
      0) return 0 ;;
      1) if ui_multiselect "Affinage manuelle — ${sel} déjà cochées"; then
           (( SELECT_COUNT > 0 )) && return 0
           ui_info "Sélection vide" "Il faut au moins une photo."
         fi ;;
      2) return 2 ;;
    esac
  done
}

phase_select() {
  SEL=(); local i
  for ((i=0;i<N_PHOTOS;i++)); do SEL[i]=0; done
  while :; do
    ui_menu "Sélection des photos" \
      "${N_PHOTOS} photos trouvées · $(human_size "$TOT_BYTES") au total · triées chronologiquement" "" -- \
      "📅  Toutes les photos d'une période (par dates)" \
      "✋  Sélection manuelle (liste complète)" \
      "◀  Revenir au choix du dossier"
    local r=$RET_IDX
    sdt_dbg "phase_select choix=$r"
    (( r == 255 )) && return 10
    case $r in
      0) while ui_daterange; do
           local nb=0
           for ((i=0;i<N_PHOTOS;i++)); do
             if (( P_EPOCH[i] >= DR_FROM && P_EPOCH[i] <= DR_TO )); then SEL[i]=1; ((nb++)); else SEL[i]=0; fi
           done
           if (( nb == 0 )); then
             ui_info "Aucun résultat" "Aucune photo ${DR_LABEL}."
             continue
           fi
           selection_recap; local rr=$?
           (( rr == 0 )) && return 0
           (( rr == 2 )) && break
         done ;;
      1) for ((i=0;i<N_PHOTOS;i++)); do SEL[i]=0; done
         sdt_dbg "multiselect entrée"
         if ui_multiselect "Sélection manuelle — ${N_PHOTOS} photos"; then
           (( SELECT_COUNT == 0 )) && { ui_info "Sélection vide" "Coche au moins une photo (Espace)."; continue; }
           selection_recap && return 0
         fi ;;
      2) return 11 ;;
    esac
  done
}

###############################################################################
# Phase cible : hôte + authentification
###############################################################################
phase_target() {
  port_args=()
  while :; do
    ui_input "Destination SSH" "Format : utilisateur@ip[:port]   ex. jules@192.168.1.42" "${LAST_TARGET:-$USER@}"
    (( RET_CANCEL )) && return 1
    local t="$RET_VAL"
    if [[ $t =~ ^([^@[:space:]]+)@([A-Za-z0-9._-]+)(:([0-9]{1,5}))?$ ]]; then
      TARGET="${BASH_REMATCH[1]}@${BASH_REMATCH[2]}"
      TGT_PORT="${BASH_REMATCH[4]:-}"
      port_args=(); [[ -n $TGT_PORT ]] && port_args=(-p "$TGT_PORT")
      break
    fi
    ui_info "Format invalide" "Attendu : utilisateur@hote\nou : utilisateur@hote:2222"
  done
  ui_info "Connexion…" "Test vers ${TARGET}${TGT_PORT:+ (port $TGT_PORT)} …" "Un instant"
  probe_target
  case $PROBE_RESULT in
    ok)    AUTH_MODE=key
           conf_save_key LAST_TARGET "$TARGET"
           ui_info "Connecté ✔" "Clé SSH acceptée pour ${TARGET}."
           return 0 ;;
    fail)  ui_info "Injoignable ✗" "${PROBE_MSG}
Vérifie l'adresse, le port, que le PC distant tourne
et possède un serveur SSH (paquet openssh-server)."
           return 1 ;;
  esac
  local tries=3 pw
  while (( tries > 0 )); do
    ui_input "Mot de passe requis" "Mot de passe SSH de ${TARGET} (masqué) :" "" mask
    [[ $RET_CANCEL == 1 ]] && return 1
    pw="$RET_VAL"
    if [[ -z $pw ]]; then ((tries--)); continue; fi
    setup_askpass "$pw"; unset pw
    if run_remote "true"; then
      AUTH_MODE=pass
      conf_save_key LAST_TARGET "$TARGET"
      return 0
    fi
    teardown_askpass
    ((tries--))
  done
  ui_info "Authentification refusée" "Mot de passe erroné après plusieurs essais,\nou droits insuffisants."
  return 1
}

###############################################################################
# Phase destination : dossier distant + organisation
###############################################################################
phase_dest_layout() {
  local opts=() patsel=()
  opts+=("📦  En vrac — tout directement dans ${DEST_DIR}"); patsel+=("")
  opts+=("🗓  Shooting_{mois}_{jour}   (préréglage)");  patsel+=("Shooting_{mois}_{jour}")
  opts+=("🗓  {annee}-{mois}-{jour}   (préréglage)");  patsel+=("{annee}-{mois}-{jour}")
  [[ -n ${CUSTOM_PATTERN:-} ]] && {
    opts+=("🗓  ${CUSTOM_PATTERN}   (personnalisé mémorisé)")
    patsel+=("$CUSTOM_PATTERN")
  }
  opts+=("✏️  Créer / éditer un motif personnalisé…")
  while :; do
    ui_menu "Organisation des photos" "Comment ranger dans ${DEST_DIR} ? (les sous-dossiers suivent la DATE de chaque photo)" "" -- \
      "${opts[@]}"
    local r=$RET_IDX
    (( r == 255 )) && return 1
    if (( r == ${#opts[@]}-1 )); then
      ui_input "Motif personnalisé" \
        "Jetons : {annee} {mois} {jour} {heure} {minute} {seconde} {mois_nom} {jour_nom}" \
        "${CUSTOM_PATTERN:-Shooting_{mois_nom}}"
      (( RET_CANCEL )) && continue
      local sample ep_samp
      for ((i=0;i<N_PHOTOS;i++)); do [[ ${SEL[i]:-0} == 1 ]] && ep_samp=${P_EPOCH[i]} && break; done
      sample="$(apply_pattern "$RET_VAL" "${ep_samp:-$(date +%s)}")"
      ui_info "Aperçu du motif" \
        "Motif saisi :  ${RET_VAL}
Exemple pour la première photo cochée :

   ${DEST_DIR}/${sample}/

Jetons utilisables : {annee} {mois} {jour} {heure}
{minute} {seconde} {mois_nom} {jour_nom}"
      CUSTOM_PATTERN="$RET_VAL"
      conf_save_key CUSTOM_PATTERN "$RET_VAL"
      DEST_PATTERN="$RET_VAL"
      return 0
    fi
    DEST_PATTERN="${patsel[r]}"
    return 0
  done
}
phase_dest() {
  local start="/home"; [[ -n ${LAST_DEST_START:-} ]] && start="$LAST_DEST_START"
  ui_browser remote "$start" || return 1
  DEST_DIR="$RET_PATH"
  if ! run_remote "cd $(sq "$DEST_DIR") 2>/dev/null && pwd -P"; then
    ui_info "Accès refusé" "Impossible d'utiliser ce dossier sur ${TARGET} :\n${RUN_OUT:0:200}"
    return 1
  fi
  conf_save_key LAST_DEST_START "$DEST_DIR"
  phase_dest_layout || return 1
  # avertissement espace disque (non bloquant)
  local free_kb dfout
  if run_remote "df -kP -- $(sq "$DEST_DIR") 2>/dev/null | awk 'NR==2{print \$4}'"; then
    free_kb="${RUN_OUT//[!0-9]/}"
    local need_kb=0 i
    for ((i=0;i<N_PHOTOS;i++)); do (( SEL[i]==1 )) && ((need_kb += P_SIZE[i]/1024 + 1)); done
    if [[ -n $free_kb ]] && (( free_kb > 0 && need_kb > free_kb )); then
      ui_info "⚠  Espace disque" "
Attention : il manque environ $(human_size $(( (need_kb-free_kb)*1024 ))) sur la destination
(besoin ≈ $(human_size $((need_kb*1024))), libre ≈ $(human_size $((free_kb*1024)))).

Continuer quand même ?" "Entrée Continuer · Échap Annuler"
      (( $? == 1 )) && return 1
    fi
  fi
  return 0
}

###############################################################################
# Planification + transfert
###############################################################################
plan_build() {
  GROUP_DIR=(); GROUP_ID_OF_FILE=()
  declare -gA GRP_MAP=()
  local i gname gid ng=0
  for ((i=0;i<N_PHOTOS;i++)); do
    (( SEL[i] == 1 )) || { GROUP_ID_OF_FILE[i]="-1"; continue; }
    if [[ -n ${DEST_PATTERN:-} ]]; then
      gname="$(apply_pattern "$DEST_PATTERN" "${P_EPOCH[i]}")"
    else
      gname=""
    fi
    if [[ -n $gname ]]; then
      gid="${GRP_MAP[$gname]:-}"
      if [[ -z $gid ]]; then
        gid=$ng; GRP_MAP[$gname]=$ng; GROUP_DIR[$ng]="$gname"; ((ng++))
      fi
      GROUP_ID_OF_FILE[i]="$gid"
    else
      GROUP_ID_OF_FILE[i]="-1"
    fi
  done
  N_GROUPS=$ng
}
plan_renames() {
  declare -gA OCCUP=()
  NAME_FINAL=(); DUP_COUNT=0
  local i bn stem ext dir key suf
  for ((i=0;i<N_PHOTOS;i++)); do
    (( SEL[i] == 1 )) || continue
    bn="${P_PATH[i]##*/}"; stem="${bn%.*}"; ext="${bn##*.}"
    dir=""
    [[ ${GROUP_ID_OF_FILE[i]:-} != "-1" ]] && dir="${GROUP_DIR[${GROUP_ID_OF_FILE[i]}]}"
    key="${dir}/${bn}"
    if [[ -n ${OCCUP[$key]:-} ]]; then
      suf="${OCCUP[$key]}"
      NAME_FINAL[i]="${stem}-${suf}.${ext}"
      OCCUP[$key]=$((suf+1)); ((DUP_COUNT++))
    else
      NAME_FINAL[i]="$bn"; OCCUP[$key]=1
    fi
  done
}

PROG_TITLE="" PROG_MSG="" 
draw_progress() { # $1 fait, $2 total, $3 texte contextuel
  local done_n=$1 total_n=$2 msg="$3"
  local pct=0; (( total_n > 0 )) && pct=$(( done_n*100/total_n ))
  local W=$(( SCR_COLS-30 )); (( W < 20 )) && W=20; (( W > 60 )) && W=60
  local fill=$(( pct*W/100 ))
  local bar nl
  printf -v bar '%*s' "$fill" ''
  bar=${bar// /━}
  printf -v nl '%*s' $((W-fill)) ''
  nl=${nl// /╺}
  scr_frame "Transfert en cours…" "$msg" "Ne quitte pas cette fenêtre !"
  printf '\e[%d;4H%s%s%s%s%s %3d%%  (%d/%d fichiers)' \
    "$((BD_TOP+1))" "$C_ACC" "$bar" "$C_DIM" "$nl" "$RST" "$pct" "$done_n" "$total_n"
}

transfer_engine() {
  AUTH_ENV_PREFIX=()
  # moteur dispo ?
  local engine=scp
  if have rsync; then
    if run_remote "command -v rsync >/dev/null 2>&1 && echo __yes__"; then
      [[ ${RUN_OUT} == *__yes__* ]] && engine=rsync
    fi
  fi
  ENGINE_USED=$engine
  FAILED=()
  TRANSFERRED=0
  local total=0 i
  for ((i=0;i<N_PHOTOS;i++)); do (( SEL[i] == 1 )) && ((total++)); done
  TOTAL_FILES=$total

  # dossiers distants
  if (( N_GROUPS > 0 )); then
    local margs="" g
    for ((g=0;g<N_GROUPS;g++)); do margs+=" $(sq "${DEST_DIR}/${GROUP_DIR[g]}")"; done
    if ! run_remote "mkdir -p -- ${margs}"; then
      ui_info "Erreur" "Impossible de créer les dossiers distants :\n${RUN_OUT:0:300}"
      return 1
    fi
  fi

  local SSH_CMDLINE="ssh ${ssh_args[*]} ${port_args[*]:-}"

  if [[ $engine == rsync ]]; then
    local g dst listf log pid line pct files_done_before collide ci
    for (( g=-1; g<N_GROUPS; g++ )); do
      if (( g == -1 )); then
        dst="$DEST_DIR"
      else
        dst="${DEST_DIR}/${GROUP_DIR[g]}"
      fi
      listf="$(mktemp)"; log="$(mktemp)"; collide=()
      for ((i=0;i<N_PHOTOS;i++)); do
        (( SEL[i] == 1 )) || continue
        if [[ $g == -1 ]]; then
          [[ ${GROUP_ID_OF_FILE[i]} == "-1" ]] || continue
        else
          [[ ${GROUP_ID_OF_FILE[i]} == "$g" ]] || continue
        fi
        if [[ ${NAME_FINAL[i]} == "${P_PATH[i]##*/}" ]]; then
          printf '%s\n' "${P_PATH[i]}" >>"$listf"
        else
          collide+=("$i")
        fi
      done
      files_done_before=$TRANSFERRED
      if [[ -s $listf ]]; then
        ( cd "$SRC_DIR" &&
          auth_env_maybe rsync -a --files-from="$listf" --info=progress2 \
            -e "$SSH_CMDLINE" ./ "${TARGET}:${dst}/" ) </dev/null >"$log" 2>&1 &
        pid=$!
        while kill -0 "$pid" 2>/dev/null; do
          line="$(tr '\r' '\n' <"$log" | grep . | tail -n1)"
          pct="$(grep -o '[0-9]\{1,3\}%' <<<"${line:-}" | tail -n1 | tr -d '%')"
          draw_progress $((TRANSFERRED)) "$TOTAL_FILES" \
            "${dst##*/} — ${line:0:$((SCR_COLS-40))}"
          sleep 0.3
        done
        wait "$pid"; local rc=$?
        if (( rc == 0 )); then
          TRANSFERRED=$((TRANSFERRED+$(grep -c . "$listf")))
        else
          while IFS= read -r f; do [[ -n $f ]] && FAILED+=("$f"); done <"$listf"
        fi
      fi
      rm -f "$listf" "$log"
      for ci in ${collide[@]+"${collide[@]}"}; do
        transfer_one_file "$ci" "$dst" "$engine" "$SSH_CMDLINE"
      done
    done
  else
    for ((i=0;i<N_PHOTOS;i++)); do
      (( SEL[i] == 1 )) || continue
      local dst="$DEST_DIR"
      [[ ${GROUP_ID_OF_FILE[i]} != "-1" ]] && dst="${DEST_DIR}/${GROUP_DIR[${GROUP_ID_OF_FILE[i]}]}"
      transfer_one_file "$i" "$dst" scp "$SSH_CMDLINE"
    done
  fi
  draw_progress "$TOTAL_FILES" "$TOTAL_FILES" "Finalisation…"
  return 0
}

transfer_one_file() { # $1 index, $2 dossier dest, $3 engine, $4 cmdline ssh
  local i="$1" dst="$2" engine="$3" sshcmd="$4"
  local src="${P_PATH[i]}" nm="${NAME_FINAL[i]}"
  draw_progress "$TRANSFERRED" "$TOTAL_FILES" "${src##*/} → ${dst##*/}"
  local ok=1
  if [[ $engine == rsync ]]; then
    ( cd "$SRC_DIR" && auth_env_maybe rsync -a -e "$sshcmd" "./${src}" "${TARGET}:${dst}/${nm}" ) \
      </dev/null >/dev/null 2>&1 || ok=0
  else
    auth_env_maybe scp -p ${port_args[@]+"${port_args[@]}"} "${ssh_args[@]}" \
      "${SRC_DIR}/${src}" "${TARGET}:${dst}/${nm}" </dev/null >/dev/null 2>&1 || ok=0
  fi
  if (( ok )); then ((TRANSFERRED++)); else FAILED+=("$src"); fi
}

phase_confirm_and_go() {
  plan_build; plan_renames
  local n=0 tot=0 i
  for ((i=0;i<N_PHOTOS;i++)); do
    (( SEL[i] == 1 )) && { ((n++, tot+=${P_SIZE[i]})); }
  done
  local orgtxt="en vrac dans ${DEST_DIR}"
  [[ -n ${DEST_PATTERN:-} ]] && orgtxt="motif « ${DEST_PATTERN} » sous ${DEST_DIR}"
  local dupwarn=""
  (( DUP_COUNT > 0 )) && dupwarn="
${C_WARN}⚠  ${DUP_COUNT} homonyme(s) seront renommés automatiquement (-1, -2…)${RST}"
  while :; do
    ui_menu "Prêt à transférer" \
      "${n} fichiers · $(human_size "$tot") → ${TARGET}:${DEST_DIR}" "" -- \
      "🚀  Lancer le transfert" \
      "◀  Revenir en arrière"
    case $RET_IDX in
      0)
        tput bel 2>/dev/null
        transfer_engine || return 1
        phase_summary; return 0 ;;
      255|1) return 1 ;;
    esac
  done
}

phase_summary() {
  local dur=$(( SECONDS - TRANSFER_T0 ))
  local dur_h="$((dur/60)) min $((dur%60)) s"
  local nfail=${#FAILED[@]}
  if (( nfail == 0 )); then
    ui_menu "Transfert terminé 🎉" \
      "${C_OK}✔ ${TRANSFERRED}/${TOTAL_FILES} fichiers envoyés en ${dur_h}${RST}
Vers ${TARGET}:${DEST_DIR}" "" -- \
      "🔄  Envoyer autre chose (même carte)" \
      "🚪  Quitter l'assistant"
    local r=$RET_IDX
    printf '\a'
    (( r == 0 )) && { WANT_NEW_BATCH=1; teardown_askpass; return 0; }
    return 0
  fi
  local lst="" k
  for ((k=0;k<nfail && k<6;k++)); do lst+="   ✗ ${FAILED[k]}\n"; done
  (( nfail > 6 )) && lst+="   … et $((nfail-6)) autres\n"
  ui_info "Terminé avec échecs ⚠" \
    "${C_WARN}${TRANSFERRED}/${TOTAL_FILES} envoyés en ${dur_h}.${RST}
${lst}
Conseils : vérifie la connexion, l'espace disque distant,
les droits d'écriture sur ${DEST_DIR}, puis relance." "Entrée Continuer"
  WANT_NEW_BATCH=1
}

###############################################################################
# Auto-test logique (--selftest)
###############################################################################
selftest() {
  local pass=0 fail=0
  chk() { if [[ "$2" == "$3" ]]; then printf 'ok    %s\n' "$1"; ((pass++));
          else printf 'FAIL  %s — attendu [%s], obtenu [%s]\n' "$1" "$2" "$3"; ((fail++)); fi; }
  chk "human_size 0"     "0 o"   "$(human_size 0)"
  chk "human_size 512"   "512 o" "$(human_size 512)"
  chk "human_size 2048"  "2 Ko"  "$(human_size 2048)"
  chk "human_size 1572864" "1,5 Mo" "$(human_size 1572864)"
  local ep; ep="$(date    -d '2024-05-07 14:30:09' +%s)"
  chk "motif Shooting"  "Shooting_05_07"   "$(apply_pattern 'Shooting_{mois}_{jour}' "$ep")"
  chk "motif iso"       "2024-05-07"       "$(apply_pattern '{annee}-{mois}-{jour}' "$ep")"
  chk "motif mois_nom"  "2024/mai"         "$(apply_pattern '{annee}/{mois_nom}' "$ep")"
  chk "motif horaire"   "2024/photos 14h30m09" "$(apply_pattern '{annee}/photos {heure}h{minute}m{seconde}' "$ep")"
  chk "motif slash-doublon" "2024/x/y"         "$(apply_pattern '{annee}//x/y///' "$ep")"
  # bornes inclusives plage dates
  local f t a b c
  f=$(date    -d '2024-05-01' +%s); t=$(date    -d '2024-05-07 + 1 day - 1 sec' +%s)
  a=$(date    -d '2024-05-01 00:00' +%s); b=$(date    -d '2024-05-07 23:59:59' +%s); c=$(date    -d '2024-05-08 00:00' +%s)
  chk "plage borne début" in "$([[ $a -ge $f && $a -le $t ]] && echo in || echo out)"
  chk "plage borne fin"   in "$([[ $b -ge $f && $b -le $t ]] && echo in || echo out)"
  chk "plage hors bornes" out "$([[ $c -ge $f && $c -le $t ]] && echo in || echo out)"
  # regroupement + collisions
  SRC_DIR=/tmp; DEST_DIR=/dest; DEST_PATTERN='{annee}-{mois}-{jour}'
  N_PHOTOS=4; SEL=(1 1 1 0); P_EPOCH=("$ep" "$ep" "$ep" "$ep")
  P_PATH=("IMG_001.JPG" "IMG_002.JPG" "IMG_001.JPG" "X.JPG"); P_SIZE=(10 20 30 99)
  plan_build
  chk "groupes construits" 1 "$N_GROUPS"
  chk "affectation groupe" 0 "${GROUP_ID_OF_FILE[0]}"
  chk "hors sélection vrac" "-1" "${GROUP_ID_OF_FILE[3]}"
  plan_renames
  chk "pas de collision"   "IMG_002.JPG"   "${NAME_FINAL[1]}"
  chk "collision renommée" "IMG_001-1.JPG" "${NAME_FINAL[2]}"
  chk "compteur collision" 1 "$DUP_COUNT"
  # prédicat extensions
  build_ext_args
  chk "prédicat CR3 présent" 1 "$(grep -c 'cr3' <<<"${EXT_ARGS[*]}")"
  printf -- '\n%d réussites / %d échecs\n' "$pass" "$fail"
  (( fail == 0 ))
}

###############################################################################
# MAIN
###############################################################################
WANT_NEW_BATCH=0
main_flow() {
  tty_init
  conf_load
  # choix carte
  if ! phase_source; then return 1; fi
  while :; do
    local rscan=0
    while :; do
      phase_scan_dir; rscan=$?
      (( rscan == 0 )) && break
      if ! phase_source; then return 1; fi   # Échap / carte vide → choisir un volume
    done
    while :; do
      phase_select; local rsel=$?
      if (( rsel == 0 )); then break
      elif (( rsel == 10 || rsel == 1 )); then return 1
      elif (( rsel == 11 )); then
        # retour dossier : re-scanner
        while :; do
          phase_scan_dir; rscan=$?
          (( rscan == 0 )) && break
          if ! phase_source; then return 1; fi
        done
      fi
    done
    phase_target || return 1
    phase_dest   || return 1
    TRANSFER_T0=$SECONDS
    phase_confirm_and_go || return 1
    (( WANT_NEW_BATCH == 1 )) || break
    WANT_NEW_BATCH=0
    # nouvelle fournée : même carte, re-scan + nouvelles sélections,
    # on garde cible/auth (destination re-demandée dans phase_dest)
    teardown_askpass
    if ! phase_source; then return 1; fi
  done
  return 0
}

case "${1:-}" in
  --version|-V) echo "sdtransfer ${SDT_VERSION}"; exit 0 ;;
  --selftest)   selftest; exit $? ;;
  -h|--help)    awk '/^# ={10,}/{flag=!flag; next} flag && /^#/ {sub(/^# ?/,""); print}' "$0"; exit 0 ;;
esac

need=(bash find sort awk date base64 mktemp stty tput scp ssh)
missing=()
for c in "${need[@]}"; do have "$c" || missing+=("$c"); done
if ((${#missing[@]} > 0)); then
  printf 'Dépendances manquantes : %s\nInstalle-les (ex. : sudo pacman -S openssh ...) puis relance.\n' \
    "${missing[*]}" >&2
  exit 2
fi
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
  printf 'bash ≥ 4.3 requis (version actuelle : %s).\n' "$BASH_VERSION" >&2
  exit 2
fi

main_flow
exit $?
