#!/usr/bin/bash
#
# opendkim-manager.sh — gestion des clés DKIM pour OpenDKIM sur l'hôte.
#
# Remplace old/generate_dkim.sh : génération de clés plus robuste, ajout d'une
# opération de suppression, et sortie JSON exploitable par une commande shell
# lancée depuis l'Ancalagon Manager.
#
# Contrat de sortie :
#   - STDOUT  : toujours un unique objet JSON (le résultat). Rien d'autre.
#   - STDERR  : messages de progression / avertissements destinés à l'humain.
#   - code de sortie : 0 = succès, 1 = erreur opérationnelle, 2 = erreur d'usage.
#   Chaque objet porte "schema_version" (entier, incrémenté en cas de
#   changement incompatible) et "status" ("success" | "error").
#
# Usage :
#   opendkim-manager.sh create -d <domaine> -s <sélecteur> [-b <bits>] [--force] [--restart]
#   opendkim-manager.sh delete -d <domaine> -s <sélecteur> [--restart]
#   opendkim-manager.sh list
#
# Variables d'environnement (surcharge, utile pour les tests sans root) :
#   OPENDKIM_DIR (def. /etc/opendkim), OPENDKIM_KEYS_DIR, OPENDKIM_USER,
#   OPENDKIM_GROUP, OPENDKIM_KEYTABLE, OPENDKIM_SIGNINGTABLE,
#   OPENDKIM_TRUSTEDHOSTS, OPENDKIM_SERVICE.

set -euo pipefail
umask 077
# Collation/typage ASCII déterministe : les classes [a-z] des regex de
# validation et le découpage octet par octet de json_escape ne doivent pas
# dépendre de la locale héritée.
export LC_ALL=C

if [[ ${BASH_VERSINFO[0]:-0} -lt 4 ]]; then
    echo "bash >= 4 requis" >&2
    exit 2
fi

readonly SCHEMA_VERSION=1

# --- Configuration (surchargeable par l'environnement) ----------------------
OPENDKIM_DIR=${OPENDKIM_DIR:-/etc/opendkim}
KEYS_DIR=${OPENDKIM_KEYS_DIR:-$OPENDKIM_DIR/keys}
OPENDKIM_USER=${OPENDKIM_USER:-opendkim}
OPENDKIM_GROUP=${OPENDKIM_GROUP:-opendkim}
KEYTABLE=${OPENDKIM_KEYTABLE:-$OPENDKIM_DIR/KeyTable}
SIGNINGTABLE=${OPENDKIM_SIGNINGTABLE:-$OPENDKIM_DIR/SigningTable}
TRUSTEDHOSTS=${OPENDKIM_TRUSTEDHOSTS:-$OPENDKIM_DIR/TrustedHosts}
SERVICE=${OPENDKIM_SERVICE:-opendkim}
LOCKFILE=$OPENDKIM_DIR/.opendkim-manager.lock
DNS_TIMEOUT=${OPENDKIM_DNS_TIMEOUT:-3}

# --- État global ------------------------------------------------------------
OPERATION=""
DOMAIN=""
SELECTOR=""
BITS=2048
FORCE=0
RESTART=0
QUIET=0
CHECK_DNS=1
RESULT_EMITTED=0
LOCK_ACQUIRED=0
TMPFILES=()
STAGEDIRS=()

# --- Sorties humaines (toujours sur stderr) ---------------------------------
info() { [[ $QUIET -eq 1 ]] || printf '%s\n' "$*" >&2; }
warn() { printf 'Attention: %s\n' "$*" >&2; }

now_ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }

# Échappe une chaîne pour l'inclure dans du JSON (RFC 8259), y compris tous les
# caractères de contrôle restants (0x01-0x1F, 0x7F) sous forme \u00XX.
json_escape() {
    local s=${1-}
    s=${s//\\/\\\\}
    s=${s//\"/\\\"}
    s=${s//$'\b'/\\b}
    s=${s//$'\f'/\\f}
    s=${s//$'\n'/\\n}
    s=${s//$'\r'/\\r}
    s=${s//$'\t'/\\t}
    if [[ $s == *[$'\x01'-$'\x1f\x7f']* ]]; then
        local out='' i ch
        for ((i = 0; i < ${#s}; i++)); do
            ch=${s:i:1}
            if [[ $ch == [$'\x01'-$'\x1f\x7f'] ]]; then
                printf -v ch '\\u%04x' "'$ch"
            fi
            out+=$ch
        done
        s=$out
    fi
    printf '%s' "$s"
}

count_lines() {
    if [[ -f $1 ]]; then awk 'END{print NR+0}' "$1"; else echo 0; fi
}

# --- Émission du résultat JSON (stdout) -------------------------------------
emit_error() {
    # emit_error <code> <message> [exit_code]
    local code=$1 msg=$2 ec=${3:-1}
    RESULT_EMITTED=1
    printf '{"schema_version":%s,"status":"error","operation":"%s","domain":"%s","selector":"%s","error_code":"%s","error":"%s","timestamp":"%s"}\n' \
        "$SCHEMA_VERSION" "$(json_escape "$OPERATION")" "$(json_escape "$DOMAIN")" "$(json_escape "$SELECTOR")" \
        "$(json_escape "$code")" "$(json_escape "$msg")" "$(now_ts)"
    exit "$ec"
}

fail() { emit_error "$1" "$2" "${3:-1}"; }
usage_err() {
    print_usage >&2
    emit_error "USAGE" "$1" 2
}

emit_help() {
    RESULT_EMITTED=1
    printf '{"schema_version":%s,"status":"success","operation":"help","timestamp":"%s"}\n' \
        "$SCHEMA_VERSION" "$(now_ts)"
}

# --- Nettoyage / garantie d'un JSON en sortie -------------------------------
cleanup_tmp() {
    local f d
    if ((${#TMPFILES[@]})); then
        for f in "${TMPFILES[@]}"; do [[ -e $f ]] && rm -f "$f"; done
    fi
    if ((${#STAGEDIRS[@]})); then
        for d in "${STAGEDIRS[@]}"; do [[ -d $d ]] && rm -rf "$d"; done
    fi
}

on_exit() {
    local ec=$?
    # Toujours produire un JSON valide, même en cas d'échec inattendu.
    # On écrit en ligne (sans appeler emit_error qui ferait exit) afin que
    # cleanup_tmp s'exécute systématiquement ensuite.
    if [[ $RESULT_EMITTED -eq 0 && $ec -ne 0 ]]; then
        RESULT_EMITTED=1
        printf '{"schema_version":%s,"status":"error","operation":"%s","domain":"%s","selector":"%s","error_code":"INTERNAL","error":"%s","timestamp":"%s"}\n' \
            "$SCHEMA_VERSION" "$(json_escape "$OPERATION")" "$(json_escape "$DOMAIN")" "$(json_escape "$SELECTOR")" \
            "$(json_escape "échec inattendu (code $ec)")" "$(now_ts)"
    fi
    cleanup_tmp
}
trap on_exit EXIT

# --- Validation des entrées -------------------------------------------------
validate_domain() {
    local d=$1
    [[ ${#d} -le 253 ]] || return 1
    [[ $d =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]
}

validate_selector() {
    local s=$1
    [[ ${#s} -le 63 ]] || return 1
    [[ $s =~ ^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?$ ]]
}

require_domain() {
    [[ -n $DOMAIN ]] || usage_err "option requise manquante : -d <domaine>"
    DOMAIN=${DOMAIN,,}
    validate_domain "$DOMAIN" || fail "INVALID_DOMAIN" "domaine invalide : $DOMAIN"
}

require_domain_selector() {
    require_domain
    [[ -n $SELECTOR ]] || usage_err "option requise manquante : -s <sélecteur>"
    validate_selector "$SELECTOR" || fail "INVALID_SELECTOR" "sélecteur invalide : $SELECTOR"
}

# --- Système de fichiers ----------------------------------------------------
set_owner() {
    # Applique l'utilisateur/groupe opendkim si présent et si on est root.
    local p=$1
    if id -u "$OPENDKIM_USER" >/dev/null 2>&1 && [[ $(id -u) -eq 0 ]]; then
        chown -h "$OPENDKIM_USER:$OPENDKIM_GROUP" "$p" 2>/dev/null || warn "chown impossible : $p"
    fi
}

preserve_meta() {
    # Recopie permissions et propriétaire de <src> vers <dst> (avant rename).
    local src=$1 dst=$2
    [[ -e $src ]] || return 0
    chmod --reference="$src" "$dst" 2>/dev/null || true
    chown --reference="$src" "$dst" 2>/dev/null || true
}

ensure_base_dirs() {
    local d
    for d in "$OPENDKIM_DIR" "$KEYS_DIR"; do
        if [[ ! -d $d ]]; then
            mkdir -p "$d" 2>/dev/null || fail "PERMISSION" "création impossible de $d (root requis ?)"
        fi
    done
    [[ -w $OPENDKIM_DIR ]] || fail "PERMISSION" "$OPENDKIM_DIR non accessible en écriture (root requis ?)"
}

ensure_file() {
    local f=$1
    if [[ ! -e $f ]]; then
        : > "$f" 2>/dev/null || fail "PERMISSION" "création impossible de $f"
        chmod 644 "$f" 2>/dev/null || true
        set_owner "$f"
    fi
}

new_tmp() { mktemp "${1}.opendkim-mgr.XXXXXX"; }

# Remplace atomiquement plusieurs fichiers (paires dest tmp) en bloc.
# Sauvegarde chaque destination avant de la remplacer ; en cas d'échec d'un
# rename, restaure toutes les destinations déjà modifiées et renvoie 1.
commit_tables() {
    local -a dest=() tmp=() bak=()
    while (($#)); do
        dest+=("$1")
        tmp+=("$2")
        shift 2
    done
    local i b ok=1
    for i in "${!dest[@]}"; do
        preserve_meta "${dest[$i]}" "${tmp[$i]}"
        if [[ -e ${dest[$i]} ]]; then
            if b=$(new_tmp "${dest[$i]}"); then
                TMPFILES+=("$b")
                cp -p "${dest[$i]}" "$b" 2>/dev/null || { ok=0; break; }
                bak[$i]=$b
            else
                ok=0
                break
            fi
        else
            bak[$i]=""
        fi
    done
    if [[ $ok -eq 1 ]]; then
        for i in "${!dest[@]}"; do
            mv -f "${tmp[$i]}" "${dest[$i]}" || { ok=0; break; }
        done
    fi
    if [[ $ok -eq 0 ]]; then
        for i in "${!dest[@]}"; do
            if [[ -n ${bak[$i]:-} ]]; then
                cp -p "${bak[$i]}" "${dest[$i]}" 2>/dev/null || true
            fi
        done
        return 1
    fi
    return 0
}

with_lock() {
    [[ $LOCK_ACQUIRED -eq 1 ]] && return 0
    exec 9>"$LOCKFILE" || fail "PERMISSION" "ouverture du verrou impossible : $LOCKFILE"
    flock -w 30 9 || fail "LOCK_TIMEOUT" "verrou non acquis sous 30s"
    LOCK_ACQUIRED=1
}

maybe_restart() {
    RESTARTED_BOOL=false
    RESTART_REQUIRED=true
    if [[ $RESTART -eq 1 ]]; then
        local out
        if out=$(systemctl restart "$SERVICE" 2>&1); then
            RESTARTED_BOOL=true
            RESTART_REQUIRED=false
            info "service $SERVICE redémarré"
        else
            warn "échec du redémarrage de $SERVICE : $(printf '%s' "$out" | tr '\n' ' ')"
        fi
    fi
}

# --- Clé publique -----------------------------------------------------------
# Extrait l'enregistrement DKIM d'un fichier .txt produit par opendkim-genkey :
# concatène les segments entre guillemets. Renseigne RECORD_VALUE (la valeur TXT
# complète « v=DKIM1; … p=… ») et PUBKEY_P (la valeur base64 du champ p=).
# Renvoie 1 si le fichier est illisible ou ne contient pas de champ p=.
parse_public_record() {
    local f=$1 val
    val=$(grep -o '"[^"]*"' "$f" 2>/dev/null | sed -e 's/^"//' -e 's/"$//' | tr -d '\n') || true
    [[ -n $val ]] || return 1
    [[ $val == *p=* ]] || return 1
    RECORD_VALUE=$val
    PUBKEY_P=${val#*p=}
    PUBKEY_P=${PUBKEY_P// /}
    return 0
}

# Repli : reconstruit la clé publique depuis la clé privée (si le .txt manque).
# Nécessite la commande openssl. Renseigne RECORD_VALUE / PUBKEY_P, renvoie 1
# si openssl est absent ou la dérivation échoue.
derive_public_from_private() {
    local f=$1 b64
    command -v openssl >/dev/null 2>&1 || return 1
    b64=$(openssl rsa -in "$f" -pubout -outform DER 2>/dev/null | base64 | tr -d '\n') || true
    [[ -n $b64 ]] || return 1
    PUBKEY_P=$b64
    RECORD_VALUE="v=DKIM1; h=sha256; k=rsa; p=$b64"
    return 0
}

# --- Vérification DNS -------------------------------------------------------
dns_tool_available() {
    command -v dig >/dev/null 2>&1 || command -v host >/dev/null 2>&1
}

# Interroge le rrset TXT de <fqdn> et imprime chaque enregistrement réassemblé
# (segments « character-string » concaténés) sur sa propre ligne. Renvoie 0 si
# une réponse DNS a pu être obtenue (même vide), 1 si le résolveur est injoignable.
dns_query_txt() {
    local fqdn=$1 raw
    if command -v dig >/dev/null 2>&1; then
        # dig exits 0 even on NXDOMAIN/NoData (a DNS answer was received); a
        # non-zero exit means the resolver was unreachable -> signal failure.
        raw=$(dig +short +time="$DNS_TIMEOUT" +tries=2 TXT "$fqdn" 2>/dev/null) || return 1
    elif command -v host >/dev/null 2>&1; then
        # host renvoie non-zéro sur NXDOMAIN (= « pas d'enregistrement ») ; on ne
        # peut donc pas distinguer proprement l'injoignabilité — best-effort.
        raw=$(host -W "$DNS_TIMEOUT" -t TXT "$fqdn" 2>/dev/null) || true
    else
        return 1
    fi
    printf '%s\n' "$raw" | while IFS= read -r line; do
        [[ $line == *'"'* ]] || continue
        grep -o '"[^"]*"' <<< "$line" | sed -e 's/^"//' -e 's/"$//' | tr -d '\n'
        printf '\n'
    done
    return 0
}

# Renseigne DNS_CHECKED / DNS_PUBLISHED / DNS_MATCH / DNS_ERROR pour le couple
# courant à partir du rrset DKIM publié. Best-effort : ne fait jamais échouer get.
check_dns_publication() {
    local fqdn="$SELECTOR._domainkey.$DOMAIN" raw rec pub
    if ! dns_tool_available; then
        DNS_ERROR="aucun outil DNS (dig ou host) disponible"
        return 0
    fi
    if ! raw=$(dns_query_txt "$fqdn"); then
        DNS_ERROR="requête DNS impossible pour $fqdn (résolveur injoignable ?)"
        return 0
    fi
    DNS_CHECKED=true
    while IFS= read -r rec; do
        [[ -z $rec ]] && continue
        case $rec in
            *v=DKIM1*|*p=*) ;;
            *) continue ;;
        esac
        DNS_PUBLISHED=true
        pub=${rec#*p=}
        pub=${pub%%;*}
        pub=${pub// /}
        if [[ -n $PUBKEY_P && $pub == "$PUBKEY_P" ]]; then
            DNS_MATCH=true
        fi
    done <<< "$raw"
    return 0
}

# --- Commande : create ------------------------------------------------------
build_tables_create() {
    # Construit les nouvelles tables dans les fichiers temporaires fournis.
    # Chaque awk est isolé pour que son échec (fichier illisible, etc.) se
    # propage et empêche un commit de table tronquée.
    local kt=$1 st=$2 th=$3
    # KeyTable : supprime toute entrée existante pour cette clé, puis ajoute.
    awk -v k="$KEYNAME" '$1!=k' "$KEYTABLE" > "$kt" || return 1
    printf '%s %s:%s:%s\n' "$KEYNAME" "$DOMAIN" "$SELECTOR" "$PRIV_PATH" >> "$kt" || return 1
    # SigningTable : une seule règle *@domaine -> cette clé.
    awk -v d="*@$DOMAIN" '$1!=d' "$SIGNINGTABLE" > "$st" || return 1
    printf '%s %s\n' "*@$DOMAIN" "$KEYNAME" >> "$st" || return 1
    # TrustedHosts : ajoute le domaine s'il est absent (awk renormalise les
    # fins de ligne, donc pas de fusion si le fichier n'a pas de \n final).
    awk -v d="$DOMAIN" '{print} $0==d{seen=1} END{if(!seen) print d}' "$TRUSTEDHOSTS" > "$th" || return 1
    return 0
}

cmd_create() {
    OPERATION=create
    require_domain_selector
    [[ $BITS =~ ^[1-9][0-9]*$ ]] || fail "INVALID_BITS" "le nombre de bits doit être un entier positif : $BITS"
    (( BITS >= 1024 )) || fail "INVALID_BITS" "le nombre de bits doit être >= 1024"
    command -v opendkim-genkey >/dev/null 2>&1 || fail "MISSING_TOOL" "opendkim-genkey introuvable (paquet opendkim-tools)"
    ensure_base_dirs
    # Verrou acquis avant le test d'existence et l'installation de la clé :
    # toute la section critique (test, génération, pose, tables) est sérialisée.
    with_lock

    KEYNAME="$SELECTOR._domainkey.$DOMAIN"
    local domdir="$KEYS_DIR/$DOMAIN"
    PRIV_PATH="$domdir/$SELECTOR.private"
    TXT_PATH="$domdir/$SELECTOR.txt"

    if [[ -e $PRIV_PATH && $FORCE -ne 1 ]]; then
        fail "KEY_EXISTS" "la clé existe déjà : $PRIV_PATH (utilisez --force ou 'delete' d'abord)"
    fi

    # Génération dans une zone de staging.
    local stage
    stage=$(mktemp -d "${TMPDIR:-/tmp}/opendkim-mgr.XXXXXX") || fail "INTERNAL" "mktemp -d a échoué"
    STAGEDIRS+=("$stage")

    local gen_out
    if ! gen_out=$(opendkim-genkey -b "$BITS" -d "$DOMAIN" -s "$SELECTOR" -D "$stage" 2>&1); then
        fail "GENKEY_FAILED" "opendkim-genkey a échoué : $(printf '%s' "$gen_out" | tr '\n' ' ')"
    fi
    local stage_priv="$stage/$SELECTOR.private" stage_txt="$stage/$SELECTOR.txt"
    [[ -f $stage_priv && -f $stage_txt ]] || fail "GENKEY_FAILED" "fichiers de clé non produits par opendkim-genkey"

    # Extraction de la clé publique depuis l'enregistrement généré.
    parse_public_record "$stage_txt" || fail "PARSE_ERROR" "clé publique illisible dans $stage_txt"
    RECORD_NAME="$KEYNAME"

    # Prépare la clé en fichiers temporaires posés À CÔTÉ de leur destination.
    # Rien n'est écrit sur les chemins réels (clé ou tables) avant l'unique
    # commit atomique : tout échec antérieur ne laisse que des temporaires, et
    # une clé existante (--force) n'est jamais écrasée tant que le commit n'a
    # pas réussi.
    mkdir -p "$domdir" || fail "PERMISSION" "création impossible de $domdir"
    chmod 700 "$domdir" 2>/dev/null || true
    set_owner "$domdir"

    local key_stage txt_stage
    key_stage=$(new_tmp "$PRIV_PATH") && TMPFILES+=("$key_stage") || fail "INTERNAL" "mktemp a échoué"
    cp "$stage_priv" "$key_stage" || fail "PERMISSION" "préparation de la clé privée impossible"
    chmod 600 "$key_stage" 2>/dev/null || true
    set_owner "$key_stage"
    txt_stage=$(new_tmp "$TXT_PATH") && TMPFILES+=("$txt_stage") || fail "INTERNAL" "mktemp a échoué"
    cp "$stage_txt" "$txt_stage" || fail "PERMISSION" "préparation de l'enregistrement TXT impossible"
    chmod 644 "$txt_stage" 2>/dev/null || true
    set_owner "$txt_stage"

    ensure_file "$KEYTABLE"
    ensure_file "$SIGNINGTABLE"
    ensure_file "$TRUSTEDHOSTS"

    # Avertit si l'on remplace le sélecteur actif d'un domaine (rotation) :
    # l'ancienne clé reste en place et devra être supprimée via 'delete'.
    local prev_key
    prev_key=$(awk -v d="*@$DOMAIN" '$1==d{print $2; exit}' "$SIGNINGTABLE" 2>/dev/null || true)
    if [[ -n $prev_key && $prev_key != "$KEYNAME" ]]; then
        warn "sélecteur actif précédent pour $DOMAIN ($prev_key) remplacé ; l'ancienne clé reste en place (fenêtre de rotation) — supprimez-la ensuite avec 'delete'."
    fi

    local kt_new st_new th_new
    kt_new=$(new_tmp "$KEYTABLE") && TMPFILES+=("$kt_new") || fail "INTERNAL" "mktemp a échoué"
    st_new=$(new_tmp "$SIGNINGTABLE") && TMPFILES+=("$st_new") || fail "INTERNAL" "mktemp a échoué"
    th_new=$(new_tmp "$TRUSTEDHOSTS") && TMPFILES+=("$th_new") || fail "INTERNAL" "mktemp a échoué"

    build_tables_create "$kt_new" "$st_new" "$th_new" \
        || fail "TABLE_UPDATE_FAILED" "construction des tables OpenDKIM impossible"

    # Commit atomique unique : clé privée, enregistrement TXT et trois tables.
    # commit_tables sauvegarde chaque destination, déplace les temporaires, et
    # restaure tout si un déplacement échoue.
    commit_tables \
        "$KEYTABLE" "$kt_new" \
        "$SIGNINGTABLE" "$st_new" \
        "$TRUSTEDHOSTS" "$th_new" \
        "$PRIV_PATH" "$key_stage" \
        "$TXT_PATH" "$txt_stage" \
        || fail "TABLE_UPDATE_FAILED" "commit OpenDKIM impossible"

    info "clé DKIM créée pour $DOMAIN (sélecteur $SELECTOR)"
    if [[ $FORCE -eq 1 ]]; then FORCE_BOOL=true; else FORCE_BOOL=false; fi
    maybe_restart
    emit_create
}

emit_create() {
    RESULT_EMITTED=1
    printf '{'
    printf '"schema_version":%s,' "$SCHEMA_VERSION"
    printf '"status":"success",'
    printf '"operation":"create",'
    printf '"domain":"%s",' "$(json_escape "$DOMAIN")"
    printf '"selector":"%s",' "$(json_escape "$SELECTOR")"
    printf '"created":true,'
    printf '"forced":%s,' "$FORCE_BOOL"
    printf '"key_bits":%s,' "$BITS"
    printf '"private_key_path":"%s",' "$(json_escape "$PRIV_PATH")"
    printf '"public_key":"%s",' "$(json_escape "$PUBKEY_P")"
    printf '"dns_record_name":"%s",' "$(json_escape "$RECORD_NAME")"
    printf '"dns_record_type":"TXT",'
    printf '"dns_record_value":"%s",' "$(json_escape "$RECORD_VALUE")"
    printf '"tables_updated":["KeyTable","SigningTable","TrustedHosts"],'
    printf '"service_restart_required":%s,' "$RESTART_REQUIRED"
    printf '"service_restarted":%s,' "$RESTARTED_BOOL"
    printf '"timestamp":"%s"' "$(now_ts)"
    printf '}\n'
}

# --- Commande : delete ------------------------------------------------------
cmd_delete() {
    OPERATION=delete
    require_domain_selector
    ensure_base_dirs
    with_lock

    KEYNAME="$SELECTOR._domainkey.$DOMAIN"
    local domdir="$KEYS_DIR/$DOMAIN"
    local priv="$domdir/$SELECTOR.private" txt="$domdir/$SELECTOR.txt"

    RM_KT=0 RM_ST=0 RM_TH=0
    RM_PRIV=false RM_TXT=false RM_DIR=false EXISTED_BOOL=false
    REMAINING=0 SIGNING_ACTIVE=true

    local before after kt_new="" st_new="" th_new=""
    local -a commit_args=()

    # KeyTable : supprime l'entrée exacte de cette clé.
    if [[ -f $KEYTABLE ]]; then
        before=$(count_lines "$KEYTABLE")
        kt_new=$(new_tmp "$KEYTABLE") && TMPFILES+=("$kt_new") || fail "INTERNAL" "mktemp a échoué"
        awk -v k="$KEYNAME" '$1!=k' "$KEYTABLE" > "$kt_new" || fail "TABLE_UPDATE_FAILED" "lecture de KeyTable impossible"
        after=$(count_lines "$kt_new")
        RM_KT=$((before - after))
        commit_args+=("$KEYTABLE" "$kt_new")
    fi

    # Reste-t-il d'autres sélecteurs pour ce domaine (après suppression) ?
    local kt_src=${kt_new:-$KEYTABLE}
    if [[ -f $kt_src ]]; then
        REMAINING=$(awk -v d="$DOMAIN" 'NF>=2{split($2,a,":"); if(a[1]==d) c++} END{print c+0}' "$kt_src")
    fi

    # SigningTable : supprime les règles pointant vers cette clé.
    if [[ -f $SIGNINGTABLE ]]; then
        before=$(count_lines "$SIGNINGTABLE")
        st_new=$(new_tmp "$SIGNINGTABLE") && TMPFILES+=("$st_new") || fail "INTERNAL" "mktemp a échoué"
        awk -v v="$KEYNAME" '$2!=v' "$SIGNINGTABLE" > "$st_new" || fail "TABLE_UPDATE_FAILED" "lecture de SigningTable impossible"
        after=$(count_lines "$st_new")
        RM_ST=$((before - after))
        commit_args+=("$SIGNINGTABLE" "$st_new")
    fi

    # TrustedHosts : retire le domaine seulement s'il n'a plus aucun sélecteur.
    if [[ $REMAINING -eq 0 && -f $TRUSTEDHOSTS ]]; then
        before=$(count_lines "$TRUSTEDHOSTS")
        th_new=$(new_tmp "$TRUSTEDHOSTS") && TMPFILES+=("$th_new") || fail "INTERNAL" "mktemp a échoué"
        awk -v d="$DOMAIN" '$0!=d' "$TRUSTEDHOSTS" > "$th_new" || fail "TABLE_UPDATE_FAILED" "lecture de TrustedHosts impossible"
        after=$(count_lines "$th_new")
        RM_TH=$((before - after))
        commit_args+=("$TRUSTEDHOSTS" "$th_new")
    fi

    # Commit atomique des tables, puis seulement suppression des fichiers de clé.
    if ((${#commit_args[@]})); then
        commit_tables "${commit_args[@]}" || fail "TABLE_UPDATE_FAILED" "commit des tables OpenDKIM impossible"
    fi

    if [[ -f $priv ]]; then rm -f "$priv" && RM_PRIV=true; fi
    if [[ -f $txt ]]; then rm -f "$txt" && RM_TXT=true; fi
    if [[ -d $domdir ]]; then
        if rmdir "$domdir" 2>/dev/null; then RM_DIR=true; fi
    fi

    # Le domaine a-t-il encore des sélecteurs mais plus aucune règle de
    # signature ? (rotation : il faut recréer une règle pour signer à nouveau)
    if [[ $REMAINING -gt 0 && -f $SIGNINGTABLE ]]; then
        if ! awk -v d="*@$DOMAIN" '$1==d{f=1} END{exit f?0:1}' "$SIGNINGTABLE"; then
            SIGNING_ACTIVE=false
            warn "le domaine $DOMAIN possède encore $REMAINING sélecteur(s) mais plus aucune règle SigningTable : il ne signe plus. Recréez une règle avec 'create'."
        fi
    fi

    if [[ $RM_KT -gt 0 || $RM_ST -gt 0 || $RM_TH -gt 0 || $RM_PRIV == true || $RM_TXT == true ]]; then
        EXISTED_BOOL=true
        info "clé DKIM supprimée pour $DOMAIN (sélecteur $SELECTOR)"
    else
        info "rien à supprimer pour $DOMAIN (sélecteur $SELECTOR)"
    fi

    maybe_restart
    emit_delete
}

emit_delete() {
    RESULT_EMITTED=1
    printf '{'
    printf '"schema_version":%s,' "$SCHEMA_VERSION"
    printf '"status":"success","operation":"delete",'
    printf '"domain":"%s","selector":"%s",' "$(json_escape "$DOMAIN")" "$(json_escape "$SELECTOR")"
    printf '"existed":%s,' "$EXISTED_BOOL"
    printf '"removed":{'
    printf '"private_key":%s,' "$RM_PRIV"
    printf '"txt_record":%s,' "$RM_TXT"
    printf '"key_dir_removed":%s,' "$RM_DIR"
    printf '"KeyTable":%s,' "$RM_KT"
    printf '"SigningTable":%s,' "$RM_ST"
    printf '"TrustedHosts":%s' "$RM_TH"
    printf '},'
    printf '"remaining_selectors":%s,' "$REMAINING"
    printf '"domain_signing_active":%s,' "$SIGNING_ACTIVE"
    printf '"service_restart_required":%s,' "$RESTART_REQUIRED"
    printf '"service_restarted":%s,' "$RESTARTED_BOOL"
    printf '"timestamp":"%s"}\n' "$(now_ts)"
}

# --- Commande : list --------------------------------------------------------
cmd_list() {
    OPERATION=list
    ensure_base_dirs
    RESULT_EMITTED=1

    local count=0 kn rest r2 dom sel path exists
    printf '{"schema_version":%s,"status":"success","operation":"list","keys":[' "$SCHEMA_VERSION"
    if [[ -f $KEYTABLE ]]; then
        while read -r kn rest _ || [[ -n ${kn:-} ]]; do
            [[ -z ${kn:-} ]] && continue
            case $kn in \#*) continue ;; esac
            [[ -n ${rest:-} ]] || continue
            # Ignore les entrées mal formées (champ 2 != domaine:sélecteur:chemin).
            [[ $rest == *:*:* ]] || continue
            dom=${rest%%:*}
            r2=${rest#*:}
            sel=${r2%%:*}
            path=${r2#*:}
            if [[ -f $path ]]; then exists=true; else exists=false; fi
            if [[ $count -gt 0 ]]; then printf ','; fi
            printf '{"domain":"%s","selector":"%s","key_name":"%s","private_key_path":"%s","private_key_exists":%s}' \
                "$(json_escape "$dom")" "$(json_escape "$sel")" "$(json_escape "$kn")" \
                "$(json_escape "$path")" "$exists"
            count=$((count + 1))
        done < "$KEYTABLE"
    fi
    printf '],"count":%s,"timestamp":"%s"}\n' "$count" "$(now_ts)"
}

# --- Commande : get ---------------------------------------------------------
# Renvoie la clé publique / l'enregistrement DNS TXT à publier pour un couple
# (domaine, sélecteur). Lecture seule : ne nécessite pas root. Source canonique :
# le fichier <sélecteur>.txt ; à défaut, reconstruction depuis la clé privée.
cmd_get() {
    OPERATION=get
    require_domain_selector

    KEYNAME="$SELECTOR._domainkey.$DOMAIN"
    local domdir="$KEYS_DIR/$DOMAIN"
    PRIV_PATH="$domdir/$SELECTOR.private"
    local txt_path="$domdir/$SELECTOR.txt"

    if [[ -f $PRIV_PATH ]]; then PRIV_EXISTS_BOOL=true; else PRIV_EXISTS_BOOL=false; fi

    if [[ -f $txt_path ]] && parse_public_record "$txt_path"; then
        SOURCE=txt_record
    elif [[ -f $PRIV_PATH ]] && derive_public_from_private "$PRIV_PATH"; then
        SOURCE=private_key
        warn "enregistrement .txt absent pour $DOMAIN ($SELECTOR) ; clé publique reconstruite depuis la clé privée"
    else
        fail "NOT_FOUND" "aucune clé DKIM pour $DOMAIN (sélecteur $SELECTOR)"
    fi
    RECORD_NAME="$KEYNAME"

    # Vérification (best-effort) que l'enregistrement est bien publié dans le DNS.
    DNS_CHECKED=false
    DNS_PUBLISHED=false
    DNS_MATCH=false
    DNS_ERROR=""
    if [[ $CHECK_DNS -eq 1 ]]; then
        check_dns_publication
    else
        DNS_ERROR="vérification DNS désactivée (--no-dns)"
    fi

    emit_get
}

emit_get() {
    RESULT_EMITTED=1
    printf '{'
    printf '"schema_version":%s,' "$SCHEMA_VERSION"
    printf '"status":"success","operation":"get",'
    printf '"domain":"%s","selector":"%s",' "$(json_escape "$DOMAIN")" "$(json_escape "$SELECTOR")"
    printf '"key_name":"%s",' "$(json_escape "$KEYNAME")"
    printf '"private_key_path":"%s",' "$(json_escape "$PRIV_PATH")"
    printf '"private_key_exists":%s,' "$PRIV_EXISTS_BOOL"
    printf '"public_key":"%s",' "$(json_escape "$PUBKEY_P")"
    printf '"dns_record_name":"%s",' "$(json_escape "$RECORD_NAME")"
    printf '"dns_record_type":"TXT",'
    printf '"dns_record_value":"%s",' "$(json_escape "$RECORD_VALUE")"
    printf '"source":"%s",' "$SOURCE"
    printf '"dns_lookup_name":"%s",' "$(json_escape "$KEYNAME")"
    printf '"dns_checked":%s,' "$DNS_CHECKED"
    printf '"dns_published":%s,' "$DNS_PUBLISHED"
    printf '"dns_match":%s,' "$DNS_MATCH"
    if [[ -n $DNS_ERROR ]]; then
        printf '"dns_error":"%s",' "$(json_escape "$DNS_ERROR")"
    else
        printf '"dns_error":null,'
    fi
    printf '"timestamp":"%s"' "$(now_ts)"
    printf '}\n'
}

# --- Commande : list-selectors ----------------------------------------------
# Liste les sélecteurs connus d'un domaine. Lecture seule (pas de root requis).
# Un sélecteur est découvert depuis le couple de fichiers <sél>.private/<sél>.txt
# ET/OU l'association déclarée dans KeyTable ; SigningTable indique lequel signe
# effectivement (active). « complete » = clé privée + clé publique + KeyTable.
cmd_list_selectors() {
    OPERATION=list-selectors
    require_domain

    local domdir="$KEYS_DIR/$DOMAIN"
    local -A SEL_PRIV=() SEL_PUB=() SEL_KT=() ALL=()
    local f sel kn rest d r2 ksel

    # 1) Fichiers de clés sur le disque (un nom ne se terminant pas exactement
    #    par .private / .txt — ex. l'autosave « #default.txt# » — est ignoré).
    if [[ -d $domdir ]]; then
        while IFS= read -r f; do
            sel=${f##*/}; sel=${sel%.private}
            [[ -n $sel ]] && { SEL_PRIV[$sel]=1; ALL[$sel]=1; }
        done < <(find "$domdir" -maxdepth 1 -type f -name '*.private' 2>/dev/null)
        while IFS= read -r f; do
            sel=${f##*/}; sel=${sel%.txt}
            [[ -n $sel ]] && { SEL_PUB[$sel]=1; ALL[$sel]=1; }
        done < <(find "$domdir" -maxdepth 1 -type f -name '*.txt' 2>/dev/null)
    fi

    # 2) Associations dans KeyTable (champ 2 = domaine:sélecteur:chemin ;
    #    les lignes de commentaire/exemple commençant par # sont ignorées).
    if [[ -r $KEYTABLE ]]; then
        while read -r kn rest _ || [[ -n ${kn:-} ]]; do
            [[ -z ${kn:-} ]] && continue
            case $kn in \#*) continue ;; esac
            [[ -n ${rest:-} && $rest == *:*:* ]] || continue
            d=${rest%%:*}
            [[ $d == "$DOMAIN" ]] || continue
            r2=${rest#*:}; ksel=${r2%%:*}
            [[ -n $ksel ]] && { SEL_KT[$ksel]=1; ALL[$ksel]=1; }
        done < "$KEYTABLE"
    fi

    # 3) Sélecteur actif : la règle *@domaine de SigningTable.
    local active_sel="" sigval
    if [[ -r $SIGNINGTABLE ]]; then
        sigval=$(awk -v dd="*@$DOMAIN" '$1==dd{print $2; exit}' "$SIGNINGTABLE" 2>/dev/null || true)
        [[ -n $sigval ]] && active_sel=${sigval%%._domainkey.*}
    fi

    local -a sorted=()
    if ((${#ALL[@]})); then
        mapfile -t sorted < <(printf '%s\n' "${!ALL[@]}" | sort)
    fi

    RESULT_EMITTED=1
    printf '{"schema_version":%s,"status":"success","operation":"list-selectors",' "$SCHEMA_VERSION"
    printf '"domain":"%s",' "$(json_escape "$DOMAIN")"
    if [[ -n $active_sel ]]; then
        printf '"active_selector":"%s",' "$(json_escape "$active_sel")"
    else
        printf '"active_selector":null,'
    fi

    printf '"selector_names":['
    local i=0 s
    for s in "${sorted[@]}"; do
        if [[ $i -gt 0 ]]; then printf ','; fi
        printf '"%s"' "$(json_escape "$s")"
        i=$((i + 1))
    done
    printf '],'

    printf '"selectors":['
    local priv pub kt active complete
    i=0
    for s in "${sorted[@]}"; do
        if [[ -n ${SEL_PRIV[$s]:-} ]]; then priv=true; else priv=false; fi
        if [[ -n ${SEL_PUB[$s]:-} ]]; then pub=true; else pub=false; fi
        if [[ -n ${SEL_KT[$s]:-} ]]; then kt=true; else kt=false; fi
        if [[ -n $active_sel && $s == "$active_sel" ]]; then active=true; else active=false; fi
        if [[ $priv == true && $pub == true && $kt == true ]]; then complete=true; else complete=false; fi
        if [[ $i -gt 0 ]]; then printf ','; fi
        printf '{"selector":"%s","key_name":"%s","private_key_path":"%s","private_key_exists":%s,"public_key_exists":%s,"in_key_table":%s,"active":%s,"complete":%s}' \
            "$(json_escape "$s")" "$(json_escape "$s._domainkey.$DOMAIN")" "$(json_escape "$domdir/$s.private")" \
            "$priv" "$pub" "$kt" "$active" "$complete"
        i=$((i + 1))
    done
    printf '],"count":%s,"timestamp":"%s"}\n' "$i" "$(now_ts)"
}

# --- Usage ------------------------------------------------------------------
print_usage() {
    cat <<'EOF'
opendkim-manager.sh — gestion des clés DKIM pour OpenDKIM

Usage :
  opendkim-manager.sh create -d <domaine> -s <sélecteur> [-b <bits>] [--force] [--restart]
  opendkim-manager.sh delete -d <domaine> -s <sélecteur> [--restart]
  opendkim-manager.sh get    -d <domaine> -s <sélecteur> [--no-dns]
  opendkim-manager.sh list-selectors -d <domaine>
  opendkim-manager.sh list
  opendkim-manager.sh help

Options :
  -d, --domain     domaine (ex. exemple.com)
  -s, --selector   sélecteur DKIM (ex. mail)
  -b, --bits       taille de clé RSA (défaut 2048, min 1024)
  -f, --force      régénère même si la clé existe déjà (create)
      --restart    redémarre le service OpenDKIM après l'opération
      --no-dns     n'interroge pas le DNS lors de get (vérification de publication)
  -q, --quiet      supprime les messages de progression sur stderr
  -h, --help       affiche cette aide

Sortie : un objet JSON sur stdout (status success|error). Détails humains sur stderr.
EOF
}

# --- Analyse des arguments --------------------------------------------------
main() {
    local cmd=${1:-}
    case $cmd in
        help|-h|--help)
            print_usage >&2
            emit_help
            exit 0
            ;;
        "")
            print_usage >&2
            usage_err "aucune commande fournie"
            ;;
    esac
    shift
    OPERATION=$cmd

    while [[ $# -gt 0 ]]; do
        case $1 in
            -d|--domain)   [[ $# -ge 2 ]] || usage_err "l'option $1 attend une valeur"; DOMAIN=$2; shift 2 ;;
            -s|--selector) [[ $# -ge 2 ]] || usage_err "l'option $1 attend une valeur"; SELECTOR=$2; shift 2 ;;
            -b|--bits)     [[ $# -ge 2 ]] || usage_err "l'option $1 attend une valeur"; BITS=$2; shift 2 ;;
            -f|--force)    FORCE=1; shift ;;
            --restart)     RESTART=1; shift ;;
            --no-dns)      CHECK_DNS=0; shift ;;
            -q|--quiet)    QUIET=1; shift ;;
            -h|--help)     print_usage >&2; emit_help; exit 0 ;;
            --)            shift; break ;;
            -*)            usage_err "option inconnue : $1" ;;
            *)             usage_err "argument inattendu : $1" ;;
        esac
    done

    case $cmd in
        create)                       cmd_create ;;
        delete|remove)                cmd_delete ;;
        get|show)                     cmd_get ;;
        list-selectors|list-selector) cmd_list_selectors ;;
        list|ls)                      cmd_list ;;
        *)                            usage_err "commande inconnue : $cmd" ;;
    esac
}

main "$@"
