#!/bin/sh
# Drop — client en ligne de commande POSIX.
#
# Ne dépend que de curl : ni jq, ni python, ni bash. Il tourne donc tel quel sur
# une Debian minimale, un conteneur, un NAS ou un macOS.
#
# Un appareil peut être appairé sur plusieurs cercles à la fois (§17 ter) :
# configuration dans ~/.config/drop/conf, en 600, un profil par cercle rejoint
#     DROP_PROFIL_1_NOM='Marc'
#     DROP_PROFIL_1_URL='https://drop.exemple.fr'
#     DROP_PROFIL_1_JETON='…'
#     DROP_PROFILS_NB=1
#     DROP_ACTIF=1
#
# « drop cercles » liste les cercles connus, « drop --cercle <nom> … » précise
# celui visé pour une commande donnée (ou la variable DROP_CERCLE) ; sans
# précision, la commande agit sur le dernier cercle actif.

set -eu

VERSION='1.0'
DELAI_RECONNEXION_MAX=30
INTERVALLE_SONDAGE=5

# Tours de sondage passés sans réinterroger /api/jeton-mercure.php quand le
# serveur a répondu « pas de hub ». À cinq secondes le tour, un hub ajouté après
# coup est repéré en une minute au plus, sans doubler le trafic entre-temps.
TOURS_SANS_HUB=12

CONFIG_DOSSIER="${XDG_CONFIG_HOME:-$HOME/.config}/drop"
CONFIG="$CONFIG_DOSSIER/conf"
ETAT="${XDG_STATE_HOME:-$HOME/.local/state}/drop"

# ---------------------------------------------------------------- utilitaires

echec() {
    printf 'drop : %s\n' "$1" >&2
    exit 1
}

dire() {
    printf '%s\n' "$1"
}

# Un caractère de contrôle sert de marqueur temporaire pour les guillemets
# échappés : sans lui, une valeur contenant \" couperait l'extraction en deux.
MARQUEUR=$(printf '\001')

# json_chaine <champ> <json> — valeur d'un champ texte, désenchappée.
json_chaine() {
    printf '%s' "$2" |
        sed "s/\\\\\"/$MARQUEUR/g" |
        sed -n "s/.*\"$1\":\"\\([^\"]*\\)\".*/\\1/p" |
        sed -e "s/$MARQUEUR/\"/g" -e 's/\\n/\
/g' -e 's/\\t/\t/g' -e 's/\\r//g' -e 's/\\\\/\\/g'
}

# json_entier <champ> <json>
json_entier() {
    printf '%s' "$2" | sed -n "s/.*\"$1\":\\([0-9][0-9]*\\).*/\\1/p" | head -n 1
}

# json_vrai <champ> <json> — le booléen JSON vaut-il true ?
json_vrai() {
    printf '%s' "$2" | grep -q "\"$1\":true"
}

# Un item coffre (§17, phase 41) est chiffré dans le navigateur qui l'a
# envoyé : sa clé ne quitte jamais celui-ci. Ce client ne peut donc pas le
# lire, et n'a aucune raison d'afficher l'enveloppe base64 à sa place.
MENTION_CHIFFRE='[élément chiffré — à ouvrir dans le navigateur]'

# json_erreur <json> — message d'erreur de l'API, s'il y en a un.
json_erreur() {
    json_chaine 'erreur' "$1"
}

# extraire_objet <cle> <json> — contenu d'un sous-objet plat de premier niveau
# (aucune accolade imbriquée à l'intérieur). Sert à isoler « utilisateur » du
# reste de la réponse avant d'y chercher un champ, pour ne pas confondre son
# « nom » avec celui d'un item de la liste « appareils » qui l'accompagne.
extraire_objet() {
    printf '%s' "$2" | sed -n "s/.*\"$1\":{\\([^}]*\\)}.*/\\1/p"
}

# json_objets <json> — découpe un tableau d'objets, un par ligne.
# Le saut de ligne final n'est pas cosmétique : « while IFS= read -r » rend
# faux sur une dernière ligne non terminée, et la boucle perd donc l'objet.
# Avec deux items ou plus, la réversion de rattrapage() en rajoutait un par
# accident, ce qui masquait le défaut ; un item arrivant seul — le cas normal
# d'un sondage toutes les cinq secondes — disparaissait en silence.
json_objets() {
    printf '%s\n' "$1" |
        sed "s/\\\\\"/$MARQUEUR/g" |
        sed -e 's/^.*"items":\[//' \
            -e 's/\],"dernier_id".*$//' -e 's/\]}$//' \
            -e 's/},{/}\
{/g' |
        sed "s/$MARQUEUR/\\\\\"/g"
}

# echapper_json <texte> — protège un texte pour l'insérer dans du JSON.
echapper_json() {
    printf '%s' "$1" |
        sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r//g' |
        sed -e ':a' -e 'N' -e '$!ba' -e 's/\n/\\n/g'
}

# ------------------------------------------------------------- configuration

# proteger_shell <valeur> — protège une valeur pour l'écrire comme littéral
# entre quotes simples. Le nom d'un cercle vient de la réponse du serveur,
# jamais d'une valeur de confiance : sans cette protection, un nom forgé
# contenant $(…) s'exécuterait au prochain « . "$CONFIG" ».
proteger_shell() {
    printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}

# lire_profils_existants — source la configuration si elle existe et migre en
# mémoire l'ancien format à jeton unique (DROP_URL/DROP_JETON) vers un premier
# profil, sans réécrire le fichier tant qu'aucun cercle n'est rejoint ou quitté.
lire_profils_existants() {
    DROP_PROFILS_NB=0
    [ -f "$CONFIG" ] || return 0

    # shellcheck disable=SC1090
    . "$CONFIG"
    DROP_PROFILS_NB="${DROP_PROFILS_NB:-0}"

    if [ "$DROP_PROFILS_NB" -eq 0 ] && [ -n "${DROP_URL:-}" ] && [ -n "${DROP_JETON:-}" ]; then
        DROP_PROFIL_1_NOM=''
        DROP_PROFIL_1_URL="$DROP_URL"
        DROP_PROFIL_1_JETON="$DROP_JETON"
        DROP_PROFILS_NB=1
        DROP_ACTIF="${DROP_ACTIF:-1}"
    fi
}

# index_profil <nom> [url] — index (1-based) du profil portant ce nom, vide sinon.
# Avec une URL, le profil doit porter les deux : le nom seul ne suffit pas à
# identifier un cercle, puisqu'il est déclaré par le serveur (cf. ecrire_config).
index_profil() {
    cible="$1"
    cible_url="${2:-}"
    trouve=''
    i=1
    while [ "$i" -le "$DROP_PROFILS_NB" ]; do
        eval "nom_i=\${DROP_PROFIL_${i}_NOM:-}"
        eval "url_i=\${DROP_PROFIL_${i}_URL:-}"
        if [ "$nom_i" = "$cible" ] && { [ -z "$cible_url" ] || [ "$url_i" = "$cible_url" ]; }; then
            trouve="$i"
        fi
        i=$((i + 1))
    done
    printf '%s' "$trouve"
}

# charger_config — résout le cercle visé (--cercle, DROP_CERCLE, ou dernier
# actif) et remplit DROP_URL/DROP_JETON/DROP_NOM_CERCLE en conséquence ; le
# reste du script n'a jamais besoin de connaître les autres cercles.
charger_config() {
    [ -f "$CONFIG" ] || echec "aucune configuration. Commence par : drop appairer <code>"
    lire_profils_existants
    [ "$DROP_PROFILS_NB" -gt 0 ] || echec "aucun cercle configuré. Commence par : drop appairer <code>"

    cible="${DROP_CERCLE_DEMANDE:-${DROP_CERCLE:-}}"
    if [ -n "$cible" ]; then
        index=$(index_profil "$cible")
        [ -n "$index" ] || echec "cercle inconnu : $cible (voir : drop cercles)"
    else
        index="${DROP_ACTIF:-1}"
    fi

    eval "DROP_URL=\${DROP_PROFIL_${index}_URL:-}"
    eval "DROP_JETON=\${DROP_PROFIL_${index}_JETON:-}"
    eval "DROP_NOM_CERCLE=\${DROP_PROFIL_${index}_NOM:-}"

    [ -n "$DROP_URL" ] && [ -n "$DROP_JETON" ] || echec 'configuration incohérente pour ce cercle'
    DROP_URL="${DROP_URL%/}"
}

# ecrire_config <nom_cercle> <url> <jeton> — ajoute ce cercle à la liste des
# profils s'il est nouveau, ou met à jour son jeton s'il est déjà connu ; les
# autres profils restent inchangés (§17 ter : rejoindre n'efface jamais).
ecrire_config() {
    nom="$1"
    url="$2"
    jeton="$3"

    mkdir -p "$CONFIG_DOSSIER"
    chmod 700 "$CONFIG_DOSSIER"

    lire_profils_existants
    nb="$DROP_PROFILS_NB"

    # Un profil est identifié par le couple nom + serveur. Le nom vient de la
    # réponse du serveur avec qui on s'appaire : un serveur hostile qui déclare
    # « Marc » prenait sinon la place du cercle légitime, son jeton avec, et tout
    # envoi ultérieur — presse-papiers, fichiers, menu contextuel — partait chez
    # lui (scan sécurité, F14). §17 ter : rejoindre n'efface jamais.
    index=$(index_profil "$nom" "$url")
    if [ -z "$index" ]; then
        homonyme=$(index_profil "$nom")
        if [ -n "$homonyme" ]; then
            eval "url_homonyme=\${DROP_PROFIL_${homonyme}_URL:-}"
            hote="${url#*://}"
            hote="${hote%%/*}"
            nom_demande="$nom"
            nom="$nom ($hote)"
            dire "Attention : un cercle « $nom_demande » existe déjà sur $url_homonyme."
            dire "Celui-ci vient de $url : il est enregistré sous « $nom », l'autre est intact."
        fi
        nb=$((nb + 1))
        index="$nb"
    fi

    {
        i=1
        while [ "$i" -le "$nb" ]; do
            if [ "$i" = "$index" ]; then
                printf 'DROP_PROFIL_%s_NOM=%s\n' "$i" "$(proteger_shell "$nom")"
                printf 'DROP_PROFIL_%s_URL=%s\n' "$i" "$(proteger_shell "$url")"
                printf 'DROP_PROFIL_%s_JETON=%s\n' "$i" "$(proteger_shell "$jeton")"
            else
                eval "nom_i=\${DROP_PROFIL_${i}_NOM:-}"
                eval "url_i=\${DROP_PROFIL_${i}_URL:-}"
                eval "jeton_i=\${DROP_PROFIL_${i}_JETON:-}"
                printf 'DROP_PROFIL_%s_NOM=%s\n' "$i" "$(proteger_shell "$nom_i")"
                printf 'DROP_PROFIL_%s_URL=%s\n' "$i" "$(proteger_shell "$url_i")"
                printf 'DROP_PROFIL_%s_JETON=%s\n' "$i" "$(proteger_shell "$jeton_i")"
            fi
            i=$((i + 1))
        done
        printf 'DROP_PROFILS_NB=%s\n' "$nb"
        printf 'DROP_ACTIF=%s\n' "$index"
    } > "$CONFIG.tmp"

    # Le fichier porte des jetons : 600 avant d'être en place, jamais après.
    chmod 600 "$CONFIG.tmp"
    mv "$CONFIG.tmp" "$CONFIG"
}

# Un fichier par cercle : suivre « Jeux » ne doit pas mélanger sa position de
# lecture avec celle de « Marc » sur le même appareil.
fichier_etat() {
    mkdir -p "$ETAT"
    identifiant=$(printf '%s' "${DROP_NOM_CERCLE:-defaut}" | tr -c 'A-Za-z0-9_-' '_')
    printf '%s/dernier_id_%s' "$ETAT" "$identifiant"
}

# ------------------------------------------------------------------------ api

# curl_authentifie <jeton> [arguments curl…]
#
# Le jeton ne passe JAMAIS en argument de curl : argv est lisible par tout compte
# local (/proc/<pid>/cmdline, ps -ef), ce qui annulait le mode 600 de ~/.config/drop/conf
# — « drop suivre » tient d'ailleurs un curl ouvert en permanence, la fenêtre de
# lecture est donc permanente (scan sécurité, F6). --config - fait lire l'en-tête
# sur l'entrée standard, où rien ne l'expose.
curl_authentifie() {
    jeton_appel="$1"
    shift
    printf 'header = "Authorization: Bearer %s"\n' "$jeton_appel" | curl --config - "$@"
}

# nom_de_fichier_sur <nom_venu_du_serveur> <repli>
#
# Le nom vient du serveur (champ nom_fichier de l'API et du SSE). Le client s'en
# remettait à l'assainissement côté serveur : un serveur hostile — ou avec lequel
# on a été amené à s'appairer — renvoyait « ../../.bashrc » et écrivait où il
# voulait sur le poste (scan sécurité, F13). On ne garde que le dernier segment,
# et on refuse ce qui ne peut pas être un nom de fichier.
nom_de_fichier_sur() {
    nom_recu="$1"
    repli="$2"

    nom_recu="${nom_recu##*/}"      # tout chemin POSIX
    nom_recu="${nom_recu##*\\}"     # et la variante Windows
    case "$nom_recu" in
        '' | '.' | '..') nom_recu="$repli" ;;
    esac

    printf '%s' "$nom_recu"
}

# api <methode> <chemin> [donnees_json]
api() {
    methode="$1"
    chemin="$2"
    corps="${3:-}"

    if [ -n "$corps" ]; then
        reponse=$(curl_authentifie "$DROP_JETON" -sS -X "$methode" \
            -H 'Content-Type: application/json' \
            -d "$corps" "$DROP_URL$chemin") || echec 'serveur injoignable'
    else
        reponse=$(curl_authentifie "$DROP_JETON" -sS -X "$methode" \
            "$DROP_URL$chemin") || echec 'serveur injoignable'
    fi

    message=$(json_erreur "$reponse")
    [ -z "$message" ] || echec "$message"

    printf '%s' "$reponse"
}

# ---------------------------------------------------------- presse-papiers

# Ordre volontaire : Wayland, puis X11, puis macOS.
#
# Le repli se déclenche sur l'échec, pas sur l'absence — même règle que
# integrations/commun/drop-envoyer pour notify-send, et pour la même raison :
# wl-clipboard est souvent installé alors qu'aucun serveur Wayland n'écoute
# (session SSH, conteneur, machine sans écran). S'arrêter au premier outil
# présent laissait alors fuir son diagnostic sur le terminal, sans jamais
# essayer xclip qui, lui, aurait pu répondre.
ORDRE_COPIE='wl-copy|xclip -selection clipboard|pbcopy'
ORDRE_COLLAGE='wl-paste --no-newline|xclip -selection clipboard -o|pbpaste'

SANS_PRESSE_PAPIERS='aucun presse-papiers utilisable (installe wl-clipboard ou xclip, et vérifie que la session graphique est accessible)'

copier_presse_papiers() {
    reste="$ORDRE_COPIE"
    while [ -n "$reste" ]; do
        outil="${reste%%|*}"
        case "$reste" in *'|'*) reste="${reste#*|}" ;; *) reste='' ;; esac

        command -v "${outil%% *}" > /dev/null 2>&1 || continue
        printf '%s' "$1" | $outil 2> /dev/null && return 0
    done

    echec "$SANS_PRESSE_PAPIERS"
}

lire_presse_papiers() {
    reste="$ORDRE_COLLAGE"
    while [ -n "$reste" ]; do
        outil="${reste%%|*}"
        case "$reste" in *'|'*) reste="${reste#*|}" ;; *) reste='' ;; esac

        command -v "${outil%% *}" > /dev/null 2>&1 || continue
        # La substitution capture la sortie avant de la rendre : un outil qui
        # échoue ne doit rien écrire, pas même un fragment.
        sortie=$($outil 2> /dev/null) && { printf '%s' "$sortie"; return 0; }
    done

    echec "$SANS_PRESSE_PAPIERS"
}

# ------------------------------------------------------------------ affichage

formater_taille() {
    octets="$1"
    if [ "$octets" -lt 1024 ]; then printf '%s o' "$octets"
    elif [ "$octets" -lt 1048576 ]; then printf '%s ko' "$((octets / 1024))"
    elif [ "$octets" -lt 1073741824 ]; then printf '%s Mo' "$((octets / 1048576))"
    else printf '%s Go' "$((octets / 1073741824))"
    fi
}

# resume_item <objet json> — une ligne par item, pour la liste et le suivi.
resume_item() {
    objet="$1"
    identifiant=$(json_entier 'id' "$objet")
    type=$(json_chaine 'type' "$objet")
    appareil=$(json_chaine 'appareil' "$objet")

    if [ "$type" = 'fichier' ]; then
        nom=$(json_chaine 'nom_fichier' "$objet")
        taille=$(json_entier 'taille' "$objet")
        description="$nom ($(formater_taille "${taille:-0}"))"
    elif json_vrai 'chiffre' "$objet"; then
        description="$MENTION_CHIFFRE"
    else
        # Un item multiligne tient sur une ligne dans la liste : le détail
        # s'obtient avec « drop dernier ».
        description=$(json_chaine 'contenu' "$objet" | tr '\n' ' ' | cut -c1-70)
    fi

    printf '%5s  %-8s  %-18s  %s\n' "$identifiant" "$type" "$appareil" "$description"
}

# ----------------------------------------------------------------- commandes

cmd_appairer() {
    code=''
    nom_appareil=$(hostname 2>/dev/null || printf 'Machine')
    url=''

    while [ $# -gt 0 ]; do
        case "$1" in
            --nom) nom_appareil="${2:-}"; shift 2 ;;
            --nom=*) nom_appareil="${1#--nom=}"; shift ;;
            --url) url="${2:-}"; shift 2 ;;
            --url=*) url="${1#--url=}"; shift ;;
            *) code="$1"; shift ;;
        esac
    done

    [ -n "$code" ] || echec 'code manquant. Usage : drop appairer <code> [--nom NOM] [--url URL]'

    # L'URL peut déjà être connue d'un cercle déjà rejoint ; sinon il faut la
    # fournir, un code seul ne dit pas à quel serveur il s'adresse.
    if [ -z "$url" ] && [ -f "$CONFIG" ]; then
        lire_profils_existants
        actif="${DROP_ACTIF:-}"
        [ -n "$actif" ] && eval "url=\${DROP_PROFIL_${actif}_URL:-}"
    fi
    [ -n "$url" ] || echec 'URL du serveur inconnue. Ajoute : --url https://drop.exemple.fr'
    url="${url%/}"

    reponse=$(curl -sS -X POST -H 'Content-Type: application/json' \
        -d "{\"code\":\"$(echapper_json "$code")\",\"nom\":\"$(echapper_json "$nom_appareil")\",\"type\":\"cli\"}" \
        "$url/api/appairage.php?action=valider") || echec 'serveur injoignable'

    message=$(json_erreur "$reponse")
    [ -z "$message" ] || echec "$message"

    jeton=$(json_chaine 'jeton' "$reponse")
    [ -n "$jeton" ] || echec 'réponse inattendue du serveur'

    # Le nom retenu par le serveur, pas celui demandé (§17, phase 40) : un
    # homonyme actif dans le cercle le fait suffixer (« Portable 2 »), et
    # annoncer le nom envoyé ferait mentir la confirmation.
    nom_retenu=$(json_chaine 'nom' "$reponse")
    [ -n "$nom_retenu" ] && nom_appareil="$nom_retenu"

    # Le nom du cercle identifie le profil ; il vient du serveur, jamais d'une
    # saisie locale — deux appareils qui rejoignent le même cercle doivent s'y
    # retrouver sous le même nom.
    reponse_cercle=$(curl_authentifie "$jeton" -sS "$url/api/appareils.php") \
        || echec 'serveur injoignable'
    nom_cercle=$(json_chaine 'nom' "$(extraire_objet 'utilisateur' "$reponse_cercle")")
    [ -n "$nom_cercle" ] || nom_cercle="$url"

    ecrire_config "$nom_cercle" "$url" "$jeton"
    dire "Appareil « $nom_appareil » appairé au cercle « $nom_cercle ». Configuration écrite dans $CONFIG"
}

cmd_cercles() {
    lire_profils_existants
    [ "$DROP_PROFILS_NB" -gt 0 ] || { dire 'Aucun cercle configuré.'; return 0; }

    i=1
    while [ "$i" -le "$DROP_PROFILS_NB" ]; do
        eval "nom_i=\${DROP_PROFIL_${i}_NOM:-}"
        eval "url_i=\${DROP_PROFIL_${i}_URL:-}"
        [ -n "$nom_i" ] || nom_i='(sans nom)'
        marque=' '
        [ "$i" = "${DROP_ACTIF:-}" ] && marque='*'
        printf '%s %s  (%s)\n' "$marque" "$nom_i" "$url_i"
        i=$((i + 1))
    done
}

# drop renommer <nom> — change le nom de CET appareil, vu de tout le cercle
# (§17, phase 40). Le serveur n'autorise que l'appelant à se renommer, et
# suffixe le nom si un autre appareil actif du cercle le porte déjà.
cmd_renommer() {
    nouveau_nom="${1:-}"
    [ -n "$(printf '%s' "$nouveau_nom" | tr -d '[:space:]')" ] || echec 'usage : drop renommer <nom>'

    identite=$(api GET '/api/appareils.php')
    identifiant=$(json_entier 'appareil_courant' "$identite")
    [ -n "$identifiant" ] || echec 'appareil courant introuvable'

    reponse=$(api PATCH "/api/appareils.php?id=$identifiant" \
        "{\"nom\":\"$(echapper_json "$nouveau_nom")\"}")

    retenu=$(json_chaine 'nom' "$reponse")
    [ -n "$retenu" ] || echec 'réponse inattendue du serveur'

    if [ "$retenu" = "$nouveau_nom" ]; then
        dire "Cet appareil s'appelle désormais « $retenu » dans « $DROP_NOM_CERCLE »."
    else
        dire "Nom déjà pris dans « $DROP_NOM_CERCLE » : enregistré sous « $retenu »."
    fi
}

# horodatage_rappel <date> — « 2026-08-27 09:00 » (heure locale) en secondes
# Unix, avec GNU date (Linux) ou BSD date (macOS). Vide si illisible.
horodatage_rappel() {
    date -d "$1" +%s 2> /dev/null || date -j -f '%Y-%m-%d %H:%M' "$1" +%s 2> /dev/null || true
}

# champ_rappel_json — fragment JSON « ,"rappel_le":N » si --rappel a été donné.
champ_rappel_json() {
    [ -n "$DROP_RAPPEL" ] || return 0
    printf ',"rappel_le":%s' "$DROP_RAPPEL"
}

# ressemble_a_un_chemin <argument> — vrai si l'argument a la forme d'un chemin
# de fichier plutôt que d'un texte : un préfixe de chemin sans aucune espace.
# Volontairement étroit — il vaut mieux laisser passer un texte douteux que
# refuser un envoi légitime — mais il couvre la faute de frappe courante, la
# seule qui publiait un chemin local dans le flux de tout un ring.
ressemble_a_un_chemin() {
    case "$1" in
        *[[:space:]]*) return 1 ;;
        /*|./*|../*|'~'/*) return 0 ;;
        [A-Za-z]:\\*) return 0 ;;
        *) return 1 ;;
    esac
}

# mention_rappel — « , rappel le JJ/MM/AAAA HH:MM » si --rappel a été donné,
# rien sinon. GNU date d'abord, BSD date en repli (macOS).
mention_rappel() {
    [ -n "$DROP_RAPPEL" ] || return 0
    printf ', rappel le %s' \
        "$(date -d "@$DROP_RAPPEL" '+%d/%m/%Y %H:%M' 2> /dev/null ||
           date -r "$DROP_RAPPEL" '+%d/%m/%Y %H:%M')"
}

# vers_cercle — « → « Jeux » », le ring réellement visé par l'envoi. Le cercle
# actif est implicite en ligne de commande : sans ce repère, rien à l'écran ne
# distinguait un envoi dans son cercle personnel d'un envoi dans un ring
# partagé par dix personnes.
vers_cercle() {
    [ -n "${DROP_NOM_CERCLE:-}" ] || return 0
    printf ' → « %s »' "$DROP_NOM_CERCLE"
}

cmd_envoyer_texte() {
    texte="$1"
    [ -n "$(printf '%s' "$texte" | tr -d '[:space:]')" ] || echec 'contenu vide'

    reponse=$(api POST '/api/items.php' \
        "{\"type\":\"texte\",\"contenu\":\"$(echapper_json "$texte")\"$(champ_rappel_json)}")
    dire "Envoyé (item $(json_entier 'id' "$reponse"))$(vers_cercle)$(mention_rappel)"
}

cmd_envoyer_fichier() {
    chemin="$1"
    legende="${2:-}"

    [ -f "$chemin" ] || echec "fichier introuvable : $chemin"

    # « -F rappel_le= » vide est ignoré par le serveur : un seul appel curl
    # quelle que soit la combinaison d'options.
    if [ -n "$legende" ]; then
        reponse=$(curl_authentifie "$DROP_JETON" -sS -X POST \
            -F "fichier=@$chemin" -F "contenu=$legende" -F "rappel_le=$DROP_RAPPEL" \
            "$DROP_URL/api/items.php") || echec 'serveur injoignable'
    else
        reponse=$(curl_authentifie "$DROP_JETON" -sS -X POST \
            -F "fichier=@$chemin" -F "rappel_le=$DROP_RAPPEL" \
            "$DROP_URL/api/items.php") || echec 'serveur injoignable'
    fi

    message=$(json_erreur "$reponse")
    [ -z "$message" ] || echec "$message"

    dire "Envoyé : $(basename "$chemin") (item $(json_entier 'id' "$reponse"))$(vers_cercle)$(mention_rappel)"
}

cmd_liste() {
    limite=20
    while [ $# -gt 0 ]; do
        case "$1" in
            -n) limite="${2:-20}"; shift 2 ;;
            -n*) limite="${1#-n}"; shift ;;
            *) shift ;;
        esac
    done

    reponse=$(api GET "/api/items.php?limite=$limite")
    objets=$(json_objets "$reponse")

    [ -n "$objets" ] || { dire "Le flux de « $DROP_NOM_CERCLE » est vide."; return 0; }

    dire "Cercle « $DROP_NOM_CERCLE »"
    printf '%5s  %-8s  %-18s  %s\n' 'id' 'type' 'appareil' 'contenu'
    printf '%s\n' "$objets" | while IFS= read -r objet; do
        [ -n "$objet" ] || continue
        resume_item "$objet"
    done
}

# drop rappels — les items qui portent un rappel, par échéance croissante.
cmd_rappels() {
    reponse=$(api GET '/api/items.php?rappels=1&limite=500')
    objets=$(json_objets "$reponse")

    [ -n "$objets" ] || { dire "Aucun rappel programmé dans « $DROP_NOM_CERCLE »."; return 0; }

    maintenant=$(date +%s)
    dire "Cercle « $DROP_NOM_CERCLE »"
    printf '%-16s  %5s  %-18s  %s\n' 'échéance' 'id' 'appareil' 'contenu'
    printf '%s\n' "$objets" | while IFS= read -r objet; do
        [ -n "$objet" ] || continue
        echeance=$(json_entier 'rappel_le' "$objet")
        quand=$(date -d "@$echeance" '+%d/%m/%Y %H:%M' 2> /dev/null || date -r "$echeance" '+%d/%m/%Y %H:%M')
        [ "$echeance" -lt "$maintenant" ] && quand="$quand (passé)"
        identifiant=$(json_entier 'id' "$objet")
        appareil=$(json_chaine 'appareil' "$objet")
        if [ "$(json_chaine 'type' "$objet")" = 'fichier' ]; then
            description=$(json_chaine 'nom_fichier' "$objet")
        elif json_vrai 'chiffre' "$objet"; then
            description="$MENTION_CHIFFRE"
        else
            description=$(json_chaine 'contenu' "$objet" | tr '\n' ' ' | cut -c1-50)
        fi
        printf '%-16s  %5s  %-18s  %s\n' "$quand" "$identifiant" "$appareil" "$description"
    done
}

cmd_dernier() {
    copier='non'
    [ "${1:-}" = '--copier' ] && copier='oui'

    reponse=$(api GET '/api/items.php?limite=1')
    objet=$(json_objets "$reponse" | head -n 1)

    [ -n "$objet" ] || echec 'le flux est vide'

    type=$(json_chaine 'type' "$objet")
    if [ "$type" = 'fichier' ]; then
        identifiant=$(json_entier 'id' "$objet")
        nom=$(json_chaine 'nom_fichier' "$objet")
        dire "Dernier item : fichier « $nom » — drop recuperer $identifiant"
        # Un fichier n'a pas de contenu texte à copier : le taire laisserait
        # croire que le presse-papiers a été rempli.
        [ "$copier" = 'oui' ] && dire '(--copier ne s'"'"'applique qu'"'"'à un item texte)'
        return 0
    fi

    if json_vrai 'chiffre' "$objet"; then
        dire "Dernier item : $MENTION_CHIFFRE"
        [ "$copier" = 'oui' ] && dire '(rien à copier : la clé ne quitte pas le navigateur)'
        return 0
    fi

    contenu=$(json_chaine 'contenu' "$objet")

    if [ "$copier" = 'oui' ]; then
        copier_presse_papiers "$contenu"
        dire 'Copié dans le presse-papiers.'
    else
        printf '%s\n' "$contenu"
    fi
}

cmd_recuperer() {
    identifiant=''
    sortie=''

    while [ $# -gt 0 ]; do
        case "$1" in
            -o) sortie="${2:-}"; shift 2 ;;
            -o*) sortie="${1#-o}"; shift ;;
            *) identifiant="$1"; shift ;;
        esac
    done

    [ -n "$identifiant" ] || echec 'identifiant manquant. Usage : drop recuperer <id> [-o chemin]'

    # L'API ne sert pas un item isolé : on le retrouve dans le flux.
    reponse=$(api GET '/api/items.php?limite=500')
    objet=$(json_objets "$reponse" | grep "\"id\":$identifiant," | head -n 1)

    [ -n "$objet" ] || echec "item $identifiant introuvable"

    url=$(json_chaine 'url' "$objet")
    [ -n "$url" ] || echec "l'item $identifiant n'est pas un fichier"

    # -o est un choix de l'utilisateur, il passe tel quel ; le nom venu du serveur,
    # lui, est ramené à un simple nom de fichier.
    [ -n "$sortie" ] || sortie=$(nom_de_fichier_sur "$(json_chaine 'nom_fichier' "$objet")" "drop-$identifiant")
    [ -n "$sortie" ] || sortie="drop-$identifiant"

    # -C - reprend un téléchargement interrompu, ce que le serveur sait faire.
    curl_authentifie "$DROP_JETON" -sS -C - -o "$sortie" \
        "$DROP_URL$url" || echec 'téléchargement impossible'

    dire "Téléchargé : $sortie"
}

cmd_coller() {
    # La substitution de commande ouvre un sous-shell : « echec » n'y arrête
    # que lui, et l'appel repartait ensuite sur un « contenu vide » trompeur.
    # D'où la lecture d'abord, le diagnostic ensuite.
    contenu=$(lire_presse_papiers) || exit 1
    cmd_envoyer_texte "$contenu"
}

# --- Suivi du flux -------------------------------------------------------

# traiter_flux <objets> <dossier> — affiche les items reçus, et télécharge les
# fichiers si un dossier de dépôt est demandé.
traiter_item() {
    objet="$1"
    dossier="$2"

    # Un rappel qui sonne (§17, phase 33) arrive enveloppé : {"rappel":{…}}.
    # Il s'affiche comme un item, précédé d'un repère — et n'est jamais
    # retéléchargé, le fichier l'a déjà été à son arrivée.
    case "$objet" in
        '{"rappel":'*)
            printf '⏰ Rappel — '
            resume_item "$objet"
            return 0
            ;;
        '{"suppression":'*|'{"maj":'*)
            return 0
            ;;
        '{"demande":'*)
            # Demande d'adhésion au cercle (§17, phase 39) : un signal, pas un
            # item — la traiter demande l'interface, ce client n'a rien à en
            # faire sinon la signaler.
            dire 'Une demande pour rejoindre ce cercle attend une réponse (panneau « Rings »).'
            return 0
            ;;
    esac

    resume_item "$objet"

    [ -n "$dossier" ] || return 0

    url=$(json_chaine 'url' "$objet")
    [ -n "$url" ] || return 0

    nom=$(nom_de_fichier_sur "$(json_chaine 'nom_fichier' "$objet")" "drop-$(json_entier 'id' "$objet")")

    curl_authentifie "$DROP_JETON" -sS -o "$dossier/$nom" \
        "$DROP_URL$url" && dire "  ↳ enregistré dans $dossier/$nom"
}

# rattrapage <dossier> — récupère ce qui est passé pendant la coupure.
rattrapage() {
    dossier="$1"
    etat=$(fichier_etat)
    dernier=$(cat "$etat" 2> /dev/null || printf '0')

    if [ "$dernier" = '0' ]; then
        reponse=$(api GET '/api/items.php?limite=1')
    else
        reponse=$(api GET "/api/items.php?depuis=$dernier")
    fi

    nouveau=$(json_entier 'dernier_id' "$reponse")
    [ -n "$nouveau" ] && printf '%s' "$nouveau" > "$etat"

    # Au premier lancement, on ne rejoue pas l'historique : on prend le flux en
    # marche, sans quoi « drop suivre » cracherait sept jours d'items.
    [ "$dernier" = '0' ] && return 0

    json_objets "$reponse" | sed '1!G;h;$!d' | while IFS= read -r objet; do
        [ -n "$objet" ] || continue
        traiter_item "$objet" "$dossier"
    done
}

cmd_suivre() {
    dossier=''
    while [ $# -gt 0 ]; do
        case "$1" in
            --dossier) dossier="${2:-}"; shift 2 ;;
            --dossier=*) dossier="${1#--dossier=}"; shift ;;
            *) shift ;;
        esac
    done

    if [ -n "$dossier" ]; then
        mkdir -p "$dossier" || echec "dossier inutilisable : $dossier"
    fi

    dire "Suivi du flux de « $DROP_NOM_CERCLE » sur $DROP_URL — Ctrl+C pour arrêter."

    delai=1
    sans_hub=0
    while true; do
        rattrapage "$dossier"

        # Une instance sans hub (installation Apache, §7 bis du README) le
        # restera : redemander un jeton d'abonnement toutes les cinq secondes
        # doublerait le trafic du sondage pour une réponse connue d'avance. On
        # le revérifie tout de même de loin en loin — un hub peut être ajouté
        # sans que le client ait à être relancé.
        if [ "$sans_hub" -gt 0 ]; then
            sans_hub=$((sans_hub - 1))
            sleep "$INTERVALLE_SONDAGE"
            continue
        fi

        reponse=$(api GET '/api/jeton-mercure.php')
        jeton_hub=$(json_chaine 'jeton' "$reponse")
        sujet=$(json_chaine 'sujet' "$reponse")

        # Pas de hub configuré côté serveur : le rattrapage ci-dessus fait déjà
        # tout le travail, inutile d'ouvrir une connexion qui sera refusée.
        if [ -z "$sujet" ]; then
            sans_hub="$TOURS_SANS_HUB"
            sleep "$INTERVALLE_SONDAGE"
            continue
        fi
        sans_hub=0
        # Sujet propre à cet appareil (§17, phase 24) : un message ciblé n'y
        # publie jamais sur le sujet du cercle entier.
        sujet_appareil=$(json_chaine 'sujet_appareil' "$reponse")

        debut=$(date +%s)
        suivre_hub "$jeton_hub" "$sujet" "$sujet_appareil" "$dossier" || true
        duree=$(( $(date +%s) - debut ))

        # Une connexion qui tombe aussitôt signale un hub absent : inutile de
        # marteler, mais inutile aussi d'attendre trente secondes pour rien —
        # on sonde, ce que le rattrapage fait déjà à chaque tour.
        if [ "$duree" -lt 2 ]; then
            sleep "$INTERVALLE_SONDAGE"
            delai=1
            continue
        fi

        sleep "$delai"
        delai=$((delai * 2))
        [ "$delai" -gt "$DELAI_RECONNEXION_MAX" ] && delai="$DELAI_RECONNEXION_MAX"
    done
}

# suivre_hub <jeton> <sujet> <sujet_appareil> <dossier> — lit le flux SSE
# jusqu'à sa rupture. Deux sujets dans la même connexion (§17, phase 24) :
# celui du cercle, et celui de cet appareil pour les messages qui lui sont
# ciblés — Mercure les fusionne dans un seul flux.
suivre_hub() {
    etat=$(fichier_etat)

    topics="topic=$2"
    [ -n "$3" ] && topics="$topics&topic=$3"

    curl_authentifie "$1" -sN --max-time 0 \
        "$DROP_URL/.well-known/mercure?$topics" 2> /dev/null |
    while IFS= read -r ligne; do
        case "$ligne" in
            data:*)
                donnees="${ligne#data:}"
                donnees="${donnees# }"
                traiter_item "$donnees" "$4"

                # Seule une création fait avancer le repère de rattrapage :
                # un rappel ou une modification porte l'id d'un item ancien,
                # qui ferait rejouer tout ce qui est arrivé depuis.
                case "$donnees" in
                    '{"item":'*)
                        identifiant=$(json_entier 'id' "$donnees")
                        [ -n "$identifiant" ] && printf '%s' "$identifiant" > "$etat"
                        ;;
                esac
                ;;
        esac
    done
}

cmd_aide() {
    cat <<AIDE

  drop $VERSION — partage instantané entre appareils

    drop "texte"                  envoie un texte
    echo … | drop                 envoie l'entrée standard
    drop fichier.pdf […]          envoie un ou plusieurs fichiers
    drop coller                   envoie le contenu du presse-papiers
    drop renommer <nom>           renomme cet appareil, pour tout le cercle

    drop --rappel "AAAA-MM-JJ HH:MM" "texte"
                                  envoie avec un rappel à cette heure locale
                                  (valable aussi pour un fichier)
    drop liste [-n 20]            affiche les derniers items
    drop rappels                  affiche les rappels programmés
    drop dernier [--copier]       affiche le dernier item, ou le copie
    drop recuperer <id> [-o F]    télécharge un fichier
    drop suivre [--dossier D]     écoute le flux en continu

    drop appairer <code> [--nom NOM] [--url URL]
                                  rattache cette machine à un cercle,
                                  sans perdre ceux déjà rejoints
    drop cercles                  liste les cercles appairés sur cette machine

    drop --cercle NOM <commande>  cible un cercle précis pour une commande
                                  (ou la variable DROP_CERCLE) ; sans
                                  précision, agit sur le dernier cercle actif

    drop --texte "…"              force l'envoi comme texte, même si
                                  l'argument ressemble à un chemin
    drop -- "-texte"              envoie tel quel un texte commençant par -
    drop aide                     affiche cette aide
    drop version                  affiche la version

  Variables d'environnement :
    DROP_CERCLE                   cercle visé, comme --cercle

  Configuration : $CONFIG

  Presse-papiers : wl-clipboard (Wayland), xclip (X11) ou pbcopy/pbpaste
  (macOS) — le premier qui répond est utilisé.

AIDE
}

# ------------------------------------------------------------------ aiguillage

DROP_CERCLE_DEMANDE=''
DROP_RAPPEL=''
FORCER_TEXTE='non'
while [ $# -gt 0 ]; do
    case "$1" in
        --texte) FORCER_TEXTE='oui'; shift ;;
        --cercle) DROP_CERCLE_DEMANDE="${2:-}"; shift 2 ;;
        --cercle=*) DROP_CERCLE_DEMANDE="${1#--cercle=}"; shift ;;
        --rappel) DROP_RAPPEL=$(horodatage_rappel "${2:-}"); shift 2
                  [ -n "$DROP_RAPPEL" ] || echec 'date de rappel illisible, attendu « AAAA-MM-JJ HH:MM »' ;;
        --rappel=*) DROP_RAPPEL=$(horodatage_rappel "${1#--rappel=}"); shift
                  [ -n "$DROP_RAPPEL" ] || echec 'date de rappel illisible, attendu « AAAA-MM-JJ HH:MM »' ;;
        --aide|-h|--help) cmd_aide; exit 0 ;;
        --version) dire "drop $VERSION"; exit 0 ;;
        # Une option inconnue est une faute de frappe, pas un texte à envoyer :
        # « drop --rappl … » partirait sinon dans le flux sans rien signaler.
        # Le séparateur « -- » reste la porte de sortie pour un texte qui
        # commence vraiment par un tiret.
        --) shift; break ;;
        -*) echec "option inconnue : $1 (voir : drop aide, ou « drop -- $1 » pour l'envoyer tel quel)" ;;
        *) break ;;
    esac
done

[ $# -eq 0 ] && [ -t 0 ] && { cmd_aide; exit 0; }

commande="${1:-}"

case "$commande" in
    appairer)
        shift
        cmd_appairer "$@"
        ;;
    cercles)
        cmd_cercles
        ;;
    liste|rappels|dernier|recuperer|suivre|coller|renommer)
        charger_config
        shift
        "cmd_$commande" "$@"
        ;;
    aide|help|--aide|-h|--help)
        cmd_aide
        ;;
    version|--version)
        dire "drop $VERSION"
        ;;
    '')
        # Aucun argument et une entrée standard redirigée : c'est un tube.
        charger_config
        cmd_envoyer_texte "$(cat)"
        ;;
    *)
        charger_config

        # Tout argument qui désigne un fichier existant est envoyé comme
        # fichier ; le reste est du texte. C'est ce qui permet aux intégrations
        # de bureau d'appeler « drop <fichiers…> » sans se poser de question.
        if [ -f "$commande" ]; then
            for chemin in "$@"; do
                cmd_envoyer_fichier "$chemin"
            done
        else
            # Sauf si l'argument a la forme d'un chemin sans exister : une
            # frappe manquée sur un nom de fichier publiait sinon le chemin
            # lui-même dans le flux de tout le ring — « /home/marc/photo.jpg »
            # lu par tous les membres, sans que rien ne le signale.
            if [ "$FORCER_TEXTE" != 'oui' ] && ressemble_a_un_chemin "$commande"; then
                echec "fichier introuvable : $commande (pour l'envoyer quand même comme texte : drop --texte …)"
            fi
            cmd_envoyer_texte "$*"
        fi
        ;;
esac
