#!/usr/bin/env python3
# -*- coding: utf-8; -*-
# SPDX-FileCopyrightText: 2025-2026 Medulla, http://www.medulla-tech.io
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import sys
import argparse
import subprocess
import socket
import secrets
import string
import logging
import shutil
import ipaddress
from pathlib import Path
import tarfile
import base64
import requests

VAULT_PASSWORD_FILE = Path.home() / ".vp"
INVENTORY_FILE = Path.home() / "ansible_hosts.yml"
TIMEZONE = 'Europe/Paris'
PLAYBOOK_VERSION = '5.6.1'
PLAYBOOK_TOKEN = 'j1HDg6G6PSmGs1Fdo0'
PLAYBOOK_BASEURL = 'https://demodl.medulla-tech.io'
MINIMUM_RAM_GB = 8
MINIMUM_CPU_CORES = 8
MINIMUM_DISK_SPACE_GB = 240

# Configure basic logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


# =====================================================
# Utilities
# =====================================================
def generate_password(length=12):
    """Generate a random password of given length containing letters and digits."""
    alphabet = string.ascii_letters + string.digits
    return ''.join(secrets.choice(alphabet) for _ in range(length))


def check_password_is_valid(name, value):
    """Check if a string contains only valid characters."""
    logger.info(f"Validating password for {name}")
    valid_chars = string.ascii_letters + string.digits + ".,_-+:&="
    for char in value:
        if char not in valid_chars:
            logger.error(f"[x] Invalid character in password defined for '{name}'. Allowed characters are letters, digits, and . , _ - + : & =")
            return False
    logger.info("[v] Password is valid")
    return True


def str_to_bool(value):
    """Convert a string to boolean for argparse (accepts true/false, yes/no, 1/0)."""
    if isinstance(value, bool):
        return value
    val = str(value).strip().lower()
    if val in ('yes', 'y', 'true', 't', '1'):
        return True
    if val in ('no', 'n', 'false', 'f', '0'):
        return False
    raise argparse.ArgumentTypeError(f"Boolean value expected (true/false), got: {value}")


def run_cmd(cmd, stream=False, check=True):
    """Run command. If stream=False (default), capture and return stdout. If stream=True, stream output live to console.
    On failure when check=True, exit with error message."""
    logger.debug("Running command: %s (stream=%s)", " ".join(cmd), stream)
    if stream:
        # Stream mode: output goes directly to console
        try:
            p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
        except FileNotFoundError:
            logger.error("Command not found: %s", cmd[0])
            sys.exit(1)
        try:
            for line in p.stdout:
                print(line, end='')
        except KeyboardInterrupt:
            p.kill()
            raise
        rc = p.wait()
        if check and rc != 0:
            logger.error("Command failed (rc=%s): %s", rc, " ".join(cmd))
            sys.exit(rc)
        return None
    else:
        # Capture mode: capture stdout and return it
        try:
            result = subprocess.run(cmd, capture_output=True, text=True)
        except FileNotFoundError:
            logger.error("Command not found: %s", cmd[0])
            if check:
                sys.exit(1)
            return ""
        if check and result.returncode != 0:
            logger.error("Command failed (%s): %s", result.returncode, " ".join(cmd))
            if result.stderr:
                logger.error(result.stderr.strip())
            sys.exit(result.returncode)
        return result.stdout.strip()


def check_os_is_debian12():
    """Check if the operating system is Debian 12 (bookworm) or later."""
    logger.info("Checking operating system is Debian 12 (bookworm) or later")
    try:
        with open("/etc/os-release") as f:
            os_release = f.read()
        if "bookworm" not in os_release:
            logger.error("[x] This script must be run on a Debian 12 (bookworm) or later")
            return False
    except Exception:
        logger.error("[x] This script must be run on a Debian 12 (bookworm) or later")
        return False
    logger.info("[v] Operating system is Debian 12 (bookworm) or later")
    return True


def check_user_is_root():
    """Check if the script is run as root."""
    logger.info("Checking if running as root")
    if os.geteuid() != 0:
        logger.error("[x] This script must be run as root or with sudo privileges")
        return False
    logger.info("[v] Running as root")
    return True


def check_minimum_ram(min_gb=MINIMUM_RAM_GB):
    """Check if the system has at least min_gb of RAM."""
    logger.info(f"Checking if system has at least {min_gb}GB of RAM")
    try:
        with open("/proc/meminfo") as f:
            meminfo = f.read()
        total_kb = int([x for x in meminfo.splitlines() if x.startswith("MemTotal:")][0].split()[1])
        total_gb = total_kb / (1000 * 1000)
        if total_gb < min_gb:
            logger.error(f"[x] This script requires at least {min_gb}GB of RAM to run (found {total_gb:.2f}GB)")
            return False
    except Exception as e:
        logger.error(f"[x] Failed to check RAM: {e}")
        return False
    logger.info(f"[v] System has sufficient RAM: {total_gb:.2f}GB")
    return True


def check_minimum_cpus(min_cpu=MINIMUM_CPU_CORES):
    """Check if the system has at least min_cpu CPU cores."""
    logger.info(f"Checking if system has at least {min_cpu} CPU cores")
    try:
        cpu_count = os.cpu_count()
        if cpu_count is None or cpu_count < min_cpu:
            logger.error(f"[x] This script requires at least {min_cpu} CPU cores to run (found {cpu_count})")
            return False
    except Exception as e:
        logger.error(f"[x] Failed to check CPU cores: {e}")
        return False
    logger.info(f"[v] System has sufficient CPU cores: {cpu_count}")
    return True


def check_minimum_disk_space(min_gb=MINIMUM_DISK_SPACE_GB):
    """Check if the system has at least min_gb of free disk space on the root and /var partitions altogether."""
    logger.info(f"Checking if system has at least {min_gb}GB of free disk space on root and /var partitions altogether")
    try:
        root_stat = shutil.disk_usage("/")
        var_stat = shutil.disk_usage("/var")
        # Determine whether /var is a separate filesystem 
        root_dev = os.stat("/").st_dev
        var_dev = os.stat("/var").st_dev
        if root_dev == var_dev:
            # Same filesystem → count only once
            space_gb = root_stat.total / (1000 * 1000 * 1000)
            logger.info("Detected that / and /var are on the same filesystem; not double-counting free space")
        else:
            # Different filesystems → sum free space
            space_gb = (root_stat.total + var_stat.total) / (1000 * 1000 * 1000)
        if space_gb < min_gb:
            logger.error(f"[x] At least {min_gb}GB of free disk space on root and /var partitions altogether are required (found {space_gb:.2f}GB)")
            return False
    except Exception as e:
        logger.error(f"[x] Failed to check disk space: {e}")
        return False
    logger.info(f"[v] System has sufficient disk space: {space_gb:.2f}GB")
    return True


def check_internet_connectivity():
    """Check if the machine has an active Internet connection by trying to connect to a well-known host."""
    logger.info("Checking Internet connectivity")
    try:
        socket.create_connection(("deb.debian.org", 80), timeout=3)
        logger.info("[v] Internet connectivity verified")
        return True
    except Exception as e:
        logger.error("[x] No Internet connectivity: %s", e)
        return False


def check_ipv6_disabled():
    """Check if IPv6 is disabled on the system."""
    logger.info("Checking if IPv6 is disabled")
    try:
        with open("/proc/sys/net/ipv6/conf/all/disable_ipv6") as f:
            value = f.read().strip()
        if value == "1":
            logger.info("[v] IPv6 is disabled")
            return True
        else:
            logger.error("[x] IPv6 is not disabled (found disable_ipv6=%s). Please disable it by adding the following lines to /etc/sysctl.conf and rebooting:\n\n"
                         "net.ipv6.conf.all.disable_ipv6 = 1\n"
                         "net.ipv6.conf.default.disable_ipv6 = 1\n"
                         "net.ipv6.conf.lo.disable_ipv6 = 1\n", value)
            return False
    except Exception as e:
        logger.error("[x] Failed to check if IPv6 is disabled: %s", e)
        return False
    

def check_systemd_limits():
    """Check if systemd limits are configured as expected"""
    logger.info("Checking systemd limits")
    expected_limits = {
        "DefaultLimitCPU": "infinity",
        "DefaultLimitFSIZE": "infinity",
        "DefaultLimitDATA": "infinity",
        "DefaultLimitSTACK": "infinity",
        "DefaultLimitCORE": "infinity",
        "DefaultLimitRSS": "infinity",
        "DefaultLimitNOFILE": "65536:1048576",
        "DefaultLimitNPROC": "16384:98304",
        "DefaultLimitMEMLOCK": "8388608:infinity",
        "DefaultLimitLOCKS": "infinity",
        "DefaultLimitSIGPENDING": "31541:infinity",
        "DefaultLimitMSGQUEUE": "819200:819200",
        "DefaultLimitRTPRIO": "0",
        "DefaultLimitRTTIME": "infinity"
    }
    all_ok = True
    for limit, expected in expected_limits.items():
        current = run_cmd(["systemctl", "--no-pager", "--quiet", f"show", f"--property={limit}"], check=False)
        if current and current.startswith(f"{limit}="):
            current_value = current.split("=", 1)[1].strip()
            # Handle range values (like "16384:65536") by comparing both parts
            if ":" in expected:
                expected_parts = expected.split(":")
                if len(expected_parts) == 2:
                    if expected_parts[1] == "infinity":
                        if int(current_value) >= int(expected_parts[0]) or current_value == "infinity":
                            logger.info(f"[v] {limit} is correctly set to {current_value} (expected range: {expected})")
                            continue
                    if int(current_value) >= int(expected_parts[0]) and int(current_value) <= int(expected_parts[1]):
                        logger.info(f"[v] {limit} is correctly set to {current_value} (expected range: {expected})")
                        continue
                    else:
                        logger.error(f"[x] {limit} is set to {current_value}, but expected {expected}")
                        all_ok = False
                        continue
            # For non-range values
            if current_value == expected:
                logger.info(f"[v] {limit} is correctly set to {expected}")
            else:
                logger.error(f"[x] {limit} is set to {current_value}, but expected {expected}")
                all_ok = False
        else:
            logger.error(f"[x] Failed to retrieve {limit} from systemd")
            all_ok = False
    return all_ok


def check_lsof_counts():
    """Check open file descriptor counts for users and compare them against baseline values."""
    logger.info("Checking open file descriptor counts (top 10, including UID 1000)")
    # Get username for UID 1000
    try:
        uid1000_user = run_cmd(["getent", "passwd", "1000"], check=False).split(":")[0]
    except Exception:
        uid1000_user = "UID1000"
    baseline_counts = {
        "root": 1000,
        uid1000_user: 200,
    }
    # Get top 10 users by open file descriptors and if any of them is not in baseline and has more than 100 open files, raise a warning
    # If they are in baseline and have more than baseline values, raise a warning as well
    try:
        lsof_output = run_cmd(["lsof", "-n", "-u", "1000", "|", "awk", "'NR>1{print $3}'", "|", "sort", "|", "uniq", "-c", "|", "sort", "-rn"], check=False)
        for line in lsof_output.splitlines():
            parts = line.strip().split()
            if len(parts) == 2:
                count = int(parts[0])
                user = parts[1]
                baseline = baseline_counts.get(user)
                if baseline and count > baseline:
                    logger.error(f"[x] User {user} has {count} open file descriptors, which is above the baseline of {baseline}")
                elif not baseline and count > 100:
                    logger.error(f"[x] User {user} has {count} open file descriptors, which is above the warning threshold of 100")
    except Exception as e:
        logger.error(f"[x] Failed to check lsof counts: {e}")
        return False
    logger.info("[v] Open file descriptor counts checked")
    return True


def check_sysctl_conf():
    """Check if sysctl values are configured as expected"""
    logger.info("Checking sysctl configuration")
    expected_sysctl = {
        "fs.file-max": "2097152",
        "kernel.pid_max": "4194304",
        "kernel.shmmax": "68719476736"
    }
    # Check if the values are as expected or higher
    for key, expected in expected_sysctl.items():
        try:
            current = run_cmd(["sysctl", "-n", key], check=False).strip()
            if int(current) >= int(expected):
                logger.info(f"[v] {key} is correctly set to {current} (expected >= {expected})")
            else:
                logger.error(f"[x] {key} is set to {current}, but expected >= {expected}")
                return False
        except Exception as e:
            logger.error(f"[x] Failed to retrieve {key} from sysctl: {e}")
            return False
    return True


def check_debian_repositories():
    """Check if Debian repositories are defined in /etc/apt/sources.list or /etc/apt/sources.list.d/debian.sources."""
    logger.info("Checking Debian repositories in /etc/apt/sources.list")
    try:
        with open("/etc/apt/sources.list") as f:
            sources = f.read()
        if "deb http://deb.debian.org/debian" in sources:
            logger.info("[v] Debian repositories are defined in /etc/apt/sources.list")
            return True
        if os.path.exists("/etc/apt/sources.list.d/debian.sources"):
            logger.info("[v] Debian repositories are defined in /etc/apt/sources.list.d/debian.sources")
            return True
        logger.error("[x] Debian repositories not found in /etc/apt/sources.list or /etc/apt/sources.list.d/debian.sources. Please add: deb http://deb.debian.org/debian bookworm main")
        return False
    except Exception as e:
        logger.error("[x] Failed to read /etc/apt/sources.list: %s", e)
        return False


def check_previous_medulla_installation():
    """Check if a previous installation of Medulla is detected by looking for the presence of /var/lib/ldap/data.mdb."""
    logger.info("Checking for previous Medulla installation")
    if os.path.exists("/var/lib/ldap/data.mdb"):
        logger.error("[x] A previous installation of Medulla is detected on this machine. Please use a fresh install of Debian 12 and run the installer again")
        return False
    logger.info("[v] No previous Medulla installation detected")
    return True


def get_fqdn():
    """Return the fqdn defined."""
    fqdn = run_cmd(["hostname", "-f"], check=False)
    return fqdn.strip() if fqdn else ""


def is_valid_fqdn(fqdn: str) -> bool:
    """Basic FQDN validation."""
    if not fqdn:
        return False
    fqdn = fqdn.rstrip(".")
    if fqdn in ("localhost", "localhost.localdomain"):
        return False
    if "." not in fqdn:
        return False
    # RFC-ish validation
    labels = fqdn.split(".")
    for label in labels:
        if not label or len(label) > 63:
            return False
        if not label[0].isalnum() or not label[-1].isalnum():
            return False
        for c in label:
            if not (c.isalnum() or c == "-"):
                return False
    return True


def parse_hosts_file():
    entries = []
    try:
        with open("/etc/hosts", "r") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                parts = line.split()
                if len(parts) >= 2:
                    ip = parts[0]
                    names = parts[1:]
                    entries.append((ip, names))
    except Exception as e:
        logger.error("Unable to read /etc/hosts: %s", e)
        return False
    return entries


def check_etc_hosts(static_fqdn: str):
    short_hostname = static_fqdn.split(".")[0]
    hosts = parse_hosts_file()
    if not hosts:
        return False
    has_localhost = False
    has_fqdn_entry = False
    fqdn_ip = None
    for ip, names in hosts:
        if ip == "127.0.0.1" and "localhost" in names:
            has_localhost = True
        if static_fqdn in names:
            fqdn_ip = ip
            # Debian expects FQDN first, then short hostname
            if names[0] != static_fqdn or short_hostname not in names:
                logger.error(
                    "/etc/hosts entry for %s is malformed.\n"
                    "Expected order: %s %s %s",
                    static_fqdn,
                    ip,
                    static_fqdn,
                    short_hostname
                )
                return False
            has_fqdn_entry = True
    if not has_localhost:
        logger.error(
            "/etc/hosts is missing required localhost entry:\n"
            "  127.0.0.1 localhost"
        )
        return False
    if not has_fqdn_entry:
        logger.error(
            "/etc/hosts is missing required Debian hostname entry:\n"
            "  127.0.1.1 %s %s",
            static_fqdn,
            short_hostname
        )
        return False
    if fqdn_ip != "127.0.1.1":
        logger.error(
            "/etc/hosts entry for %s must use 127.0.1.1 on Debian (found %s)",
            static_fqdn,
            fqdn_ip
        )
        return False
    return True


def check_fqdn_setup(fqdn: str):
    """Check that the machine's FQDN is properly configured and resolvable and equal to --internal-fqdn."""
    logger.info("Checking FQDN setup")
    found_fqdn = get_fqdn()
    if found_fqdn != fqdn:
        logger.error(
            "[x] FQDN '%s' set on the machine does not match expected FQDN '%s'. Please set it using:\n"
            "  hostnamectl set-hostname <fqdn> or redefine --internal-fqdn to match the machine's FQDN",
            found_fqdn or "<empty>",
            fqdn
        )
        return False
    if not is_valid_fqdn(found_fqdn):
        logger.error(
            "[x] Invalid FQDN detected: '%s'. Please set a proper FQDN using:\n"
            "  hostnamectl set-hostname <fqdn>",
            found_fqdn or "<empty>"
        )
        return False
    domain = run_cmd(["hostname", "-d"], check=False)
    if not domain:
        logger.error("[x] The machine's domain name is not defined. Please set it using hostnamectl set-hostname <fqdn_of_server> and add it to /etc/hosts file")
        return False
    if not check_etc_hosts(found_fqdn):
        logger.error("[x] /etc/hosts file is not properly configured. Please ensure it contains the following entries:\n")
        logger.error("127.0.0.1 localhost")
        logger.error(f"127.0.1.1 {found_fqdn} {found_fqdn.split('.')[0]}")
        return False
    logger.info("[v] FQDN setup looks correct")
    return True


def ensure_vault_password():
    if not VAULT_PASSWORD_FILE.exists():
        pwd = generate_password(12)
        VAULT_PASSWORD_FILE.write_text(pwd)
        os.chmod(VAULT_PASSWORD_FILE, 0o600)


def vault_string(secret, name):
    """Return vaulted string exactly as ansible-vault produces"""
    ensure_vault_password()
    cmd = [
        "ansible-vault", "encrypt_string",
        "--vault-password-file", str(VAULT_PASSWORD_FILE),
        secret,
        "--name", name
    ]
    output = run_cmd(cmd)
    if not output:
        logger.error("Failed to vault %s", name)
        sys.exit(1)
    return output  # already formatted with !vault |


def check_fqdn_reachable(fqdn: str):
    """Check if an FQDN is resolvable via DNS."""
    if not fqdn:
        return True
    logger.info("Checking FQDN reachability: %s", fqdn)
    try:
        socket.getaddrinfo(fqdn, None)
        logger.info("[v] FQDN reachable: %s", fqdn)
        return True
    except socket.gaierror as e:
        logger.error("[x] FQDN not reachable: %s (%s)", fqdn, e)
        return False


def check_mysql_connectivity(host: str, port: str, user: str, password: str, database: str):
    """Check MySQL connectivity using mysql CLI or attempt via socket."""
    if not host or not user or not password:
        logger.warning("Skipping MySQL connectivity check: missing parameters")
        return True
    logger.info("Checking MySQL connectivity to %s:%s/%s", host, port or "3306", database)
    port = port or "3306"
    cmd = [
        "mysql", "-h", str(host), "-P", str(port), "-u", str(user),
        f"-p{str(password)}", "-e", "SELECT 1;"
    ]
    if database:
        cmd.extend(["-D", str(database)])
    result = run_cmd(cmd, check=False)
    if result and "1" in result:
        logger.info("[v] MySQL connectivity verified to %s:%s/%s", host, port, database or "(any)")
        return True
    else:
        logger.error("[x] MySQL connectivity failed to %s:%s/%s", host, port, database or "(any)")
        return False


def check_mysql_user_create_view_privilege(host: str, port: str, user: str, password: str):
    """Check if MySQL user has CREATE VIEW privileges."""
    if not host or not user or not password:
        logger.warning("Skipping MySQL CREATE VIEW privilege check: missing parameters")
        return True
    logger.info("Checking MySQL CREATE VIEW privileges for user %s on %s:%s", user, host, port or "3306")
    port = port or "3306"
    cmd = [
        "mysql", "-h", str(host), "-P", str(port), "-u", str(user),
        f"-p{str(password)}", "-e",
        "SHOW GRANTS FOR CURRENT_USER;"
    ]
    result = run_cmd(cmd, check=False)
    if result and "CREATE VIEW" in result:
        logger.info("[v] MySQL user %s has CREATE VIEW privileges on %s:%s", user, host, port)
        return True
    else:
        logger.error("[x] MySQL user %s lacks CREATE VIEW privileges on %s:%s", user, host, port)
        return False


def check_db_database_exists(db_host, db_port, db_name, db_user, db_pass):
    """Check if the specified database exists and contains the required tables and views."""
    logger.info(f"Checking if database '{db_name}' exists and contains required tables and views...")
    required_tables = [
        'glpi_computers', 'glpi_users', 'glpi_profiles', 'glpi_entities', 'glpi_groups',
        'glpi_softwares', 'glpi_networkports', 'glpi_printers', 'glpi_monitors',
        'glpi_peripherals', 'glpi_tickets', 'glpi_changes', 'glpi_problems',
        'glpi_projects', 'glpi_locations', 'glpi_plugin_glpiinventory_collects'
    ]
    required_views = ['glpi_computers_pulse', 'glpi_view_computers_items_printer', 'glpi_view_computers_items_peripheral', 'glpi_view_peripherals_manufacturers']
    import pymysql
    try:
        conn = pymysql.connect(
            host=db_host,
            port=int(db_port),
            user=db_user,
            password=db_pass
        )
        cursor = conn.cursor()
        cursor.execute(f"SHOW DATABASES LIKE '{db_name}'")
        if not cursor.fetchone():
            logger.error(f"[x] Database '{db_name}' does not exist.")
            return False
        cursor.execute(f"USE {db_name}")
        for table in required_tables:
            cursor.execute(f"SHOW TABLES LIKE '{table}'")
            if not cursor.fetchone():
                logger.error(f"[x] Required table '{table}' is missing in database '{db_name}'.")
                return False
        for view in required_views:
            cursor.execute(f"SHOW FULL TABLES IN {db_name} WHERE TABLE_TYPE LIKE 'VIEW' AND Tables_in_{db_name} LIKE '{view}'")
            if not cursor.fetchone():
                logger.error(f"[x] Required view '{view}' is missing in database '{db_name}'.")
                return False
        logger.info("[v] Database contains all required tables and views.")
        return True
    except pymysql.Error as err:
        logger.error(f"[x] Database connection failed: {err}")
        return False
    finally:
        if 'conn' in locals() and conn.open:
            conn.close()


def get_tokens_from_db(db_host, db_port, db_user, db_pass, db_name, api_user):
    """Retrieve API tokens from the database."""
    logger.info("[=] Retrieving API tokens from the database...")
    import pymysql
    try:
        conn = pymysql.connect(
            host=db_host,
            port=int(db_port),
            user=db_user,
            password=db_pass,
            database=db_name
        )
        cursor = conn.cursor()
        cursor.execute("SELECT app_token from glpi_apiclients WHERE name = 'MMC' LIMIT 1")
        row = cursor.fetchone()
        apptoken = row[0] if row else None
        logger.info("[v] API app token retrieved successfully from the database.")
        cursor.execute(f"SELECT api_token from glpi_users WHERE name = '{api_user}' LIMIT 1")
        row = cursor.fetchone()
        usertoken = row[0] if row else None
        logger.info("[v] API user token retrieved successfully from the database.")
        return apptoken, usertoken
    except pymysql.Error as err:
        logger.error(f"[x] Error retrieving API tokens from the database: {err}")
        return None, None
    finally:
        if 'cursor' in locals():
            cursor.close()
        if 'conn' in locals() and conn.open:
            conn.close()

    
def check_api_connection(api_url, api_user, api_pass, api_usertoken, api_apptoken):
    """Check if the API connection can be established with the provided parameters."""
    logger.info("Checking API connection with provided parameters...")
    try:
        #First initialize session with user credentials and app token to verify that the API user credentials and token are correct, as well as to check if there are any IP restrictions configured on the API server that may block the connection from Medulla server.
        response = requests.get(f"{api_url}/initSession", auth=(api_user, api_pass), headers={"App-Token": f"{api_apptoken}"}, timeout=10)
        if response.status_code == 200:
            logger.info("[v] API connection successful using user credentials and app token.")
        else:
            logger.error(f"[x] API connection failed: Received status code {response.status_code}")
            logger.error("[x] Please check if the API endpoint is correct and if there are any IP restrictions configured on the API server.")
            # Give user the curl command to test API connection with provided parameters for easier troubleshooting
            logger.error(f"To test the API connection with the provided parameters, you can run the following curl command from the Medulla server:\n")
            logger.error(f"curl -u {api_user}:{api_pass} -H \"App-Token: {api_apptoken}\" {api_url}/initSession")
            return False
        
        # Now test the API connection using user token to verify that the user token is also correct and can be used for authentication, as it will be needed for the actual API calls made by Medulla to the ITSM provider. This will help ensure that both the app token and user token are valid and can be used for successful API communication.
        response = requests.get(f"{api_url}/initSession?get_full_session=true", headers={"App-Token": f"{api_apptoken}", "Authorization": f"user_token {api_usertoken}"}, timeout=10)
        if response.status_code == 200:
            logger.info("[v] API connection successful using user token authentication.")
            return True
        else:
            logger.error(f"[x] API connection failed with user token authentication: Received status code {response.status_code}")
            # Give user the curl command to test API connection with user token authentication for easier troubleshooting
            logger.error(f"To test the API connection with user token authentication, you can run the following curl command from the Medulla server:\n")
            logger.error(f"curl -H \"App-Token: {api_apptoken}\" -H \"Authorization: user_token {api_usertoken}\" {api_url}/initSession?get_full_session=true")
            return False
    except requests.exceptions.RequestException as e:
        logger.error(f"[x] API connection failed: {e}")
        return False


def check_url_reachable(url: str):
    """Check if a URL is reachable."""
    if not url:
        logger.warning("Skipping URL reachability check: missing URL")
        return True
    logger.info("Checking URL reachability at %s", url)
    import urllib.request
    import urllib.error
    try:
        with urllib.request.urlopen(url, None, timeout=5) as response:
            logger.info("[v] URL reachable at %s (HTTP %s)", url, response.getcode())
            return True
    except urllib.error.HTTPError as e:
        # HTTP error still means the server is reachable
        if e.code in (400, 401, 403, 405):
            logger.info("[v] URL reachable at %s (HTTP %s)", url, e.code)
            return True
        logger.error("[x] Server error at %s (HTTP %s)", url, e.code)
    except Exception as e:
        logger.error("[x] URL not reachable at %s: %s", url, e)
        return False


def check_ldap_connectivity(ldap_url: str, ldap_type: str):
    """Check if LDAP server is reachable. Validates URL format (protocol://hostname:port) and connectivity."""
    if not ldap_url or not ldap_type:
        logger.warning("Skipping LDAP connectivity check: missing parameters")
        return True
    
    # Validate LDAP type
    if ldap_type not in ('ad', 'openldap'):
        logger.error("[x] Invalid LDAP type '%s'. Must be 'ad' or 'openldap'", ldap_type)
        return False
    
    logger.info("Checking LDAP connectivity to %s (type: %s)", ldap_url, ldap_type)
    
    # Parse URL format: protocol://hostname:port
    try:
        from urllib.parse import urlparse
        parsed = urlparse(ldap_url)
        if not parsed.scheme or not parsed.hostname or not parsed.port:
            logger.error("[x] Invalid LDAP URL format. Expected 'protocol://hostname:port' (e.g., ldaps://example.com:636)")
            return False
        
        protocol = parsed.scheme
        hostname = parsed.hostname
        port = parsed.port
        
        # Validate protocol for LDAP
        if protocol not in ('ldap', 'ldaps'):
            logger.error("[x] Invalid LDAP protocol '%s'. Must be 'ldap' or 'ldaps'", protocol)
            return False
        
        # Try to connect to LDAP server
        try:
            conn = socket.create_connection((hostname, port), timeout=5)
            conn.close()
            logger.info("[v] LDAP server reachable at %s:%d", hostname, port)
            return True
        except socket.timeout:
            logger.error("[x] LDAP server connection timeout at %s:%d", hostname, port)
            return False
        except socket.error as e:
            logger.error("[x] LDAP server connection failed at %s:%d: %s", hostname, port, e)
            return False
    except Exception as e:
        logger.error("[x] Error parsing LDAP URL: %s", e)
        return False


def check_ldap_bind(ldap_url: str, bind_dn: str, bind_password: str, base_dn: str):
    """Check if LDAP bind is successful using ldapsearch."""
    if not ldap_url or not bind_dn or not bind_password:
        logger.warning("Skipping LDAP bind check: missing parameters")
        return True
    logger.info("Checking LDAP bind to %s as %s with base DN %s", ldap_url, bind_dn, base_dn)
    cmd = [
        "ldapsearch",
        "-x",
        "-H", str(ldap_url),
        "-D", str(bind_dn),
        "-w", str(bind_password),
        "-b", str(base_dn),
        "-s", "base",
        "1.1"  # no attributes
    ]
    result = run_cmd(cmd, check=False)
    if result and "result: 0 Success" in result:
        logger.info("[v] LDAP bind successful to %s as %s", ldap_url, bind_dn)
        return True
    else:
        logger.error("[x] LDAP bind failed to %s as %s", ldap_url, bind_dn)
        return False
    

def check_ssl_certificate_file(filename: str, ansible_hosts_dir: str):
    """Check if SSL PEM chain certificate file exists, is readable, and contains valid PEM certificates."""
    if not filename:
        logger.warning("Skipping SSL certificate check: missing filename")
        return True
    logger.info("Checking SSL certificate file: %s", filename)
    cert_path = Path(ansible_hosts_dir) / filename
    
    # Check if file exists
    if not cert_path.exists():
        logger.error("[x] SSL certificate file not found at %s", cert_path)
        return False
    
    # Check if file is readable
    if not cert_path.is_file():
        logger.error("[x] SSL certificate path is not a file: %s", cert_path)
        return False
    
    try:
        # Read file content
        with open(cert_path, 'r') as f:
            content = f.read()
        
        # Check for valid PEM certificate blocks
        pem_begin = "-----BEGIN CERTIFICATE-----"
        pem_end = "-----END CERTIFICATE-----"
        
        if pem_begin not in content or pem_end not in content:
            logger.error("[x] SSL certificate file does not contain valid PEM certificate blocks at %s", cert_path)
            return False
        
        # Count certificates in the chain
        cert_count = content.count(pem_begin)
        if cert_count == 0:
            logger.error("[x] SSL certificate file contains no certificates at %s", cert_path)
            return False
        
        logger.info("[v] SSL certificate file is valid with %d certificate(s) in the chain at %s", cert_count, cert_path)
        return True
    except IOError as e:
        logger.error("[x] Cannot read SSL certificate file at %s: %s", cert_path, e)
        return False
    except Exception as e:
        logger.error("[x] Error validating SSL certificate file at %s: %s", cert_path, e)
        return False


def check_ssl_private_key_file(filename: str, ansible_hosts_dir: str):
    """Check if SSL PEM private key file exists, is readable, and contains valid PEM private key."""
    if not filename:
        logger.warning("Skipping SSL private key check: missing filename")
        return True
    logger.info("Checking SSL private key file: %s", filename)
    key_path = Path(ansible_hosts_dir) / filename
    
    # Check if file exists
    if not key_path.exists():
        logger.error("[x] SSL private key file not found at %s", key_path)
        return False
    
    # Check if file is readable
    if not key_path.is_file():
        logger.error("[x] SSL private key path is not a file: %s", key_path)
        return False
    
    try:
        # Read file content
        with open(key_path, 'r') as f:
            content = f.read()
        
        # Check for valid PEM private key block
        valid_blocks = [
            ("-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----"),
            ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
        ]
        
        for begin, end in valid_blocks:
            if begin in content and end in content:
                logger.info("[v] SSL private key file is valid at %s", key_path)
                return True
            
        logger.error(
            "[x] SSL private key file does not contain a valid PEM private key block at %s", key_path
        )
        return False
    
    except IOError as e:
        logger.error("[x] Cannot read SSL private key file at %s: %s", key_path, e)
        return False
    except Exception as e:
        logger.error("[x] Error validating SSL private key file at %s: %s", key_path, e)
        return False


def check_ssh_connection_to_host(host: str, user: str, key_file: Path):
    """Check if SSH connection can be established to a host using the provided key file."""
    logger.info("Checking SSH connection to %s@%s using key %s", user, host, key_file)
    if not host or not user or not key_file:
        logger.warning("Skipping SSH connection check: missing parameters")
        return True
    cmd = [
        "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",
        "-i", str(key_file), f"{user}@{host}", "echo SSH_OK"
    ]
    result = run_cmd(cmd, check=False)
    if result and "SSH_OK" in result:
        logger.info("[v] SSH connection successful to %s@%s", user, host)
        return True
    else:
        logger.error("[x] SSH connection failed to %s@%s", user, host)
        return False


def check_relay_sudo_privileges_via_ssh(host: str, user: str, key_file: Path):
    """Check if the relay user has passwordless sudo privileges on the relay host by running a simple command via SSH."""
    logger.info("Checking if %s has passwordless sudo privileges on the relay host", user)
    cmd = [
        "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",
        "-i", str(key_file), f"{user}@{host}", "sudo -n true"
    ]
    result = run_cmd(cmd, check=False)
    if result is not None:
        logger.info("[v] %s has passwordless sudo privileges on the relay host", user)
        return True
    else:
        logger.error("[x] %s does not have passwordless sudo privileges on the relay host", user)
        return False
    

def check_network_interface_exists(parameter_name: str, interface: str):
    """Check if a network interface exists on the system."""
    logger.info("Checking if network interface defined for %s exists: %s", parameter_name, interface)
    try:
        interfaces = socket.if_nameindex()
        for idx, name in interfaces:
            if name == interface:
                logger.info("[v] Network interface '%s' exists", interface)
                return True
        logger.error("[x] Network interface '%s' does not exist", interface)
        return False
    except Exception as e:
        logger.error("[x] Failed to check network interfaces: %s", e)
        return False


def generate_ssh_keys():
    """Create SSH keypair if it doesn't exist."""
    key_path = Path.home() / ".ssh" / "id_rsa"
    key_path.parent.mkdir(parents=True, exist_ok=True)
    if key_path.exists():
        logger.info("SSH key %s already exists; skipping generation", key_path)
        return key_path

    cmd = [
        "ssh-keygen", "-t", "rsa", "-b", "4096",
        "-f", str(key_path), "-N", "", "-C", "medulla_deploy"
    ]
    logger.info("Generating SSH key: %s", key_path)
    try:
        run_cmd(cmd, stream=True)
    except SystemExit:
        logger.error("Failed to generate SSH key %s", key_path)
        raise
    key_path.chmod(0o600)
    logger.info("SSH key generated: %s", key_path)
    return key_path


def download_and_extract_playbook(base_url: str, dest_dir: Path = Path(".")) -> Path:
    dest_dir = Path(dest_dir)
    dest_dir.mkdir(parents=True, exist_ok=True)

    import urllib.request
    
    archive_name = f"ansible-{PLAYBOOK_VERSION}.tar.gz"
    url = f"{base_url}/{archive_name}?token={PLAYBOOK_TOKEN}"
    dest = dest_dir / archive_name

    logger.info("Downloading playbook from %s to %s", url, dest)

    try:
        urllib.request.urlretrieve(url, dest)
    except Exception as exc:
        logger.error("Failed to download playbook from %s: %s", url, exc)
        raise RuntimeError("Playbook download failed") from exc

    logger.info("Playbook downloaded: %s", dest)
    logger.info("Extracting playbook to %s", dest_dir)

    try:
        with tarfile.open(dest, "r:gz") as tar:
            tar.extractall(dest_dir)
    except Exception as exc:
        logger.error("Failed to extract playbook %s: %s", dest, exc)
        raise RuntimeError("Playbook extraction failed") from exc

    logger.info("Playbook extracted to %s", dest_dir)
    return dest


def run_playbook(playbook="playbook_medullamain.yml", vault_password_file=None, inventory=None, limit="medulla", ansible_dir="ansible", local_connection=True):
    if vault_password_file is None:
        vault_password_file = VAULT_PASSWORD_FILE
    if inventory is None:
        inventory = INVENTORY_FILE

    # Change to ansible directory
    ansible_path = Path(ansible_dir)
    if not ansible_path.exists():
        logger.error("Ansible directory not found: %s", ansible_path)
        sys.exit(1)

    original_dir = os.getcwd()
    logger.info("Changing directory to %s", ansible_path)
    os.chdir(ansible_path)

    try:
        logger.info("Running ansible-playbook %s (inventory=%s, limit=%s)", playbook, inventory, limit)
        logger.info("Ansible playbook command: ansible-playbook %s --vault-password-file %s -i %s --limit %s --connection %s",
                   playbook, vault_password_file, inventory, limit, "local" if local_connection else "ssh")
        cmd = [
            "ansible-playbook", str(playbook),
            "--vault-password-file", str(vault_password_file),
            "-i", str(inventory),
            "--limit", str(limit),
            "--connection", "local" if local_connection else "ssh"
        ]
        logger.info("Starting ansible-playbook execution...")
        run_cmd(cmd, stream=True)
        logger.info("Ansible-playbook execution completed successfully")
    finally:
        # Change back to original directory
        os.chdir(original_dir)
        logger.debug("Changed back to directory %s", original_dir)


def set_timezone(tz: str):
    """Set system timezone to `tz`. Uses timedatectl when available, otherwise falls back to /etc/localtime symlink and /etc/timezone."""
    if not tz:
        tz = TIMEZONE
    logger.info("Setting timezone to %s", tz)
    # Validate timezone file exists
    zonefile = Path('/usr/share/zoneinfo') / tz
    if not zonefile.exists():
        logger.error("Timezone '%s' not found under /usr/share/zoneinfo", tz)
        sys.exit(1)

    if shutil.which('timedatectl'):
        run_cmd(['timedatectl', 'set-timezone', tz], stream=False)
    else:
        # Fallback: link /etc/localtime and write /etc/timezone
        try:
            run_cmd(['ln', '-sf', str(zonefile), '/etc/localtime'], stream=False)
            Path('/etc/timezone').write_text(tz)
        except Exception as e:
            logger.error("Failed to set timezone via fallback: %s", e)
            sys.exit(1)
    logger.info("Timezone set to %s", tz)

# =====================================================
# Install Ansible and dependencies
# =====================================================
def install_ansible():
    """
    Install Ansible using the official Ansible PPA signing key and repository,
    equivalent to the provided bash snippet.
    """
    if shutil.which("ansible"):
        logger.info("Ansible is already installed.")
        return

    logger.info("Installing Ansible from Ansible PPA repository")

    # Ensure required tools are present
    run_cmd([
        "apt-get", "update"
    ])
    run_cmd([
        "apt-get", "install", "-y",
        "wget", "gnupg", "lsb-release", "ca-certificates"
    ])

    # Determine Ubuntu codename used by the Ansible PPA
    # Debian 12 (bookworm) → use Ubuntu jammy
    ubuntu_codename = "jammy"

    keyring_path = "/usr/share/keyrings/ansible-archive-keyring.gpg"
    repo_file = "/etc/apt/sources.list.d/ansible.list"

    # Import Ansible signing key
    logger.info("Importing Ansible signing key")
    run_cmd([
        "bash", "-c",
        (
            "wget -qO- "
            "'https://keyserver.ubuntu.com/pks/lookup?"
            "fingerprint=on&op=get&search=0x6125E2A8C77F2818FB7BD15B93C4A3FD7BB9C367' "
            f"| gpg --dearmor -o {keyring_path} --yes"
        )
    ])

    # Add Ansible repository
    logger.info("Adding Ansible APT repository")
    repo_line = (
        f"deb [signed-by={keyring_path}] "
        f"http://ppa.launchpad.net/ansible/ansible/ubuntu "
        f"{ubuntu_codename} main\n"
    )
    Path(repo_file).write_text(repo_line)

    # Update package index
    run_cmd(["apt-get", "update"])

    # Install Ansible and dependencies
    packages = [
        "ansible",
        "python3-passlib",
        "python3-dnspython",
        "python3-netaddr",
        "python3-jmespath",
        "debconf-utils",
        "python3-debconf",
    ]

    logger.info("Installing Ansible packages")
    run_cmd(["apt-get", "install", "-yq"] + packages)

    logger.info("Ansible installed successfully")

    # Install MySQL client
    if not shutil.which("mysql"):
        logger.info("MySQL client not found. Installing...")
        run_cmd(["apt-get", "install", "-y", "mariadb-client"])
        logger.info("MySQL client installed successfully.")
    else:
        logger.info("MySQL client is already installed.")

    # Install ldap-utils
    if not shutil.which("ldapsearch"):
        logger.info("ldap-utils not found. Installing...")
        run_cmd(["apt-get", "install", "-y", "ldap-utils"])
        logger.info("ldap-utils installed successfully.")
    else:
        logger.info("ldap-utils is already installed.")

def install_script_dependencies():
    """Install dependencies required by the installer script."""
    logger.info("Installing script dependencies")
    run_cmd(["apt-get", "update"])
    run_cmd(["apt-get", "install", "-y", "lsof", "python3-pymysql", "python3-requests"])
    logger.info("Script dependencies installed successfully")

def display_final_message(internal_fqdn, root_password, public_ip=None):
    """Display success message explaining how to access Medulla."""
    print("\n" + "="*70)
    print("### Medulla installed successfully")
    print("# ")
    print(f"# To access Medulla, point your browser to http://{internal_fqdn}")
    print(f"# and log on using root / {root_password}")
    print("# ")
    if not public_ip:
        print("# Please note that clients outside the LAN will not be able to connect")
        print("# as no public IP address is defined")
    print("# ")
    print(f"# The client agent can be downloaded from")
    print(f"# http://{internal_fqdn}/downloads/win/Medulla-Agent-windows-FULL-latest.exe")
    print("# ")
    print("# Step 1:")
    print("# Download the agent from the URL above and install it on your Windows clients")
    print("# ")
    print("# Step 2:")
    print("# Once the install is complete, the Windows machines need to be restarted")
    print("# ")
    print("# Step 3:")
    print("# Once restarted the Windows machines will connect to Medulla to complete their")
    print("# setup then go online on Medulla console in the Computers page. This phase can")
    print("# take up to 20 minutes depending on the bandwidth between the client machine")
    print("# and Medulla server.")
    print("# Once a machine is online and inventoried, it can be managed by Medulla.")
    print("# ")
    print("# For a complete documentation of medulla, please refer to")
    print("# https://docs.medulla-tech.io")
    print("# ")
    print("="*70 + "\n")


# =====================================================
# Main ansible_hosts generation
# =====================================================
def generate_ansible_hosts(args, dry_run=False):
    global internal_fqdn, root_password

    # Before generating the file, make sure it does not exist. If it does, exit with error to avoid overwriting existing configuration.
    if INVENTORY_FILE.exists():
        print(f"Ansible inventory file {INVENTORY_FILE} already exists. Please remove it before running the installer again to avoid overwriting existing configuration.")
        # Show command that will allow user to run the playbook with the existing inventory file
        print(f"To run the playbook with the existing inventory file, use the following command for a simple install:")
        print(f"cd ansible && ansible-playbook playbook_medullamain.yml -i {INVENTORY_FILE} --vault-password-file {VAULT_PASSWORD_FILE} --limit mmcmain --connection local")
        exit(1)

    # Detect some defaults
    internal_fqdn = args.internal_fqdn or socket.getfqdn()
    root_password = args.medulla_root_pw or generate_password(12)
    itsm_db_password = args.external_itsm_dbpassword or generate_password(12)

    # Public FQDN for external access (only set if provided)
    public_fqdn = getattr(args, 'public_fqdn', None)

    # Validate public IPs if provided
    if getattr(args, 'public_ip', None):
        try:
            ipaddress.ip_address(args.public_ip)
        except ValueError:
            sys.exit(f"Invalid public IP address provided: {args.public_ip}")
    if getattr(args, 'ars_public_ip', None):
        try:
            ipaddress.ip_address(args.ars_public_ip)
        except ValueError:
            sys.exit(f"Invalid ARS public IP address provided: {args.ars_public_ip}")

    # Generate all passwords
    passwords = {
        "ROOT_PASSWORD": root_password,
        "XMPP_MASTER_PASSWORD": generate_password(12),
        "DBPASSWORD": generate_password(12),
        "GUACDBPASSWD": generate_password(12),
        "GUACAMOLE_ROOT_PASSWORD": generate_password(40),
        "CLIENTS_VNC_PASSWORD": generate_password(8),
        "AES_KEY": generate_password(32),
        "DRIVERS_PASSWORD": generate_password(12),
        "MASTER_TOKEN": generate_password(32)
    }
    
    # Only generate ITSM_DBPASSWD if external ITSM database password is not defined
    if not getattr(args, 'external_itsm_dbpassword', None):
        passwords["ITSM_DBPASSWD"] = itsm_db_password
    
    # Only generate GLPI_DBPASSWD if external ITSM type is not defined
    if not getattr(args, 'external_itsm_type', None):
        passwords["GLPI_DBPASSWD"] = itsm_db_password

    vaulted = {}
    if not dry_run:
        for k,v in passwords.items():
            vaulted[k] = vault_string(v, k)

    # Build YAML manually to preserve formatting
    lines = []
    lines.append("medulla:")
    lines.append("  hosts:")
    lines.append(f"    {internal_fqdn}:")
    if getattr(args, 'public_ip', None):
        lines.append(f"      PUBLIC_IP: {args.public_ip}")
    if public_fqdn:
        lines.append(f"      PUBLIC_FQDN: {public_fqdn}")
    lines.append(f"      INTERNAL_FQDN: {internal_fqdn}")
    if getattr(args, 'interface', None):
        lines.append(f"      INTERFACE: {args.interface}")

    # If --dmz-fqdn argument is passed on command line, add dmzars section
    if getattr(args, 'dmz_fqdn', None):
        lines.append(f"    {args.dmz_fqdn}:")
        lines.append(f"      ansible_user: medulla")
        lines.append(f"      ansible_become: true")
        if getattr(args, 'dmz_public_ip', None):
            lines.append(f"      PUBLIC_IP: {args.dmz_public_ip}")
        lines.append(f"      INTERNAL_FQDN: {args.dmz_fqdn}")
        if getattr(args, 'dmz_interface', None):
            lines.append(f"      INTERFACE: {args.dmz_interface}")
        lines.append(f"      PULSEMAIN_IP: {args.dmz_medullamainip}")
        lines.append(f"      PULSEMAIN_FQDN: {args.dmz_medullamainfqdn}")
        if public_fqdn:
            lines.append(f"      PULSEMAIN_PUBLIC_FQDN: {public_fqdn}")
        lines.append("      XMPP_DOMAIN: pulse")
        lines.append("      INITIAL_CLUSTER_SERVER: '{{ PULSEMAIN_FQDN }}'")
        lines.append("      RELAY_INDEX: 2")
        lines.append("      CLUSTER_FROM_ENTITY: false")
        lines.append("      CLUSTER_NAME_PREFIX: dmz")
    
    # If --ars-fqdn argument is passed on command line, add ars section
    index = 1
    while getattr(args, f'ars{index}_fqdn', None):
        ars_fqdn = getattr(args, f'ars{index}_fqdn')
        ars_public_ip = getattr(args, f'ars{index}_public_ip', None)
        ars_interface = getattr(args, f'ars{index}_interface', None)
        ars_medullamainip = getattr(args, f'ars{index}_medullamainip', None)
        ars_medullamainfqdn = getattr(args, f'ars{index}_medullamainfqdn', None)
        lines.append(f"    {ars_fqdn}:")
        lines.append(f"      ansible_user: medulla")
        lines.append(f"      ansible_become: true")
        if ars_public_ip:
            lines.append(f"      PUBLIC_IP: {ars_public_ip}")
        lines.append(f"      INTERNAL_FQDN: {ars_fqdn}")
        if ars_interface:
            lines.append(f"      INTERFACE: {ars_interface}")
        lines.append(f"      PULSEMAIN_IP: {ars_medullamainip}")
        lines.append(f"      PULSEMAIN_FQDN: {ars_medullamainfqdn}")
        if public_fqdn:
            lines.append(f"      PULSEMAIN_PUBLIC_FQDN: {public_fqdn}")
        index += 1

    lines.append("  vars:")
    lines.append("    PULSE4REPO_URL: https://apt.medulla-tech.io/stable.sources")
    # Build list of vaulted keys for medulla section (only authentication/connection keys)
    medulla_vaulted_keys = ["ROOT_PASSWORD"]
    for key in medulla_vaulted_keys:
        if dry_run:
            lines.append(f"    {key}: {passwords[key]}")
        else:
            for l in vaulted[key].splitlines():
                lines.append("    "+l)
    lines.append("mmcmain:")
    lines.append("  hosts:")
    lines.append(f"    {internal_fqdn}: {{}}")
    lines.append("  vars:")
    lines.append("    XMPP_DOMAIN: pulse")
    # Build list of vaulted keys for mmcmain section
    mmcmain_keys = ["DRIVERS_PASSWORD","MASTER_TOKEN"]
    for key in mmcmain_keys:
        if dry_run:
            lines.append(f"    {key}: {passwords[key]}")
        else:
            for l in vaulted[key].splitlines():
                lines.append("    "+l)
    # OIDC provider configuration (optional)
    if getattr(args, 'oidc_provider_url', None):
        lines.append(f"    OIDC_PROVIDER: OIDC")
        lines.append(f"    OIDC_PROVIDER_URL: {args.oidc_provider_url}")
        if getattr(args, 'oidc_provider_clientid', None):
            lines.append(f"    OIDC_PROVIDER_CLIENTID: {args.oidc_provider_clientid}")
        if getattr(args, 'oidc_provider_clientsecret', None):
            if dry_run:
                lines.append(f"    OIDC_PROVIDER_CLIENTSECRET: {args.oidc_provider_clientsecret}")
            else:
                v = vault_string(args.oidc_provider_clientsecret, 'OIDC_PROVIDER_CLIENTSECRET')
                for l in v.splitlines():
                    lines.append("    "+l)
    # External LDAP configuration (optional)
    if getattr(args, 'external_ldap_url', None) and getattr(args, 'external_ldap_type', None):
        lines.append(f"    EXTERNAL_LDAP_URL: {args.external_ldap_url}")
        lines.append(f"    EXTERNAL_LDAP_TYPE: {args.external_ldap_type}")
        lines.append(f"    EXTERNAL_LDAP_SUFFIX: {args.external_ldap_suffix}")
        if getattr(args, 'external_ldap_bindname', None):
            lines.append(f"    EXTERNAL_LDAP_BINDNAME: {args.external_ldap_bindname}")
        if getattr(args, 'external_ldap_password', None):
            if dry_run:
                lines.append(f"    EXTERNAL_LDAP_PASSWORD: {args.external_ldap_password}")
            else:
                v = vault_string(args.external_ldap_password, 'EXTERNAL_LDAP_PASSWORD')
                for l in v.splitlines():
                    lines.append("    "+l)
        if getattr(args, 'external_ldap_userattr', None):
            lines.append(f"    EXTERNAL_LDAP_USERATTR: {args.external_ldap_userattr}")
    # SSL certificate configuration (optional)
    if getattr(args, 'ssl_pem_chain_filename', None):
        lines.append(f"    SSL_PEM_CHAIN_FILENAME: {args.ssl_pem_chain_filename}")
        lines.append("    HTTP_PROTOCOL: https")
    if getattr(args, 'ssl_pem_key_filename', None):
        lines.append(f"    SSL_PEM_KEY_FILENAME: {args.ssl_pem_key_filename}")
    lines.append("    URBACKUP_DISABLED: true")

    # Variables for dmzars
    if getattr(args, 'dmz_fqdn', None):
        lines.append("dmzars:")
        lines.append("  hosts:")
        lines.append(f"    {args.dmz_fqdn}: {{}}")
        lines.append("  vars:")
        lines.append("    PKI_SERVER: '{{ PULSEMAIN_IP }}'")
        lines.append("    DBHOST: '{{ PULSEMAIN_IP }}'")
        lines.append("    IS_RELAY: True")
        lines.append("    IMAGING_DISABLED: true")

    # Variables for ars
    if getattr(args, 'ars1_fqdn', None):
        lines.append("ars:")
        lines.append("  hosts:")
        index = 1
        while getattr(args, f'ars{index}_fqdn', None):
            ars_fqdn = getattr(args, f'ars{index}_fqdn')
            lines.append(f"    {ars_fqdn}: {{}}")
            index += 1
        lines.append("  vars:")
        lines.append("    PKI_SERVER: '{{ PULSEMAIN_IP }}'")
        lines.append("    DBHOST: '{{ PULSEMAIN_IP }}'")
        lines.append("    IS_RELAY: True")
        lines.append("    IMAGING_DISABLED: false")
    # Global variables for all hosts
    lines.append("all:")
    lines.append("  vars:")
    mmcmain_keys = ["XMPP_MASTER_PASSWORD","DBPASSWORD","GUACDBPASSWD",
                    "GUACAMOLE_ROOT_PASSWORD","CLIENTS_VNC_PASSWORD","AES_KEY"]
    if not getattr(args, 'external_itsm_dbpassword', None):
        mmcmain_keys.insert(2, "ITSM_DBPASSWD")
    if not getattr(args, 'external_itsm_type', None):
        mmcmain_keys.append("GLPI_DBPASSWD")
    for key in mmcmain_keys:
        if dry_run:
            lines.append(f"    {key}: {passwords[key]}")
        else:
            for l in vaulted[key].splitlines():
                lines.append("    "+l)
    # External ITSM configuration (optional)
    if getattr(args, 'external_itsm_type', None):
        lines.append(f"    ITSM_LOCAL: false")
        lines.append(f"    ITSM_TYPE: {args.external_itsm_type}")
        if getattr(args, 'external_itsm_dbhost', None):
            lines.append(f"    ITSM_DBHOST: {args.external_itsm_dbhost}")
        if getattr(args, 'external_itsm_dbport', None):
            lines.append(f"    ITSM_DBPORT: {args.external_itsm_dbport}")
        if getattr(args, 'external_itsm_dbname', None):
            lines.append(f"    ITSM_DBNAME: {args.external_itsm_dbname}")
        if getattr(args, 'external_itsm_dbuser', None):
            lines.append(f"    ITSM_DBUSER: {args.external_itsm_dbuser}")
        if getattr(args, 'external_itsm_dbpassword', None):
            if dry_run:
                lines.append(f"    ITSM_DBPASSWD: {args.external_itsm_dbpassword}")
            else:
                v = vault_string(args.external_itsm_dbpassword, 'ITSM_DBPASSWD')
                for l in v.splitlines():
                    lines.append("    "+l)
        if getattr(args, 'external_itsm_apiurl', None):
            lines.append(f"    ITSM_APIURL: {args.external_itsm_apiurl}")
        if getattr(args, 'external_itsm_apiuser', None):
            lines.append(f"    ITSM_API_USER: {args.external_itsm_apiuser}")
        if getattr(args, 'external_itsm_apipassword', None):
            if dry_run:
                lines.append(f"    ITSM_API_PASSWORD: {args.external_itsm_apipassword}")
            else:
                v = vault_string(args.external_itsm_apipassword, 'ITSM_API_PASSWORD')
                for l in v.splitlines():
                    lines.append("    "+l)
        itsm_writeable = getattr(args, 'external_itsm_writeable', False)
        lines.append(f"    ITSM_WRITEABLE: {'true' if itsm_writeable else 'false'}")
        if getattr(args, 'external_itsm_baseurl', None):
            lines.append(f"    ITSM_BASEURL: {args.external_itsm_baseurl}")
        if getattr(args, 'external_itsm_inventory_type', None):
            lines.append(f"    CLIENTS_CONF_INVENTORY_COLLECTOR: {args.external_itsm_inventory_type}")
        if getattr(args, 'external_itsm_url_forward_inventory', None):
            lines.append(f"    ITSM_FORWARD_URLINVENTORY: {args.external_itsm_url_forward_inventory}")
    # Organization configuration (optional)
    if getattr(args, 'organisation', None):
        lines.append(f"    ORGANISATION: {args.organisation}")
    # Reverse SSH configuration (optional)
    if getattr(args, 'reverse_ssh_port', None):
        lines.append(f"    REVERSE_SSH_PORT: {args.reverse_ssh_port}")
    lines.append("    ansible_ssh_common_args: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'")
    lines.append("    ansible_python_interpreter: /usr/bin/python3")
    lines.append("    SSH_USER: root")
    lines.append("    proxy_env:")

    out_file = INVENTORY_FILE
    out_path = Path(out_file)
    
    if dry_run:
        # In dry-run mode, display the content to stdout
        logger.info("=== Generated ansible_hosts content (dry-run, with unvaulted passwords) ===")
        print("\n" + "\n".join(lines) + "\n")
    else:
        out_path.write_text("\n".join(lines))
        logger.info("Ansible hosts file generated: %s", out_path)
    
    return out_path


# =====================================================
# Entry point
# =====================================================
def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--only-generate-ansible-hosts", action="store_true", help="Skip system checks and ansible installation; only generate hosts file")
    parser.add_argument("--dry-run", action="store_true", help="Show generated passwords without vaulting or writing files")
    parser.add_argument("--run-mmcmain", action="store_true", help="Only run the mmcmain playbook after installation. By default all playbooks will be run sequentially")
    parser.add_argument("--run-dmzars", action="store_true", help="Only run the dmzars playbook after installation. By default all playbooks will be run sequentially")
    parser.add_argument("--run-ars", action="store_true", help="Only run the ars playbook after installation. By default all playbooks will be run sequentially")
    parser.add_argument("--timezone", help="System timezone to apply during installation")
    parser.add_argument("--medulla-root-pw", help="Root password to use for Medulla (will be vaulted)")
    parser.add_argument("--public-ip", help="Public IP address of the host")
    parser.add_argument("--interface", help="Network interface name to use")
    parser.add_argument("--internal-fqdn", help="Internal FQDN to use in inventory")
    parser.add_argument("--public-fqdn", help="Public FQDN to use in inventory")
    parser.add_argument("--verbose", action="store_true", help="Enable debug logging")
    parser.add_argument("--organisation", help="Organization name")
    parser.add_argument("--reverse-ssh-port", dest="reverse_ssh_port", help="Reverse SSH port for remote access")
    # External ITSM options
    parser.add_argument("--external-itsm-type", dest="external_itsm_type", help="Type of external ITSM (e.g., glpi, itsm-ng)")
    parser.add_argument("--external-itsm-dbhost", dest="external_itsm_dbhost", help="External ITSM database host")
    parser.add_argument("--external-itsm-dbport", dest="external_itsm_dbport", help="External ITSM database port")
    parser.add_argument("--external-itsm-dbname", dest="external_itsm_dbname", help="External ITSM database name")
    parser.add_argument("--external-itsm-dbuser", dest="external_itsm_dbuser", help="External ITSM database user")
    parser.add_argument("--external-itsm-dbpassword", dest="external_itsm_dbpassword", help="External ITSM database password (will be vaulted)")
    parser.add_argument("--external-itsm-apiurl", dest="external_itsm_apiurl", help="External ITSM API URL. If not provided but external ITSM type is provided, it will be set to {external_itsm_baseurl}/apirest.php")
    parser.add_argument("--external-itsm-apiuser", dest="external_itsm_apiuser", help="External ITSM API user")
    parser.add_argument("--external-itsm-apipassword", dest="external_itsm_apipassword", help="External ITSM API password (will be vaulted)")
    parser.add_argument("--external-itsm-apiusertoken", dest="external_itsm_apiusertoken", help="External ITSM API user token (will be vaulted)")
    parser.add_argument("--external-itsm-apiapptoken", dest="external_itsm_apiapptoken", help="External ITSM API app token (will be vaulted)")
    parser.add_argument("--external-itsm-writeable", dest="external_itsm_writeable", type=str_to_bool, default=False, help="Set external ITSM as writable (true/false)")
    parser.add_argument("--external-itsm-baseurl", dest="external_itsm_baseurl", help="External ITSM base URL")
    parser.add_argument("--external-itsm-inventory-type", dest="external_itsm_inventory_type", help="Type of external ITSM inventory (e.g., ocs, glpi)")
    parser.add_argument("--external-itsm-url-forward-inventory", dest="external_itsm_url_forward_inventory", help="External ITSM URL for forward inventory. If ITSM-NG is used, this URL must point to OCS.")
    # OIDC provider options
    parser.add_argument("--oidc-provider-url", dest="oidc_provider_url", help="OIDC provider URL (e.g. https://auth.example.com)")
    parser.add_argument("--oidc-provider-clientid", dest="oidc_provider_clientid", help="OIDC provider client id")
    parser.add_argument("--oidc-provider-clientsecret", dest="oidc_provider_clientsecret", help="OIDC provider client secret (will be vaulted)")
    # External LDAP options
    parser.add_argument("--external-ldap-url", dest="external_ldap_url", help="External LDAP server URL (format: protocol://hostname:port, e.g., ldaps://ldap.company.com:636)")
    parser.add_argument("--external-ldap-type", dest="external_ldap_type", help="Type of external LDAP server (ad or openldap)")
    parser.add_argument("--external-ldap-suffix", dest="external_ldap_suffix", help="External LDAP suffix (e.g., dc=company,dc=com)")
    parser.add_argument("--external-ldap-bindname", dest="external_ldap_bindname", help="External LDAP bind DN (e.g., cn=admin,dc=company,dc=com)")
    parser.add_argument("--external-ldap-password", dest="external_ldap_password", help="External LDAP bind password (will be vaulted)")
    parser.add_argument("--external-ldap-userattr", dest="external_ldap_userattr", help="External LDAP attribute to use as user name (default: cn)")
    parser.add_argument("--external-ldap-test-user", dest="external_ldap_test_user", help="External LDAP test user DN to validate connectivity and authentication (e.g., cn=test.user,dc=company,dc=com)")
    parser.add_argument("--external-ldap-test-password", dest="external_ldap_test_password", help="External LDAP test user password (will be vaulted)")
    # SSL certificate options
    parser.add_argument("--ssl-pem-chain-filename", dest="ssl_pem_chain_filename", help="SSL PEM chain certificate filename (file must exist in same directory as ansible_hosts)")
    parser.add_argument("--ssl-pem-key-filename", dest="ssl_pem_key_filename", help="SSL PEM private key filename (file must exist in same directory as ansible_hosts)")
    # DMZ options
    parser.add_argument("--dmz-fqdn", dest="dmz_fqdn", help="DMZ FQDN")
    parser.add_argument("--dmz-public-ip", dest="dmz_public_ip", help="Public IP address of the DMZ host")
    parser.add_argument("--dmz-interface", dest="dmz_interface", help="Network interface name to use for DMZ")
    parser.add_argument("--dmz-medullamainip", dest="dmz_medullamainip", help="Medulla main server IP address for DMZ configuration")
    parser.add_argument("--dmz-medullamainfqdn", dest="dmz_medullamainfqdn", help="Medulla main server FQDN for DMZ configuration")
    # ARS options: ensure all are registered before parsing (underscore variant only)
    import re
    ars_indices = set()
    ars_fqdn_pattern = re.compile(r"--ars(\d+)-fqdn")
    for arg in sys.argv:
        m = ars_fqdn_pattern.match(arg)
        if m:
            ars_indices.add(int(m.group(1)))
    for index in sorted(ars_indices):
        parser.add_argument(f"--ars{index}-fqdn", dest=f"ars{index}_fqdn", help=f"ARS FQDN for ARS instance {index}")
        parser.add_argument(f"--ars{index}-public-ip", dest=f"ars{index}_public_ip", help=f"Public IP address of the ARS host for ARS instance {index}")
        parser.add_argument(f"--ars{index}-interface", dest=f"ars{index}_interface", help=f"Network interface name to use for ARS for ARS instance {index}")
        parser.add_argument(f"--ars{index}-medullamainip", dest=f"ars{index}_medullamainip", help=f"Medulla main server IP address for ARS configuration for ARS instance {index}")
        parser.add_argument(f"--ars{index}-medullamainfqdn", dest=f"ars{index}_medullamainfqdn", help=f"Medulla main server FQDN for ARS configuration for ARS instance {index}")

    args = parser.parse_args()

    # Make sure --run-mmcmain, --run-dmzars and --run-ars are not used together but at least one of them are used
    if (args.run_mmcmain and args.run_dmzars) or (args.run_mmcmain and args.run_ars) or (args.run_dmzars and args.run_ars):
        parser.error("Cannot use --run-mmcmain, --run-dmzars and --run-ars together; please choose only one")

    # Set default value for external ITSM database name if external ITSM is configured
    if getattr(args, 'external_itsm_type', None) and not getattr(args, 'external_itsm_dbname', None):
        args.external_itsm_dbname = 'glpi'
        logger.debug("Set default external ITSM database name to 'glpi'")
    
    # Set default value for external ITSM database port if external ITSM is configured
    if getattr(args, 'external_itsm_type', None) and not getattr(args, 'external_itsm_dbport', None):
        args.external_itsm_dbport = '3306'
        logger.debug("Set default external ITSM database port to '3306'")
    
    # Set default value for external LDAP type if LDAP URL is provided
    if getattr(args, 'external_ldap_url', None) and not getattr(args, 'external_ldap_type', None):
        args.external_ldap_type = 'ad'
        logger.debug("Set default external LDAP type to 'ad'")
        if not getattr(args, 'external_ldap_suffix', None):
            parser.error("--external-ldap-suffix is required when using --external-ldap-url")
    
    # Validate that mandatory arguments are provided when using external ITSM
    if getattr(args, 'external_itsm_type', None):
        if not getattr(args, 'external_itsm_dbhost', None):
            parser.error("--external-itsm-dbhost is required when using --external-itsm-type")
        if not getattr(args, 'external_itsm_dbname', None):
            parser.error("--external-itsm-dbname is required when using --external-itsm-type")
        if not getattr(args, 'external_itsm_dbuser', None):
            parser.error("--external-itsm-dbuser is required when using --external-itsm-type")
        if not getattr(args, 'external_itsm_dbpassword', None):
            parser.error("--external-itsm-dbpassword is required when using --external-itsm-type")
        if getattr(args, 'external_itsm_writeable', False):
            if not getattr(args, 'external_itsm_apiuser', None):
                parser.error("--external-itsm-apiuser is required when using --external-itsm-type")
            if not getattr(args, 'external_itsm_apipassword', None):
                parser.error("--external-itsm-apipassword is required when using --external-itsm-type")
        if not getattr(args, 'external_itsm_baseurl', None):
            parser.error("--external-itsm-baseurl is required when using --external-itsm-type")

    # Validate that mandatory arguments are provided when using --ars-fqdn
    index = 1
    while getattr(args, f'ars{index}_fqdn', None):
        if not getattr(args, f'ars{index}_medullamainip', None):
            parser.error(f"--ars{index}-medullamainip is required when using --ars{index}-fqdn")
        if not getattr(args, f'ars{index}_medullamainfqdn', None):
            parser.error(f"--ars{index}-medullamainfqdn is required when using --ars{index}-fqdn")
        index += 1

    # Enable debug logging if requested
    if args.verbose:
        logger.setLevel(logging.DEBUG)

    if not args.only_generate_ansible_hosts:
        install_ansible()

        install_script_dependencies()

        # Set timezone (use provided or default to Europe/Paris) unless dry-run
        if not args.dry_run:
            tz_to_set = args.timezone or 'Europe/Paris'
            set_timezone(tz_to_set)

    # Do not re-generate ansible_hosts if running ars playbook
    if not args.run_ars:
        # Generate ansible_hosts file
        generate_ansible_hosts(args, dry_run=args.dry_run)
    
    if args.only_generate_ansible_hosts and not args.run_ars:
        logger.info("Ansible hosts file generation complete. Exiting.")
        return
    
    # Run pre-flight checks to validate provided arguments (run even in dry-run)
    logger.info("=== Running pre-flight checks ===")
    all_checks_passed = True

    # Check that passwords provided via command line arguments are valid per check_password_is_valid function
    if getattr(args, 'medulla_root_pw', None) and not check_password_is_valid('--medulla-root-pw', args.medulla_root_pw):
        all_checks_passed = False
    if getattr(args, 'external_itsm_dbpassword', None) and not check_password_is_valid('--external-itsm-dbpassword', args.external_itsm_dbpassword):
        all_checks_passed = False
    if getattr(args, 'external_itsm_apipassword', None) and not check_password_is_valid('--external-itsm-apipassword', args.external_itsm_apipassword):
        all_checks_passed = False
    if getattr(args, 'oidc_provider_clientsecret', None) and not check_password_is_valid('--oidc-provider-clientsecret', args.oidc_provider_clientsecret):
        all_checks_passed = False
    if getattr(args, 'external_ldap_password', None) and not check_password_is_valid('--external-ldap-password', args.external_ldap_password):
        all_checks_passed = False

    # Check OS is Debian 12 or later
    if not check_os_is_debian12():
        all_checks_passed = False

    # Check script is run as root
    if not check_user_is_root():
        all_checks_passed = False

    # Check minimum RAM
    if not check_minimum_ram(MINIMUM_RAM_GB):
        all_checks_passed = False

    # Check minimum cpus
    if not check_minimum_cpus(MINIMUM_CPU_CORES):
        all_checks_passed = False

    # Check minimum disk space
    if not check_minimum_disk_space(MINIMUM_DISK_SPACE_GB):
        all_checks_passed = False

    # Check internet connectivity
    if not check_internet_connectivity():
        all_checks_passed = False

    # Check if ipv6 is disabled
    if not check_ipv6_disabled():
        all_checks_passed = False

    # Check if systemd limits are properly configured
    if not check_systemd_limits():
        all_checks_passed = False

    # Check if lsof counts are ok
    if not check_lsof_counts():
        all_checks_passed = False

    # check if sysctl is properly configured
    if not check_sysctl_conf():
        all_checks_passed = False

    # Check sources.list is properly configured
    if not check_debian_repositories():
        all_checks_passed = False

    # Check if a previous installation of Medulla is detected
    if not check_previous_medulla_installation():
        all_checks_passed = False

    # Check FQDN is properly set up and internal FQDN is equal to FQDN defined on machine
    if not check_fqdn_setup(internal_fqdn):
        all_checks_passed = False

    # Check FQDN reachability
    if internal_fqdn and not check_fqdn_reachable(internal_fqdn):
        all_checks_passed = False
    if getattr(args, 'public_fqdn', None) and not check_fqdn_reachable(args.public_fqdn):
        all_checks_passed = False

    # Check if interface provided exist on the machine
    if getattr(args, 'interface', None) and not check_network_interface_exists('--interface', args.interface):
        all_checks_passed = False

    # Check MySQL connectivity and api accessibility for external ITSM
    if getattr(args, 'external_itsm_type', None):
        if not check_mysql_connectivity(
            getattr(args, 'external_itsm_dbhost', None),
            getattr(args, 'external_itsm_dbport', None),
            getattr(args, 'external_itsm_dbuser', None),
            getattr(args, 'external_itsm_dbpassword', None),
            getattr(args, 'external_itsm_dbname', None)
        ):
            all_checks_passed = False

        # Check if dbuser is allowed to create views if ITSM is writeable
        if getattr(args, 'external_itsm_writeable', False):
            if not check_mysql_user_create_view_privilege(
                getattr(args, 'external_itsm_dbhost', None),
                getattr(args, 'external_itsm_dbport', None),
                getattr(args, 'external_itsm_dbuser', None),
                getattr(args, 'external_itsm_dbpassword', None),
                getattr(args, 'external_itsm_dbname', None)
            ):
                all_checks_passed = False
        # if ITSM is not writeable, check if itsm db has the requires views already created
        else:
            if not check_db_database_exists(
                getattr(args, 'external_itsm_dbhost', None),
                getattr(args, 'external_itsm_dbport', None),
                getattr(args, 'external_itsm_dbname', None),
                getattr(args, 'external_itsm_dbuser', None),
                getattr(args, 'external_itsm_dbpassword', None)
            ):
                all_checks_passed = False

        # Check API accessibility for external ITSM. If external_itsm_apiurl is not provided, construct it using external_itsm_baseurl with /apirest.php suffix
        if getattr(args, 'external_itsm_apiurl', None):
            external_itsm_apiurl = getattr(args, 'external_itsm_apiurl', None)
        else:
            external_itsm_apiurl = f"{getattr(args, 'external_itsm_baseurl', '')}/apirest.php"
        if get_tokens_from_db(
            getattr(args, 'external_itsm_dbhost', None),
            getattr(args, 'external_itsm_dbport', None),
            getattr(args, 'external_itsm_dbuser', None),
            getattr(args, 'external_itsm_dbpassword', None),
            getattr(args, 'external_itsm_dbname', None),
            getattr(args, 'external_itsm_apiuser', None)
        ) == (None, None):
            all_checks_passed = False
        api_apptoken, api_usertoken = get_tokens_from_db(
            getattr(args, 'external_itsm_dbhost', None),
            getattr(args, 'external_itsm_dbport', None),
            getattr(args, 'external_itsm_dbuser', None),
            getattr(args, 'external_itsm_dbpassword', None),
            getattr(args, 'external_itsm_dbname', None),
            getattr(args, 'external_itsm_apiuser', None)
        )
        if not check_api_connection(
            external_itsm_apiurl,
            getattr(args, 'external_itsm_apiuser', None),
            getattr(args, 'external_itsm_apipassword', None),
            api_usertoken,
            api_apptoken
        ):
            all_checks_passed = False

    # Check OIDC provider URL if provided
    if getattr(args, 'oidc_provider_url', None):
        if not check_url_reachable(getattr(args, 'oidc_provider_url', None)):
            all_checks_passed = False

    # Check LDAP connectivity if external LDAP is configured
    if getattr(args, 'external_ldap_url', None) or getattr(args, 'external_ldap_type', None):
        if not check_ldap_connectivity(
            getattr(args, 'external_ldap_url', None),
            getattr(args, 'external_ldap_type', None)
        ):
            all_checks_passed = False

    # Check external LDAP bind using ldapsearch if credentials are provided
    if getattr(args, 'external_ldap_bindname', None) and getattr(args, 'external_ldap_password', None):
        external_ldap_bindname = args.external_ldap_bindname
        if not check_ldap_bind(
            getattr(args, 'external_ldap_url', None),
            external_ldap_bindname,
            getattr(args, 'external_ldap_password', None),
            getattr(args, 'external_ldap_suffix', None)
        ):
            all_checks_passed = False
    if getattr(args, 'external_ldap_test_user', None) and getattr(args, 'external_ldap_test_password', None):
        external_ldap_test_user = args.external_ldap_test_user
        if not check_ldap_bind(
            getattr(args, 'external_ldap_url', None),
            external_ldap_test_user,
            getattr(args, 'external_ldap_test_password', None),
            getattr(args, 'external_ldap_suffix', None)
        ):
            all_checks_passed = False

    # Check ITSM forward inventory URL if provided
    if getattr(args, 'external_itsm_url_forward_inventory', None):
        if not check_url_reachable(getattr(args, 'external_itsm_url_forward_inventory', None)):
            all_checks_passed = False

    # Check SSL certificate file if provided
    if getattr(args, 'ssl_pem_chain_filename', None):
        out_file = INVENTORY_FILE
        ansible_hosts_dir = str(Path(out_file).parent.absolute())
        if not check_ssl_certificate_file(getattr(args, 'ssl_pem_chain_filename', None), ansible_hosts_dir):
            all_checks_passed = False
    if getattr(args, 'ssl_pem_key_filename', None):
        out_file = INVENTORY_FILE
        ansible_hosts_dir = str(Path(out_file).parent.absolute())
        if not check_ssl_private_key_file(getattr(args, 'ssl_pem_key_filename', None), ansible_hosts_dir):
            all_checks_passed = False

    # Check ssh connection from mmcmain to ars and from ars to mmcmain if ARS FQDN is provided
    index = 1
    while getattr(args, f'ars{index}_fqdn', None):
        ars_fqdn = getattr(args, f'ars{index}_fqdn')
        if not check_ssh_connection_to_host(host=ars_fqdn, user="medulla", key_file=Path("/root/.ssh/id_rsa")):
            all_checks_passed = False
        if not check_relay_sudo_privileges_via_ssh(host=ars_fqdn, user="medulla", key_file=Path("/root/.ssh/id_rsa")):
            all_checks_passed = False
        index += 1

    if not all_checks_passed:
        logger.error("Some pre-flight checks failed. Exiting with status 2.")
        sys.exit(2)
    logger.info("All pre-flight checks passed successfully.")

    # If this is a dry-run, we've finished validation; exit now
    if args.dry_run:
        if not all_checks_passed:
            logger.error("Dry-run: validation failed. Exiting with status 2.")
            sys.exit(2)
        logger.info("Dry-run: validation complete. Exiting without making changes.")
        return

    # Generate SSH keys
    try:
        generate_ssh_keys()
    except Exception:
        logger.error("SSH key generation failed; aborting")
        sys.exit(1)

    # Download and extract playbook
    download_and_extract_playbook(PLAYBOOK_BASEURL, dest_dir=Path('.'))

    # Run the playbooks
    try:
        # First check if we need to run dmz-specific playbooks and display appropriate messages
        if getattr(args, 'dmz_fqdn', None):
            logger.info("DMZ FQDN provided, will run DMZ-specific playbooks for mmcmain and ARS...")
            if args.run_mmcmain:
                logger.info("Will run playbook_medullamain_dmz.yml playbook for mmcmain in DMZ setup...")
                run_playbook(playbook="playbook_medullamain_dmz.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="mmcmain", ansible_dir="ansible", local_connection=True)
            if args.run_dmzars:
                logger.info("Will run playbook_medullarelay_dmz.yml playbook for DMZ ARS setup...")
                run_playbook(playbook="playbook_medullarelay_dmz.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="dmzars", ansible_dir="ansible", local_connection=False)
            if args.run_ars:
                logger.info("Will run playbook_medullarelay.yml playbook for ARS setup...")
                run_playbook(playbook="playbook_medullarelay.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="ars", ansible_dir="ansible", local_connection=False)
            if not args.run_mmcmain and not args.run_dmzars and not args.run_ars:
                logger.info("No specific playbook specified, will run all the playbooks by default...")
                run_playbook(playbook="playbook_medullamain_dmz.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="mmcmain", ansible_dir="ansible", local_connection=True)
                run_playbook(playbook="playbook_medullarelay_dmz.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="dmzars", ansible_dir="ansible", local_connection=False)
                if getattr(args, 'ars1_fqdn', None):
                    run_playbook(playbook="playbook_medullarelay.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="ars", ansible_dir="ansible", local_connection=False)
        else:
            logger.info("No DMZ FQDN provided, will run regular playbooks for mmcmain and ARS...")
            if args.run_mmcmain:
                logger.info("Will run playbook_medullamain.yml playbook for mmcmain...")
                run_playbook(playbook="playbook_medullamain.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="mmcmain", ansible_dir="ansible", local_connection=True)
            if args.run_ars:
                logger.info("Will run playbook_medullarelay.yml playbook for ARS...")
                run_playbook(playbook="playbook_medullarelay.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="ars", ansible_dir="ansible", local_connection=False)
            if not args.run_mmcmain and not args.run_ars:
                logger.info("No specific playbook specified, will run all the playbooks by default...")
                run_playbook(playbook="playbook_medullamain.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="mmcmain", ansible_dir="ansible", local_connection=True)
                if getattr(args, 'ars1_fqdn', None):
                    run_playbook(playbook="playbook_medullarelay.yml", vault_password_file=VAULT_PASSWORD_FILE, inventory=INVENTORY_FILE, limit="ars", ansible_dir="ansible", local_connection=False)

        # Display success message
        public_ip = getattr(args, 'public_ip', None)
        display_final_message(internal_fqdn, root_password, public_ip)
    except SystemExit as e:
        logger.error("Playbook execution failed: %s", e)
        sys.exit(1)

if __name__=="__main__":
    main()
