#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
medulla_migrate.py — Migration d'un agent Medulla (configuration XMPP et contexte TLS)

Basé sur la procédure : "Migration d'un agent Medulla - configuration XMPP et
contexte TLS" (MIGRATION_AGENT_XMPP_TLS.md, 2026-09-22).

CE QUE FAIT CE SCRIPT
----------------------
  1. Télécharge la CA racine et la CA intermédiaire Medulla publiques de
     l'infrastructure CIBLE (https://<infra>.medulla-tech.io/downloads/).
  2. Vérifie leur format (openssl x509) avant toute installation.
  3. Les installe dans le(s) magasin(s) de confiance pertinent(s) pour l'OS
     (Linux : update-ca-certificates ou update-ca-trust selon la distribution ;
     macOS : bundle certifi de l'agent + /etc/ssl/cert.pem, trousseau système
     optionnel ; Windows : certutil.exe).
  4. Sauvegarde la configuration XMPP existante puis installe la nouvelle
     configuration XMPP de l'infra cible (fichier FOURNI, jamais généré ici).
  5. Teste STARTTLS contre le nouveau serveur XMPP avant de rien démarrer.
  6. (optionnel, --start-configurator) Démarre le configurateur et surveille
     brièvement le journal agent pour repérer "session_start".

CE QUE CE SCRIPT NE FAIT JAMAIS (règles de sécurité de la procédure)
----------------------------------------------------------------------
  - Il ne génère ni ne devine confpassword / keyAES32, et ne les affiche/
    journalise jamais.
  - Il ne copie JAMAIS les clés hôte OpenSSH d'une autre machine.
  - Il ne touche JAMAIS à une clé privée TLS.
  - Il ne copie pas la configuration d'une ancienne machine "telle quelle" :
    la nouvelle configuration XMPP doit être fournie via --xmpp-config et
    doit provenir de l'installateur/infra cible.

EXEMPLES
--------
  # Linux, migration vers l'infra "int", à blanc (rien n'est modifié) :
  sudo python3 medulla_migrate.py \\
      --old-fqdn agent-old.example.com \\
      --new-fqdn int.medulla-tech.io \\
      --xmpp-config /root/agentconf-int.ini \\
      --dry-run

  # Exécution réelle, puis démarrage du configurateur :
  sudo python3 medulla_migrate.py \\
      --old-fqdn agent-old.example.com \\
      --new-fqdn int.medulla-tech.io \\
      --xmpp-config /root/agentconf-int.ini \\
      --start-configurator --yes
"""

from __future__ import annotations

import argparse
import configparser
import datetime
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import urllib.error
from pathlib import Path

LOG = logging.getLogger("medulla_migrate")

# ---------------------------------------------------------------------------
# Constantes issues de la procédure
# ---------------------------------------------------------------------------

DOMAIN_SUFFIX = ".medulla-tech.io"
CERT_ROOT_NAME = "medulla-rootca.cert.pem"
CERT_CHAIN_NAME = "medulla-ca-chain.cert.pem"

LINUX_XMPP_DIR = Path("/etc/pulse-xmpp-agent")
LINUX_CA_DIR = Path("/usr/local/share/ca-certificates")
LINUX_CA_TRUST_DIR = Path("/etc/ssl/certs")

MACOS_XMPP_DIR = Path("/opt/medulla/etc")
MACOS_CERT_DIR = Path("/opt/medulla/certs")
MACOS_VENV_PYTHON = Path("/opt/medulla/venv/bin/python3")
MACOS_SYSTEM_CERT_PEM = Path("/etc/ssl/cert.pem")

WINDOWS_XMPP_DIR = Path(r"C:\Program Files\Medulla\etc")

CONFIGURATOR_PY = Path("/opt/medulla/bin/python3.11")
CONFIGURATOR_SCRIPT = Path(
    "/opt/medulla/lib/python3.11/site-packages/pulse_xmpp_agent/connectionagent.py"
)

WINDOWS_PYTHON = Path(r"C:\Program Files\Python3\python.exe")
WINDOWS_CONFIGURATOR_SCRIPT = Path(
    r"C:\Program Files\Python3\Lib\site-packages\pulse_xmpp_agent\connectionagent.py"
)


class MigrationError(RuntimeError):
    """Erreur fatale de migration : le script s'arrête proprement."""


# ---------------------------------------------------------------------------
# Utilitaires
# ---------------------------------------------------------------------------

def run(cmd, *, dry_run: bool, check: bool = True, capture: bool = True, **kw):
    """Exécute une commande, avec prise en charge de --dry-run.

    Ne journalise jamais de secrets : les appelants ne doivent passer que des
    commandes dont les arguments sont non sensibles.
    """
    printable = " ".join(str(c) for c in cmd)
    LOG.info("$ %s", printable)
    if dry_run:
        LOG.info("  (dry-run : commande non exécutée)")
        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
    try:
        result = subprocess.run(
            cmd,
            check=False,
            text=True,
            stdout=subprocess.PIPE if capture else None,
            stderr=subprocess.STDOUT if capture else None,
            **kw,
        )
    except OSError as exc:
        raise MigrationError(f"Commande introuvable ou non exécutable : {printable} ({exc})") from exc
    if capture and result.stdout:
        for line in result.stdout.splitlines():
            LOG.info("  %s", line)
    if check and result.returncode != 0:
        raise MigrationError(f"Échec de la commande ({result.returncode}) : {printable}")
    return result


def require_root(dry_run: bool):
    if os.name == "posix" and os.geteuid() != 0 and not dry_run:
        raise MigrationError(
            "Ce script doit être exécuté en root (sudo) — sauf en --dry-run."
        )


def confirm(question: str, auto_yes: bool) -> bool:
    if auto_yes:
        return True
    reply = input(f"{question} [o/N] ").strip().lower()
    return reply in ("o", "oui", "y", "yes")


def timestamp() -> str:
    return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")


def backup_path(path: Path) -> Path:
    return path.with_name(f"{path.name}.bak-{timestamp()}")


# ---------------------------------------------------------------------------
# Étape 1 — Infra cible / URL de téléchargement
# ---------------------------------------------------------------------------

def derive_infra(new_fqdn: str, infra_override: str | None) -> str:
    if infra_override:
        return infra_override
    if new_fqdn.endswith(DOMAIN_SUFFIX):
        return new_fqdn[: -len(DOMAIN_SUFFIX)].split(".")[-1]
    raise MigrationError(
        f"Impossible de déduire le code infra depuis '{new_fqdn}'. "
        "Précise-le explicitement avec --infra (ex. int, jfk, spo, kno)."
    )


# ---------------------------------------------------------------------------
# Étape 2 — Téléchargement + vérification des CA publiques
# ---------------------------------------------------------------------------

def download_file(url: str, dest: Path, *, dry_run: bool):
    LOG.info("Téléchargement : %s -> %s", url, dest)
    if dry_run:
        LOG.info("  (dry-run : téléchargement non effectué)")
        return
    try:
        with urllib.request.urlopen(url, timeout=30) as resp, open(dest, "wb") as fh:
            shutil.copyfileobj(resp, fh)
    except urllib.error.URLError as exc:
        raise MigrationError(f"Échec du téléchargement de {url} : {exc}") from exc


def verify_cert_format(path: Path, *, dry_run: bool):
    if dry_run and not path.exists():
        LOG.info("(dry-run) vérification openssl ignorée pour %s", path)
        return
    if not shutil.which("openssl"):
        LOG.warning("openssl introuvable : vérification du certificat ignorée.")
        return
    run(
        ["openssl", "x509", "-in", str(path), "-noout", "-subject", "-issuer", "-dates"],
        dry_run=False,  # la vérification a toujours du sens même en dry-run si le fichier existe
    )


def fetch_public_cas(base_url: str, dest_dir: Path, *, dry_run: bool) -> tuple[Path, Path]:
    dest_dir.mkdir(parents=True, exist_ok=True)
    root = dest_dir / CERT_ROOT_NAME
    chain = dest_dir / CERT_CHAIN_NAME
    download_file(f"{base_url}/{CERT_ROOT_NAME}", root, dry_run=dry_run)
    download_file(f"{base_url}/{CERT_CHAIN_NAME}", chain, dry_run=dry_run)
    verify_cert_format(root, dry_run=dry_run)
    verify_cert_format(chain, dry_run=dry_run)
    return root, chain


# ---------------------------------------------------------------------------
# Étape 3 — Installation des CA dans le magasin de confiance (par OS)
# ---------------------------------------------------------------------------

def install_ca_linux(root: Path, chain: Path, *, dry_run: bool):
    # Debian/Ubuntu : update-ca-certificates ; RHEL/Fedora/CentOS : update-ca-trust
    debian_style = shutil.which("update-ca-certificates") is not None
    rhel_style = shutil.which("update-ca-trust") is not None
    if not debian_style and not rhel_style and not dry_run:
        raise MigrationError(
            "Aucun outil de magasin de confiance reconnu (ni update-ca-certificates, "
            "ni update-ca-trust) : distribution Linux non prise en charge."
        )

    if rhel_style and not debian_style:
        ca_dir = Path("/etc/pki/ca-trust/source/anchors")
        ca_dir.mkdir(parents=True, exist_ok=True)
        target_root = ca_dir / "medulla-rootca.crt"
        target_chain = ca_dir / "medulla-ca-chain.crt"
        if not dry_run:
            shutil.copyfile(root, target_root)
            shutil.copyfile(chain, target_chain)
        else:
            LOG.info("(dry-run) copie %s -> %s", root, target_root)
            LOG.info("(dry-run) copie %s -> %s", chain, target_chain)
        run(["update-ca-trust", "extract"], dry_run=dry_run)
        return

    LINUX_CA_DIR.mkdir(parents=True, exist_ok=True)
    target_root = LINUX_CA_DIR / "medulla-rootca.crt"
    target_chain = LINUX_CA_DIR / "medulla-ca-chain.crt"
    if not dry_run:
        shutil.copyfile(root, target_root)
        shutil.copyfile(chain, target_chain)
    else:
        LOG.info("(dry-run) copie %s -> %s", root, target_root)
        LOG.info("(dry-run) copie %s -> %s", chain, target_chain)
    run(["update-ca-certificates"], dry_run=dry_run)
    run(["bash", "-c", f"ls -l {LINUX_CA_TRUST_DIR} | grep -i medulla || true"], dry_run=dry_run)


def install_ca_macos(root: Path, chain: Path, *, dry_run: bool, use_keychain: bool, auto_yes: bool):
    MACOS_CERT_DIR.mkdir(parents=True, exist_ok=True)
    target_root = MACOS_CERT_DIR / root.name
    target_chain = MACOS_CERT_DIR / chain.name
    if not dry_run:
        shutil.copyfile(root, target_root)
        shutil.copyfile(chain, target_chain)

    # 1) bundle certifi réellement utilisé par le venv de l'agent
    if MACOS_VENV_PYTHON.exists() or dry_run:
        result = run(
            [str(MACOS_VENV_PYTHON), "-c", "import certifi; print(certifi.where())"],
            dry_run=dry_run,
            check=False,
        )
        certifi_file = Path(result.stdout.strip()) if (result.stdout and not dry_run) else None
        if certifi_file:
            _append_chain_if_untrusted(certifi_file, target_root, target_chain, dry_run=dry_run)
        else:
            LOG.info("(dry-run ou indisponible) mise à jour du bundle certifi ignorée.")
    else:
        LOG.warning("venv agent introuvable (%s) : bundle certifi non mis à jour.", MACOS_VENV_PYTHON)

    # 2) /etc/ssl/cert.pem, si présent
    if MACOS_SYSTEM_CERT_PEM.exists() or dry_run:
        _append_chain_if_untrusted(MACOS_SYSTEM_CERT_PEM, target_root, target_chain, dry_run=dry_run)

    # 3) trousseau système — facultatif, JAMAIS silencieux sans confirmation explicite
    if use_keychain:
        LOG.warning(
            "Ajout de la CA racine au trousseau système : peut demander une "
            "confirmation interactive macOS. Ne pas utiliser en installation silencieuse."
        )
        if confirm("Confirmer l'ajout au trousseau système (trustRoot) ?", auto_yes):
            run(
                [
                    "security", "add-trusted-cert", "-d", "-r", "trustRoot",
                    "-k", "/Library/Keychains/System.keychain", str(target_root),
                ],
                dry_run=dry_run,
            )
        else:
            LOG.info("Ajout au trousseau système annulé par l'utilisateur.")


def _append_chain_if_untrusted(bundle: Path, root: Path, chain: Path, *, dry_run: bool):
    if dry_run:
        LOG.info("(dry-run) vérification/mise à jour de %s", bundle)
        return
    if not shutil.which("openssl"):
        LOG.warning("openssl introuvable : impossible de vérifier %s.", bundle)
        return
    check = subprocess.run(
        ["openssl", "verify", "-CAfile", str(bundle), str(root)],
        capture_output=True, text=True,
    )
    if check.returncode == 0:
        LOG.info("%s fait déjà confiance à la CA Medulla.", bundle)
        return
    backup = backup_path(bundle)
    shutil.copy2(bundle, backup)
    LOG.info("Sauvegarde de %s -> %s", bundle, backup)
    with open(chain, "r") as src, open(bundle, "a") as dst:
        dst.write("\n")
        dst.write(src.read())
    LOG.info("Chaîne Medulla ajoutée à %s.", bundle)


def install_ca_windows(root: Path, chain: Path, *, dry_run: bool):
    certutil = shutil.which("certutil.exe") or "certutil.exe"
    run([certutil, "-addstore", "-f", "Root", str(root)], dry_run=dry_run)
    run([certutil, "-addstore", "-f", "CA", str(chain)], dry_run=dry_run)
    run(
        ["powershell", "-Command",
         f'certutil.exe -store Root | Select-String -Pattern "Medulla" -Context 2,2'],
        dry_run=dry_run, check=False,
    )


# ---------------------------------------------------------------------------
# Étape 4 — Configuration XMPP : sauvegarde + remplacement contrôlé
# ---------------------------------------------------------------------------

def xmpp_dir_for_os(os_name: str) -> Path:
    if os_name == "linux":
        return LINUX_XMPP_DIR
    if os_name == "windows":
        return WINDOWS_XMPP_DIR
    return MACOS_XMPP_DIR


def read_current_confserver(agentconf_path: Path) -> str | None:
    """Lit UNIQUEMENT confserver pour comparaison — ne journalise jamais
    confpassword ni keyAES32."""
    if not agentconf_path.exists():
        return None
    parser = configparser.ConfigParser()
    try:
        parser.read(agentconf_path)
        return parser.get("configuration_server", "confserver", fallback=None)
    except configparser.Error:
        return None


def install_xmpp_config(
    new_config: Path, os_name: str, old_fqdn: str, new_fqdn: str,
    *, dry_run: bool, auto_yes: bool,
):
    target_dir = xmpp_dir_for_os(os_name)
    target_file = target_dir / "agentconf.ini"

    current = read_current_confserver(target_file)
    if current:
        LOG.info("confserver actuellement configuré : %s", current)
        if old_fqdn not in current and current != old_fqdn:
            LOG.warning(
                "Le confserver actuel ('%s') ne correspond pas à l'ancien FQDN "
                "indiqué ('%s'). Vérifie que tu migres la bonne machine.",
                current, old_fqdn,
            )
        if not confirm(
            f"Remplacer la configuration XMPP existante ({target_file}) par "
            f"celle fournie pour la cible '{new_fqdn}' ?", auto_yes,
        ):
            raise MigrationError("Remplacement de la configuration XMPP annulé par l'utilisateur.")

    target_dir.mkdir(parents=True, exist_ok=True)
    if not dry_run:
        try:
            os.chmod(target_dir, 0o700)
        except OSError:
            pass
    if target_file.exists():
        backup = backup_path(target_file)
        if not dry_run:
            shutil.copy2(target_file, backup)
            try:
                os.chmod(backup, 0o600)
            except OSError:
                pass
        LOG.info("Sauvegarde de la configuration existante -> %s", backup)

    LOG.info("Installation de la nouvelle configuration XMPP -> %s", target_file)
    if not dry_run:
        shutil.copy2(new_config, target_file)
        try:
            os.chmod(target_file, 0o600)
        except OSError:
            pass
    else:
        LOG.info("(dry-run) copie %s -> %s", new_config, target_file)


# ---------------------------------------------------------------------------
# Étape 5 — Test STARTTLS
# ---------------------------------------------------------------------------

def test_starttls(
    server: str, port: int, xmpphost: str, *, ca_file: str | None, ca_path: str | None,
    dry_run: bool,
) -> bool:
    if not shutil.which("openssl"):
        LOG.warning("openssl introuvable : test STARTTLS ignoré.")
        return False

    cmd = [
        "openssl", "s_client",
        "-connect", f"{server}:{port}",
        "-starttls", "xmpp",
        "-xmpphost", xmpphost,
        "-verify_return_error",
    ]
    if ca_file:
        cmd += ["-CAfile", ca_file]
    if ca_path:
        cmd += ["-CApath", ca_path]

    LOG.info("$ %s </dev/null", " ".join(cmd))
    if dry_run:
        LOG.info("(dry-run) test STARTTLS non exécuté.")
        return True

    result = subprocess.run(
        cmd, input="", text=True, capture_output=True, timeout=30,
    )
    output = result.stdout + result.stderr
    for line in output.splitlines():
        if any(k in line for k in ("subject=", "issuer=", "Verification error", "Verify return code")):
            LOG.info("  %s", line)

    ok = "Verify return code: 0 (ok)" in output
    if ok:
        LOG.info("STARTTLS OK (Verify return code: 0).")
    else:
        LOG.error("STARTTLS ÉCHOUÉ : la chaîne de confiance TLS n'est pas valide.")
    return ok


def default_ca_for_os(os_name: str, *, dry_run: bool) -> tuple[str | None, str | None]:
    """Retourne (ca_file, ca_path) adaptés au magasin réellement utilisé par l'agent."""
    if os_name == "linux":
        return None, str(LINUX_CA_TRUST_DIR)
    if os_name == "macos":
        if MACOS_VENV_PYTHON.exists() and not dry_run:
            result = subprocess.run(
                [str(MACOS_VENV_PYTHON), "-c", "import certifi; print(certifi.where())"],
                capture_output=True, text=True,
            )
            if result.returncode == 0:
                return result.stdout.strip(), None
        return None, None
    return None, None  # windows : pas d'équivalent direct simple, best effort


# ---------------------------------------------------------------------------
# Étape 6 — Démarrage du configurateur (optionnel)
# ---------------------------------------------------------------------------

def start_configurator(os_name: str, *, dry_run: bool):
    if os_name == "windows":
        configurator_py, configurator_script = WINDOWS_PYTHON, WINDOWS_CONFIGURATOR_SCRIPT
    else:
        configurator_py, configurator_script = CONFIGURATOR_PY, CONFIGURATOR_SCRIPT

    if not (configurator_py.exists() and configurator_script.exists()) and not dry_run:
        raise MigrationError(
            f"Configurateur introuvable ({configurator_py} / {configurator_script})."
        )
    LOG.info(
        "Démarrage du configurateur. Vérifie ensuite dans les journaux que la "
        "connexion dépasse 'Configurator connected' et atteint 'session_start', "
        "l'envoi de présence et la demande de configuration."
    )
    run(
        [str(configurator_py), str(configurator_script), "-c", "-t", "machine"],
        dry_run=dry_run, check=False,
    )


# ---------------------------------------------------------------------------
# Programme principal
# ---------------------------------------------------------------------------

def detect_os(explicit: str) -> str:
    if explicit != "auto":
        return explicit
    system = platform.system().lower()
    if system == "linux":
        return "linux"
    if system == "darwin":
        return "macos"
    if system == "windows":
        return "windows"
    raise MigrationError(f"OS non reconnu automatiquement ({system}) — utilise --os.")


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Migration d'un agent Medulla : contexte TLS + configuration XMPP.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    p.add_argument("--old-fqdn", required=True, help="FQDN / hôte de l'ancienne infrastructure (traçabilité, comparaison).")
    p.add_argument("--new-fqdn", required=True, help="FQDN de l'infrastructure cible (ex. int.medulla-tech.io).")
    p.add_argument("--infra", help="Code infra cible si non déductible du FQDN (int, jfk, spo, kno...).")
    p.add_argument("--base-url", help="Surcharge l'URL de téléchargement des CA (par défaut https://<infra>.medulla-tech.io/downloads).")
    p.add_argument("--confserver", help="Adresse XMPP réelle pour le test STARTTLS (par défaut = --new-fqdn).")
    p.add_argument("--confport", type=int, default=5222, help="Port XMPP (défaut : 5222).")
    p.add_argument("--confdomain", default="pulse", help="Domaine XMPP / xmpphost (défaut : pulse).")
    p.add_argument("--xmpp-config", type=Path, help="Fichier agentconf.ini de la nouvelle infra (fourni par l'installateur cible). Requis sauf --skip-xmpp-config.")
    p.add_argument("--os", choices=["auto", "linux", "macos", "windows"], default="auto", help="Force la plateforme (défaut : détection automatique).")

    p.add_argument("--skip-certs", action="store_true", help="Ne pas installer les CA publiques.")
    p.add_argument("--skip-xmpp-config", action="store_true", help="Ne pas remplacer la configuration XMPP.")
    p.add_argument("--skip-starttls", action="store_true", help="Ne pas tester STARTTLS.")
    p.add_argument("--start-configurator", action="store_true", help="Démarrer le configurateur après les vérifications.")
    p.add_argument("--macos-keychain", action="store_true", help="macOS : ajouter aussi la CA racine au trousseau système (facultatif, demande confirmation).")
    p.add_argument("--ca-file", help="Surcharge le fichier CA utilisé pour le test STARTTLS.")
    p.add_argument("--ca-path", help="Surcharge le répertoire CA utilisé pour le test STARTTLS.")

    p.add_argument("--force", action="store_true", help="Démarrer le configurateur même si le test STARTTLS a échoué (déconseillé).")
    p.add_argument("--yes", action="store_true", help="Ne pas demander de confirmation interactive.")
    p.add_argument("--dry-run", action="store_true", help="N'exécute rien réellement : affiche uniquement les actions prévues.")
    p.add_argument("-v", "--verbose", action="store_true", help="Journalisation détaillée.")
    return p


def main(argv=None) -> int:
    args = build_parser().parse_args(argv)

    logging.basicConfig(
        level=logging.DEBUG if args.verbose else logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%H:%M:%S",
    )

    try:
        require_root(args.dry_run)

        os_name = detect_os(args.os)
        LOG.info("Plateforme cible : %s", os_name)

        infra = derive_infra(args.new_fqdn, args.infra)
        base_url = args.base_url or f"https://{infra}.medulla-tech.io/downloads"
        confserver = args.confserver or args.new_fqdn
        LOG.info("Infra cible : %s | Base URL certs : %s | confserver test : %s", infra, base_url, confserver)
        LOG.info("Migration : %s  ->  %s", args.old_fqdn, args.new_fqdn)

        # Rappel de sécurité systématique
        LOG.info(
            "Rappel : ce script ne copie jamais les clés hôte OpenSSH ni de clé "
            "privée TLS, et n'affiche/ne journalise jamais confpassword ou keyAES32."
        )

        # --- Étape 1/2 : CA publiques -----------------------------------
        if not args.skip_certs:
            with tempfile.TemporaryDirectory() as tmp:
                root, chain = fetch_public_cas(base_url, Path(tmp), dry_run=args.dry_run)
                if os_name == "linux":
                    install_ca_linux(root, chain, dry_run=args.dry_run)
                elif os_name == "macos":
                    install_ca_macos(
                        root, chain, dry_run=args.dry_run,
                        use_keychain=args.macos_keychain, auto_yes=args.yes,
                    )
                elif os_name == "windows":
                    install_ca_windows(root, chain, dry_run=args.dry_run)
        else:
            LOG.info("Étape CA ignorée (--skip-certs).")

        # --- Étape 3 : configuration XMPP --------------------------------
        if not args.skip_xmpp_config:
            if not args.xmpp_config:
                raise MigrationError(
                    "--xmpp-config est requis (fichier agentconf.ini de l'infra "
                    "cible) sauf si --skip-xmpp-config est utilisé."
                )
            if not args.xmpp_config.exists() and not args.dry_run:
                raise MigrationError(f"Fichier introuvable : {args.xmpp_config}")
            install_xmpp_config(
                args.xmpp_config, os_name, args.old_fqdn, args.new_fqdn,
                dry_run=args.dry_run, auto_yes=args.yes,
            )
        else:
            LOG.info("Étape configuration XMPP ignorée (--skip-xmpp-config).")

        # --- Étape 4 : STARTTLS ------------------------------------------
        starttls_ok = True
        if not args.skip_starttls:
            ca_file, ca_path = args.ca_file, args.ca_path
            if not ca_file and not ca_path:
                ca_file, ca_path = default_ca_for_os(os_name, dry_run=args.dry_run)
            starttls_ok = test_starttls(
                confserver, args.confport, args.confdomain,
                ca_file=ca_file, ca_path=ca_path, dry_run=args.dry_run,
            )
        else:
            LOG.info("Test STARTTLS ignoré (--skip-starttls).")

        # --- Étape 5 : configurateur --------------------------------------
        if args.start_configurator:
            if not starttls_ok and not args.force:
                raise MigrationError(
                    "STARTTLS n'est pas valide : le configurateur n'est PAS démarré "
                    "(utilise --force pour outrepasser, déconseillé)."
                )
            start_configurator(os_name, dry_run=args.dry_run)
            LOG.info(
                "Pense à contrôler les journaux serveur (ejabberdctl registered_vhosts, "
                "journalctl -u ejabberd --since '5 minutes ago') en plus des journaux agent."
            )
        else:
            LOG.info("Configurateur non démarré (utilise --start-configurator pour le lancer).")

        LOG.info("Migration terminée.")
        return 0

    except MigrationError as exc:
        LOG.error(str(exc))
        return 1
    except KeyboardInterrupt:
        LOG.error("Interrompu par l'utilisateur.")
        return 130


if __name__ == "__main__":
    sys.exit(main())
