#!/usr/bin/env python3

# Check if we should be using the virtual environment's Python interpreter
import os
import sys
from pathlib import Path

def check_and_reexec_with_venv():
    """Check if we should re-execute with the virtual environment's Python"""
    # Skip if we're already running from venv or explicitly told not to use venv
    if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) or os.environ.get('R1SETUP_NO_VENV'):
        return False
    
    # Get the real user's home directory (handles sudo scenarios)
    if 'SUDO_USER' in os.environ:
        real_user = os.environ['SUDO_USER']
        if os.name == 'posix':
            import pwd
            real_home = Path(pwd.getpwnam(real_user).pw_dir)
        else:
            real_home = Path.home()
    else:
        real_home = Path.home()
    
    # Check if virtual environment Python exists
    venv_python = real_home / '.ratio1' / 'r1_setup' / '.r1_venv' / 'bin' / 'python3'
    
    if venv_python.exists() and str(venv_python) != sys.executable:
        # Re-execute with virtual environment Python
        import subprocess
        try:
            os.execv(str(venv_python), [str(venv_python)] + sys.argv)
        except OSError:
            # If exec fails, continue with current Python
            pass
    
    return False

# Try to re-execute with virtual environment Python
check_and_reexec_with_venv()

import subprocess
import yaml
import getpass
import re
import json
import urllib.request
import urllib.error
import shutil
import tempfile
import stat
import ssl
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime

# Import version from ver.py
try:
    # Try to import from the same directory as this script
    script_dir = Path(__file__).parent
    sys.path.insert(0, str(script_dir))
    from ver import __VER__ as CLI_VERSION
except ImportError:
    # Fallback to hardcoded version if ver.py is not available
    CLI_VERSION = "1.4.9"

# Debug configuration
DEBUG = False  # Set to True to enable debug output, or use --debug flag

# Version information
UPDATE_CHECK_URL = "https://raw.githubusercontent.com/Ratio1/r1setup/refs/heads/main/mnl_factory/scripts/ver.py"
DOWNLOAD_BASE_URL = "https://github.com/Ratio1/multi_node_launcher/releases/download"

# Whitelist of service template variables that can be overridden via Advanced → Customize Service
CUSTOMIZABLE_VARS = {
    'mnl_docker_image_url': {
        'description': 'Docker image URL',
        'default': '<from group_vars>',
        'example': 'ratio1/edge_node:devnet',
    },
    'mnl_docker_gpus': {
        'description': 'GPU flags passed to docker run',
        'default': '--gpus all',
        'example': '--gpus "device=0"',
    },
    'mnl_port_forward': {
        'description': 'Port forwarding flags',
        'default': '(empty or -p 1883:1883 in dev mode)',
        'example': '-p 1883:1883',
    },
    'mnl_commented_restart': {
        'description': 'Auto-reboot toggle (empty = enabled, "#" = disabled)',
        'default': '(empty — reboot enabled)',
        'example': '#',
    },
    'mnl_docker_container_name': {
        'description': 'Docker container name',
        'default': 'edge_node',
        'example': 'edge_node_canary',
    },
    'mnl_docker_volume_path': {
        'description': 'Host volume path for persistent data',
        'default': '/var/cache/edge_node/_local_cache',
        'example': '/data/edge_node/_local_cache',
    },
}

SSH_SCHEMA_VERSION = 2
SSH_AUTH_MODE_PASSWORD_ONLY = 'password_only'
SSH_AUTH_MODE_KEY_CONFIGURED_LEGACY = 'key_configured_legacy'
SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED = 'key_installed_unverified'
SSH_AUTH_MODE_KEY_VERIFIED = 'key_verified'
SSH_AUTH_MODE_PASSWORD_DISABLED = 'password_disabled'
SSH_AUTH_MODE_VERIFICATION_FAILED = 'verification_failed'
SERVICE_FILE_VERSION_FIELD = 'r1setup_service_file_version'
DEFAULT_SERVICE_FILE_VERSION = 'v0'
UNKNOWN_SERVICE_FILE_VERSION_MARKERS = {'', 'unknown', 'not found', 'n/a', 'none', 'null'}

SSH_KEY_MANAGEMENT_REQUIRED_PLAYBOOKS = (
    'playbooks/ssh_install_key.yml',
    'playbooks/ssh_add_extra_keys.yml',
    'playbooks/ssh_disable_password_auth.yml',
)

SSH_KEY_MANAGEMENT_REQUIRED_TOOLS = {
    'ssh': 'OpenSSH client',
    'ssh-keygen': 'OpenSSH key management',
    'openssl': 'OpenSSL',
}


def _get_gpu_hosts(inventory: dict) -> dict:
    """Extract gpu_nodes hosts from an inventory dict."""
    return inventory.get('all', {}).get('children', {}).get('gpu_nodes', {}).get('hosts', {})


def _parse_iso_to_datetime(value) -> Optional[datetime]:
    """Parse ISO datetime string to datetime object. Returns None on failure."""
    if not value:
        return None
    try:
        if isinstance(value, str):
            return datetime.fromisoformat(value.replace('Z', '+00:00'))
        return datetime.fromtimestamp(value)
    except (ValueError, TypeError, OSError):
        return None


def _parse_iso_datetime(value, fmt: str = '%Y-%m-%d %H:%M') -> Optional[str]:
    """Parse ISO datetime string, return formatted string or None."""
    dt = _parse_iso_to_datetime(value)
    return dt.strftime(fmt) if dt else None


class VersionManager:
    """Handles CLI and Ansible collection version checking and updates.

    Accesses from self.app (R1Setup):
        - print_colored(), print_debug()
        - os_type
        - ansible_config_root
    """

    def __init__(self, app):
        self.app = app
        self._version_cache = {
            'collection_version': None,
            'collection_check_time': None,
            'cache_duration': 300  # 5 minutes cache
        }

    def _create_ssl_context(self) -> ssl.SSLContext:
        """Create SSL context for secure connections, with certifi support"""
        try:
            # Try to create a default SSL context
            context = ssl.create_default_context()

            # Try to use certifi for certificate verification (should be installed in venv)
            try:
                import certifi
                context.load_verify_locations(certifi.where())
                # If we reach here, certifi is working properly
                return context
            except ImportError:
                # Certifi not available, try alternative approaches
                if self.app.os_type == "macos":
                    try:
                        # Try to load system root certificates on macOS
                        context.load_default_certs()
                        return context
                    except Exception:
                        # If all else fails, disable certificate verification for GitHub
                        # This is safe since we're only downloading from GitHub releases
                        self.app.print_colored("Warning: Could not load system certificates, disabling SSL verification for updates", 'yellow')
                        context.check_hostname = False
                        context.verify_mode = ssl.CERT_NONE
                else:
                    # On Linux, the default context should work
                    try:
                        context.load_default_certs()
                        return context
                    except Exception:
                        self.app.print_colored("Warning: Could not load system certificates, disabling SSL verification for updates", 'yellow')
                        context.check_hostname = False
                        context.verify_mode = ssl.CERT_NONE

            return context

        except Exception as e:
            # Fallback: create unverified context
            self.app.print_colored(f"SSL context creation failed ({e}), using unverified context for updates", 'yellow')
            context = ssl.create_default_context()
            context.check_hostname = False
            context.verify_mode = ssl.CERT_NONE
            return context

    def _check_latest_version(self) -> Tuple[Optional[str], Optional[str]]:
        """Check the latest version from GitHub repository"""
        try:
            req = urllib.request.Request(UPDATE_CHECK_URL)
            req.add_header('User-Agent', f'Ratio1-CLI/{CLI_VERSION}')

            # Create SSL context for secure connection
            ssl_context = self._create_ssl_context()

            with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response:
                content = response.read().decode('utf-8')

                # Parse version from ver.py content
                latest_version = None
                for line in content.split('\n'):
                    line = line.strip()
                    if line.startswith('__VER__') and '=' in line:
                        # Extract version from line like: __VER__ = '1.1.6'
                        version_part = line.split('=')[1].strip()
                        # Remove quotes and whitespace
                        latest_version = version_part.strip('\'"')
                        break

                if not latest_version:
                    self.app.print_colored("Could not parse version from repository", 'red')
                    return None, None

                # Construct download URLs for the latest version
                # First try release assets, fall back to raw content if needed
                download_urls = {
                    'r1setup': f"{DOWNLOAD_BASE_URL}/v{latest_version}/r1setup",
                    'ver.py': f"{DOWNLOAD_BASE_URL}/v{latest_version}/ver.py",
                    'update.py': f"{DOWNLOAD_BASE_URL}/v{latest_version}/update.py"
                }

                # Fallback URLs using raw GitHub content
                fallback_urls = {
                    'r1setup': "https://raw.githubusercontent.com/Ratio1/r1setup/refs/heads/main/mnl_factory/scripts/r1setup",
                    'ver.py': "https://raw.githubusercontent.com/Ratio1/r1setup/refs/heads/main/mnl_factory/scripts/ver.py",
                    'update.py': "https://raw.githubusercontent.com/Ratio1/r1setup/refs/heads/main/mnl_factory/scripts/update.py"
                }

                return latest_version, download_urls, fallback_urls

        except urllib.error.URLError as e:
            error_msg = str(e)
            if "CERTIFICATE_VERIFY_FAILED" in error_msg or "SSL" in error_msg:
                self.app.print_colored("SSL certificate verification failed.", 'red')
                self.app.print_colored("This is a common issue on macOS. Possible solutions:", 'yellow')
                self.app.print_colored("1. Install certificates: /Applications/Python\\ 3.x/Install\\ Certificates.command", 'white')
                self.app.print_colored("2. Install certifi: pip install certifi", 'white')
                self.app.print_colored("3. Update macOS and Python to latest versions", 'white')
            else:
                self.app.print_colored(f"Network error checking for updates: {e}", 'red')
            return None, None, None
        except Exception as e:
            self.app.print_colored(f"Error checking for updates: {e}", 'red')
            return None, None, None

    @staticmethod
    def _compare_versions(version1: str, version2: str) -> int:
        """Compare two version strings. Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal"""
        def normalize_version(v):
            # Handle pre-release versions by splitting on '-' and taking first part
            v = v.split('-')[0]
            # Split version into parts and convert to integers
            parts = []
            for part in v.split('.'):
                try:
                    parts.append(int(part))
                except ValueError:
                    # If conversion fails, treat as 0
                    parts.append(0)
            return parts

        v1_parts = normalize_version(version1)
        v2_parts = normalize_version(version2)

        # Pad shorter version with zeros
        max_len = max(len(v1_parts), len(v2_parts))
        v1_parts.extend([0] * (max_len - len(v1_parts)))
        v2_parts.extend([0] * (max_len - len(v2_parts)))

        for i in range(max_len):
            if v1_parts[i] > v2_parts[i]:
                return 1
            elif v1_parts[i] < v2_parts[i]:
                return -1

        return 0

    def _perform_update(self, latest_version: str, download_urls: Dict[str, str], fallback_urls: Dict[str, str] = None) -> bool:
        """Download and install the update"""
        try:
            self.app.print_colored("Downloading update files...", 'yellow')

            # Get current script path and directory
            current_script = Path(sys.argv[0]).resolve()
            script_dir = current_script.parent

            # Create temporary directory for downloads
            with tempfile.TemporaryDirectory() as temp_dir:
                temp_path = Path(temp_dir)
                downloaded_files = {}

                # Download r1setup, ver.py, and update.py
                for filename, url in download_urls.items():
                    self.app.print_colored(f"Downloading {filename}...", 'yellow')

                    temp_file = temp_path / filename

                    req = urllib.request.Request(url)
                    req.add_header('User-Agent', f'Ratio1-CLI/{CLI_VERSION}')

                    try:
                        # Create SSL context for secure connection
                        ssl_context = self._create_ssl_context()

                        with urllib.request.urlopen(req, timeout=30, context=ssl_context) as response:
                            with open(temp_file, 'wb') as f:
                                shutil.copyfileobj(response, f)

                        # Verify download
                        if not temp_file.exists() or temp_file.stat().st_size == 0:
                            self.app.print_colored(f"Download failed - {filename} is empty", 'red')
                            if filename == 'r1setup':
                                return False  # r1setup is critical
                            continue  # Other files are optional

                        downloaded_files[filename] = temp_file
                        self.app.print_colored(f"✅ Downloaded {filename}", 'green')

                    except urllib.error.URLError as e:
                        # Try fallback URL if available
                        if fallback_urls and filename in fallback_urls:
                            self.app.print_colored(f"Trying fallback URL for {filename}...", 'yellow')
                            try:
                                fallback_req = urllib.request.Request(fallback_urls[filename])
                                fallback_req.add_header('User-Agent', f'Ratio1-CLI/{CLI_VERSION}')

                                with urllib.request.urlopen(fallback_req, timeout=30, context=ssl_context) as response:
                                    with open(temp_file, 'wb') as f:
                                        shutil.copyfileobj(response, f)

                                # Verify fallback download
                                if not temp_file.exists() or temp_file.stat().st_size == 0:
                                    self.app.print_colored(f"Fallback download failed - {filename} is empty", 'red')
                                    if filename == 'r1setup':
                                        return False  # r1setup is critical
                                    continue  # Other files are optional

                                downloaded_files[filename] = temp_file
                                self.app.print_colored(f"✅ Downloaded {filename} (fallback)", 'green')
                                continue  # Success with fallback, move to next file

                            except urllib.error.URLError as fallback_error:
                                self.app.print_colored(f"Fallback also failed for {filename}: {fallback_error}", 'red')

                        if filename == 'r1setup':
                            # r1setup is critical, fail the update
                            self.app.print_colored(f"Failed to download critical file {filename}: {e}", 'red')
                            return False
                        else:
                            # Other files are optional, continue without them
                            self.app.print_colored(f"Warning: Could not download {filename}: {e}", 'yellow')
                            continue

                # Install new files
                self.app.print_colored("Installing new version...", 'yellow')

                # Install r1setup
                if 'r1setup' in downloaded_files:
                    current_stat = current_script.stat()
                    shutil.move(str(downloaded_files['r1setup']), str(current_script))
                    current_script.chmod(current_stat.st_mode)  # Restore executable permissions
                    self.app.print_colored("✅ Installed new r1setup", 'green')

                # Install ver.py
                if 'ver.py' in downloaded_files:
                    ver_py_path = script_dir / 'ver.py'
                    shutil.move(str(downloaded_files['ver.py']), str(ver_py_path))
                    ver_py_path.chmod(0o644)  # Set readable permissions
                    self.app.print_colored("✅ Installed new ver.py", 'green')

                # Install update.py
                if 'update.py' in downloaded_files:
                    update_py_path = script_dir / 'update.py'
                    shutil.move(str(downloaded_files['update.py']), str(update_py_path))
                    update_py_path.chmod(0o755)  # Set executable permissions
                    self.app.print_colored("✅ Installed new update.py", 'green')

                # Verify the new script works
                self.app.print_colored("Validating installation...", 'yellow')
                result = subprocess.run([str(current_script), '--version'],
                                        capture_output=True, text=True, timeout=5)
                if result.returncode != 0:
                    self.app.print_colored("Warning: New version validation failed, but files have been updated", 'yellow')

                self.app.print_colored("✅ Installation completed successfully", 'green')

                # Show information about the update script
                update_py_path = script_dir / 'update.py'
                if update_py_path.exists():
                    self.app.print_colored(f"\n💡 Update script available at: {update_py_path}", 'cyan')
                    self.app.print_colored("   You can run 'python update.py --help' for future update options", 'white')

                return True

        except Exception as e:
            self.app.print_colored(f"Update installation failed: {e}", 'red')
            return False

    def _update_ansible_collection(self) -> bool:
        """Update the Ansible collection to the latest version"""
        try:
            # Use the same collection path as the setup scripts
            ansible_dir = self.app.ansible_config_root
            collections_path = ansible_dir / 'collections'
            collection_dir = collections_path / 'ansible_collections' / 'ratio1' / 'multi_node_launcher'

            # Ensure collections directory exists
            collections_path.mkdir(parents=True, exist_ok=True)

            # Set environment variables for the subprocess
            env = os.environ.copy()
            env['ANSIBLE_CONFIG'] = str(self.app.ansible_config_root / 'ansible.cfg')
            env['ANSIBLE_COLLECTIONS_PATH'] = str(collections_path)
            env['ANSIBLE_HOME'] = str(self.app.ansible_config_root)

            # Get current version if installed
            current_version = None
            if collection_dir.exists():
                galaxy_yml = collection_dir / 'galaxy.yml'
                if galaxy_yml.exists():
                    try:
                        with open(galaxy_yml, 'r') as f:
                            galaxy_data = yaml.safe_load(f)
                            current_version = galaxy_data.get('version', 'unknown')
                            self.app.print_colored(f"  Current collection version: {current_version}", 'cyan')
                    except Exception as e:
                        self.app.print_debug(f"Could not read current version: {e}")

            # The issue with ansible-galaxy is that --upgrade doesn't actually force update
            # So we need to first uninstall and then reinstall to get the latest version

            # Method 1: Try to uninstall first, then reinstall (most reliable)
            if collection_dir.exists():
                self.app.print_colored("  Removing existing collection to force update...", 'yellow')
                try:
                    # Remove the entire collection directory
                    shutil.rmtree(collection_dir)
                    self.app.print_colored("  Existing collection removed successfully", 'green')
                except Exception as e:
                    self.app.print_colored(f"  Warning: Could not remove existing collection: {e}", 'yellow')
                    # Continue anyway - maybe the install will work

            # Now install the latest version
            self.app.print_colored("  Installing latest collection from Ansible Galaxy...", 'yellow')

            cmd = [
                'ansible-galaxy', 'collection', 'install',
                'ratio1.multi_node_launcher',
                '--collections-path', str(collections_path),
                '--force'
            ]

            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                timeout=300,  # 5 minute timeout
                env=env
            )

            if result.returncode == 0:
                self.app.print_colored("  Collection installation completed successfully", 'green')

                # Get the new version and verify the update
                new_version = None
                if collection_dir.exists():
                    galaxy_yml = collection_dir / 'galaxy.yml'
                    if galaxy_yml.exists():
                        try:
                            with open(galaxy_yml, 'r') as f:
                                galaxy_data = yaml.safe_load(f)
                                new_version = galaxy_data.get('version', 'unknown')
                                self.app.print_colored(f"  Updated to collection version: {new_version}", 'cyan')

                                # Show version change if we had a previous version
                                if current_version and current_version != 'unknown' and new_version != current_version:
                                    self.app.print_colored(f"  Version changed: {current_version} → {new_version}", 'green')
                                elif current_version and current_version != 'unknown' and new_version == current_version:
                                    self.app.print_colored(f"  Collection was already at the latest version", 'green')
                        except Exception as e:
                            self.app.print_debug(f"Could not read new version: {e}")

                # Verify the collection exists and is functional
                verification_success = False

                # Method 1: Check filesystem directly
                if collection_dir.exists():
                    verification_success = True
                    self.app.print_colored("  Collection verified via filesystem check", 'cyan')

                # Method 2: Check with ansible-galaxy list as backup verification
                if verification_success:
                    try:
                        verify_cmd = [
                            'ansible-galaxy', 'collection', 'list',
                            'ratio1.multi_node_launcher',
                            '--collections-path', str(collections_path)
                        ]

                        verify_result = subprocess.run(
                            verify_cmd,
                            capture_output=True,
                            text=True,
                            timeout=30,
                            env=env
                        )

                        if DEBUG:
                            self.app.print_debug(f"Verification command output: {verify_result.stdout}")
                            self.app.print_debug(f"Verification command stderr: {verify_result.stderr}")

                        if verify_result.returncode == 0 and 'ratio1' in verify_result.stdout.lower():
                            self.app.print_colored("  Collection verified via ansible-galaxy list", 'cyan')
                        else:
                            self.app.print_colored("  Warning: ansible-galaxy list verification failed, but filesystem check passed", 'yellow')
                    except Exception as e:
                        self.app.print_debug(f"Verification command failed: {e}")

                # Clear stale service overrides after template files may have changed
                self._clear_overrides_on_update()

                return verification_success
            else:
                # Log the error details
                error_msg = result.stderr.strip() if result.stderr else "Unknown error"
                self.app.print_colored(f"  ansible-galaxy command failed: {error_msg}", 'red')

                # Show command output for debugging
                if DEBUG and result.stdout:
                    self.app.print_debug(f"Command output: {result.stdout}")

                # Check if it's a common issue and provide helpful hints
                if "timeout" in error_msg.lower() or "connection" in error_msg.lower():
                    self.app.print_colored("  This might be a network connectivity issue. Try again later.", 'yellow')
                elif "permission" in error_msg.lower():
                    self.app.print_colored("  This might be a permissions issue. Check directory permissions.", 'yellow')
                elif "not found" in error_msg.lower():
                    self.app.print_colored("  The collection might not exist on Galaxy. Check the collection name.", 'yellow')

                return False

        except subprocess.TimeoutExpired:
            self.app.print_colored("  Collection update timed out. This might be due to slow network.", 'yellow')
            return False
        except FileNotFoundError:
            self.app.print_colored("  ansible-galaxy command not found. Please ensure Ansible is installed.", 'yellow')
            return False
        except Exception as e:
            self.app.print_colored(f"  Unexpected error updating collection: {e}", 'red')
            if DEBUG:
                import traceback
                self.app.print_debug(f"Full traceback: {traceback.format_exc()}")
            return False

    def _clear_overrides_on_update(self) -> None:
        """Remove stale service overrides after an Ansible collection update.

        Templates may have changed, so old overrides could be invalid.
        """
        try:
            overrides = self.app._get_service_overrides()
            if not overrides:
                return

            cleared_vars = list(overrides.keys())
            self.app._save_service_overrides({})

            self.app.print_colored("\n⚠️  Service overrides cleared after collection update:", 'yellow', bold=True)
            for var in cleared_vars:
                desc = CUSTOMIZABLE_VARS.get(var, {}).get('description', var)
                self.app.print_colored(f"   • {desc} ({var}) = {overrides[var]}", 'yellow')
            self.app.print_colored("  Re-apply via Advanced Menu → Customize Service if needed.", 'yellow')
        except Exception as e:
            self.app.print_debug(f"Could not clear overrides on update: {e}")

    def _get_current_collection_version(self, force_refresh: bool = False) -> Optional[str]:
        """Get current collection version using multiple smart methods with caching"""
        # Check cache first (unless force refresh)
        if not force_refresh and self._version_cache['collection_version'] is not None:
            import time
            cache_time = self._version_cache.get('collection_check_time', 0)
            current_time = time.time()

            # Use cached version if within cache duration
            if current_time - cache_time < self._version_cache['cache_duration']:
                if DEBUG:
                    self.app.print_debug(f"Using cached collection version: {self._version_cache['collection_version']}")
                return self._version_cache['collection_version']

        current_version = None

        # Set environment variables for consistency
        env = os.environ.copy()
        env['ANSIBLE_CONFIG'] = str(self.app.ansible_config_root / 'ansible.cfg')
        env['ANSIBLE_COLLECTIONS_PATH'] = str(self.app.ansible_config_root / 'collections')
        env['ANSIBLE_HOME'] = str(self.app.ansible_config_root)

        # Method 1: ansible-galaxy collection list with specific collection (most targeted)
        try:
            cmd = [
                'ansible-galaxy', 'collection', 'list',
                'ratio1.multi_node_launcher',
                '--collections-path', str(self.app.ansible_config_root / 'collections')
            ]

            result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, env=env)

            if result.returncode == 0:
                # Parse output more carefully
                for line in result.stdout.split('\n'):
                    line = line.strip()
                    if 'ratio1.multi_node_launcher' in line:
                        parts = line.split()
                        if len(parts) >= 2:
                            current_version = parts[1]
                            if DEBUG:
                                self.app.print_debug(f"Method 1 (targeted list): Found version {current_version}")
                            return current_version
            elif DEBUG:
                self.app.print_debug(f"Method 1 failed: {result.stderr}")
        except Exception as e:
            if DEBUG:
                self.app.print_debug(f"Method 1 error: {e}")

        # Method 2: ansible-galaxy collection list (broad search)
        if not current_version:
            try:
                cmd = [
                    'ansible-galaxy', 'collection', 'list',
                    '--collections-path', str(self.app.ansible_config_root / 'collections')
                ]

                result = subprocess.run(cmd, capture_output=True, text=True, timeout=20, env=env)

                if result.returncode == 0:
                    for line in result.stdout.split('\n'):
                        if 'ratio1.multi_node_launcher' in line:
                            parts = line.split()
                            if len(parts) >= 2:
                                current_version = parts[1]
                                if DEBUG:
                                    self.app.print_debug(f"Method 2 (broad list): Found version {current_version}")
                                break
                elif DEBUG:
                    self.app.print_debug(f"Method 2 failed: {result.stderr}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Method 2 error: {e}")

        # Method 3: Read galaxy.yml directly (filesystem approach)
        if not current_version:
            try:
                collection_dir = self.app.ansible_config_root / 'collections' / 'ansible_collections' / 'ratio1' / 'multi_node_launcher'
                galaxy_yml = collection_dir / 'galaxy.yml'

                if galaxy_yml.exists():
                    with open(galaxy_yml, 'r') as f:
                        galaxy_data = yaml.safe_load(f)
                        current_version = galaxy_data.get('version')
                        if current_version and DEBUG:
                            self.app.print_debug(f"Method 3 (galaxy.yml): Found version {current_version}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Method 3 error: {e}")

        # Method 4: Read MANIFEST.json (alternative metadata file)
        if not current_version:
            try:
                collection_dir = self.app.ansible_config_root / 'collections' / 'ansible_collections' / 'ratio1' / 'multi_node_launcher'
                manifest_json = collection_dir / 'MANIFEST.json'

                if manifest_json.exists():
                    with open(manifest_json, 'r') as f:
                        manifest_data = json.load(f)
                        # MANIFEST.json has different structure
                        collection_info = manifest_data.get('collection_info', {})
                        current_version = collection_info.get('version')
                        if current_version and DEBUG:
                            self.app.print_debug(f"Method 4 (MANIFEST.json): Found version {current_version}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Method 4 error: {e}")

        # Method 5: Test if collection modules are importable (Python approach)
        if not current_version:
            try:
                # Try to use Python to check if collection is available
                python_check = f"""
import sys
sys.path.insert(0, '{self.app.ansible_config_root / "collections"}')
try:
    from ansible_collections.ratio1.multi_node_launcher import __version__
    print(__version__)
except Exception:
    try:
        import os
        galaxy_path = '{self.app.ansible_config_root / "collections" / "ansible_collections" / "ratio1" / "multi_node_launcher" / "galaxy.yml"}'
        if os.path.exists(galaxy_path):
            import yaml
            with open(galaxy_path) as f:
                data = yaml.safe_load(f)
                print(data.get('version', ''))
    except Exception:
        pass
"""
                result = subprocess.run([sys.executable, '-c', python_check],
                                      capture_output=True, text=True, timeout=10)

                if result.returncode == 0 and result.stdout.strip():
                    current_version = result.stdout.strip()
                    if DEBUG:
                        self.app.print_debug(f"Method 5 (Python import): Found version {current_version}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Method 5 error: {e}")

        # Method 6: Use ansible-doc to check collection availability (functional test)
        if not current_version:
            try:
                # Try to get documentation for a known module in the collection
                cmd = [
                    'ansible-doc', '--list',
                    '--type', 'module',
                    'ratio1.multi_node_launcher'
                ]

                result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, env=env)

                if result.returncode == 0 and 'ratio1.multi_node_launcher' in result.stdout:
                    # Collection is available, try to get version from filesystem
                    collection_dir = self.app.ansible_config_root / 'collections' / 'ansible_collections' / 'ratio1' / 'multi_node_launcher'
                    if collection_dir.exists():
                        # If ansible-doc found it, collection exists, try galaxy.yml again
                        galaxy_yml = collection_dir / 'galaxy.yml'
                        if galaxy_yml.exists():
                            with open(galaxy_yml, 'r') as f:
                                galaxy_data = yaml.safe_load(f)
                                current_version = galaxy_data.get('version', 'detected')
                                if DEBUG:
                                    self.app.print_debug(f"Method 6 (ansible-doc + filesystem): Found version {current_version}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Method 6 error: {e}")

        # Clean up version string
        if current_version:
            current_version = str(current_version).strip('\'"')

        # Cache the result
        import time
        self._version_cache['collection_version'] = current_version
        self._version_cache['collection_check_time'] = time.time()

        if DEBUG:
            self.app.print_debug(f"Final current version result: {current_version}")

        return current_version

    def _check_ansible_collection_version(self) -> Tuple[Optional[str], Optional[str], bool]:
        """Check the current Ansible collection version and if updates are available"""
        try:
            # Use the smart method to get current version
            current_version = self._get_current_collection_version()
            latest_version = None

            # Check latest available version from Galaxy API
            try:
                # Use the correct Galaxy API v3 endpoint format
                api_url = "https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/ratio1/multi_node_launcher/"
                req = urllib.request.Request(api_url)
                req.add_header('User-Agent', f'Ratio1-CLI/{CLI_VERSION}')

                # Create SSL context for secure connection
                ssl_context = self._create_ssl_context()

                with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response:
                    data = json.loads(response.read().decode('utf-8'))
                    # The API response has highest_version.version structure
                    highest_version = data.get('highest_version', {})
                    latest_version = highest_version.get('version')
                    if DEBUG:
                        self.app.print_debug(f"Found latest version via Galaxy API v3: {latest_version}")
            except Exception as e:
                if DEBUG:
                    self.app.print_debug(f"Galaxy API v3 query failed: {e}")

                # Fallback: Try the older API format (keep as backup)
                try:
                    # Try the older API format as fallback
                    api_url = "https://galaxy.ansible.com/api/v1/collections/ratio1/multi_node_launcher/"
                    req = urllib.request.Request(api_url)
                    req.add_header('User-Agent', f'Ratio1-CLI/{CLI_VERSION}')

                    with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response:
                        data = json.loads(response.read().decode('utf-8'))
                        # Try different possible fields for v1 API
                        latest_version = (data.get('latest_version') or
                                        data.get('version') or
                                        data.get('current_version'))
                        if DEBUG:
                            self.app.print_debug(f"Found latest version via fallback API v1: {latest_version}")
                except Exception as e2:
                    if DEBUG:
                        self.app.print_debug(f"Fallback API v1 also failed: {e2}")
                    # If all else fails, we can't determine the latest version
                    latest_version = None

            # Compare versions if we have both
            update_available = False
            if current_version and latest_version:
                try:
                    # Remove any extra quotes or whitespace
                    current_version = current_version.strip('\'"')
                    latest_version = latest_version.strip('\'"')

                    # Simple version comparison
                    current_parts = [int(x) for x in current_version.split('.')]
                    latest_parts = [int(x) for x in latest_version.split('.')]

                    # Pad shorter version with zeros
                    max_len = max(len(current_parts), len(latest_parts))
                    current_parts.extend([0] * (max_len - len(current_parts)))
                    latest_parts.extend([0] * (max_len - len(latest_parts)))

                    # Compare version parts
                    for i in range(max_len):
                        if latest_parts[i] > current_parts[i]:
                            update_available = True
                            break
                        elif latest_parts[i] < current_parts[i]:
                            break  # Current is newer

                    if DEBUG:
                        self.app.print_debug(f"Version comparison: current={current_version}, latest={latest_version}, update_available={update_available}")

                except (ValueError, IndexError) as e:
                    if DEBUG:
                        self.app.print_debug(f"Version comparison failed: {e}")
                    # If we can't compare, assume update might be available
                    update_available = True
            elif current_version and not latest_version:
                # We have current but not latest - assume update might be available
                update_available = True
                if DEBUG:
                    self.app.print_debug(f"Could not determine latest version, assuming update available")
            elif not current_version and latest_version:
                # No current version means collection is not installed
                update_available = True
                if DEBUG:
                    self.app.print_debug(f"Collection not installed, update available")
            else:
                # Neither version available - can't determine
                update_available = False
                if DEBUG:
                    self.app.print_debug(f"Could not determine any versions")

            return current_version, latest_version, update_available

        except Exception as e:
            self.app.print_colored(f"Error checking Ansible collection version: {e}", 'yellow')
            if DEBUG:
                import traceback
                self.app.print_debug(f"Full traceback: {traceback.format_exc()}")
            return None, None, False

    def _auto_update_check(self) -> None:
        """Automatically check for and install updates on startup"""
        print("  Checking for updates...", end='\r')

        # Check CLI version
        latest_cli_version = None
        cli_update_available = False
        cli_download_urls = None
        cli_fallback_urls = None

        try:
            latest_cli_version, cli_download_urls, cli_fallback_urls = self._check_latest_version()
            if latest_cli_version:
                cli_update_available = self._compare_versions(CLI_VERSION, latest_cli_version) < 0
        except Exception as e:
            self.app.print_colored(f"Could not check for CLI updates: {e}", 'yellow')

        # Check Ansible collection version
        current_collection_version, latest_collection_version, collection_update_available = self._check_ansible_collection_version()

        # Show update status
        if DEBUG:
            self.app.print_debug(f"Auto-update check: current_collection_version={current_collection_version}, latest_collection_version={latest_collection_version}, collection_update_available={collection_update_available}")

        # Clear the "Checking for updates..." line
        print("                              ", end='\r')

        # Only show messages when updates are available
        if cli_update_available:
            self.app.print_colored(f"\U0001f195 CLI update available: {CLI_VERSION} \u2192 {latest_cli_version}", 'green')

        if collection_update_available:
            self.app.print_colored("\U0001f195 Ansible collection update available", 'green')

        # Perform auto-updates
        updates_performed = False

        if cli_update_available and latest_cli_version and cli_download_urls:
            self.app.print_colored(f"\n🚀 Auto-updating CLI to version {latest_cli_version}...", 'cyan', bold=True)
            success = self._perform_update(latest_cli_version, cli_download_urls, cli_fallback_urls)

            if success:
                self.app.print_colored(f"✅ Successfully updated CLI to version {latest_cli_version}!", 'green')
                self.app.print_colored("Restarting with the new version...", 'cyan')
                # Give user a moment to see the message
                import time
                time.sleep(2)
                os.execv(sys.executable, [sys.executable] + sys.argv)
            else:
                self.app.print_colored("❌ CLI auto-update failed. Continuing with current version.", 'red')

        if collection_update_available:
            self.app.print_colored("\n🚀 Auto-updating Ansible collection...", 'cyan', bold=True)
            if current_collection_version:
                self.app.print_colored(f"  Current version: {current_collection_version}", 'cyan')
            if latest_collection_version:
                self.app.print_colored(f"  Updating to: {latest_collection_version}", 'cyan')

            success = self._update_ansible_collection()

            if success:
                self.app.print_colored("✅ Ansible collection updated successfully!", 'green')
                updates_performed = True
            else:
                self.app.print_colored("❌ Ansible collection auto-update failed.", 'red')

        if updates_performed:
            self.app.print_colored("\n\u2705 Auto-update completed successfully!", 'green')
            self.app.wait_for_enter()


class SettingsManager:
    """Manages global user preferences stored in ~/.ratio1/r1_setup/settings.json.

    Separate from active_config.json (per-configuration metadata).
    Settings are global user preferences that persist across config switches.
    """

    DEFAULT_SETTINGS = {
        'show_node_status': False,
        'status_refresh_cooldown': 60,  # seconds
        'connection_timeout': 30,  # seconds (30-600); base timeout for node-facing operations
    }

    def __init__(self, app):
        self.app = app
        self.settings_file = app.r1_setup_dir / 'settings.json'
        self.settings = dict(self.DEFAULT_SETTINGS)
        self._last_status_refresh = None  # in-memory only; resets on CLI restart

    def load_settings(self):
        """Load settings from disk, merging with defaults for forward-compat."""
        if self.settings_file.exists():
            try:
                with open(self.settings_file, 'r') as f:
                    loaded = json.load(f)
                self.settings = {**self.DEFAULT_SETTINGS, **loaded}
                self.app.print_debug(f"Settings loaded from {self.settings_file}")
            except (json.JSONDecodeError, IOError) as e:
                self.app.print_debug(f"Warning: corrupt settings.json, using defaults: {e}")
                self.settings = dict(self.DEFAULT_SETTINGS)
        else:
            self.app.print_debug("No settings.json found, using defaults")

    def save_settings(self):
        """Persist current settings to disk."""
        try:
            self.settings_file.parent.mkdir(parents=True, exist_ok=True)
            with open(self.settings_file, 'w') as f:
                json.dump(self.settings, f, indent=2)
            self.app.print_debug(f"Settings saved to {self.settings_file}")
        except IOError as e:
            self.app.print_debug(f"Warning: could not save settings: {e}")

    def get(self, key):
        return self.settings.get(key, self.DEFAULT_SETTINGS.get(key))

    def set(self, key, value):
        self.settings[key] = value
        self.save_settings()

    def should_refresh_status(self):
        """Check if enough time has elapsed since last status probe."""
        if not self.get('show_node_status'):
            return False
        if self._last_status_refresh is None:
            return True
        import time
        elapsed = time.time() - self._last_status_refresh
        return elapsed >= self.get('status_refresh_cooldown')

    def mark_status_refreshed(self):
        """Record that a status probe just completed."""
        import time
        self._last_status_refresh = time.time()

    @property
    def connection_timeout(self) -> int:
        """Base timeout (seconds) for node-facing operations. Clamped to [30, 600]."""
        val = self.get('connection_timeout')
        try:
            val = int(val)
        except (TypeError, ValueError):
            val = self.DEFAULT_SETTINGS['connection_timeout']
        return max(30, min(600, val))

    @property
    def ssh_connect_timeout(self) -> int:
        """SSH ConnectTimeout derived from connection_timeout. Floor of 10s."""
        return max(10, self.connection_timeout // 3)

    def settings_menu(self):
        """Interactive toggle UI for user preferences."""
        while True:
            self.app.print_header("Settings")

            current_status = "ON" if self.get('show_node_status') else "OFF"
            cooldown = self.get('status_refresh_cooldown')

            self.app.print_section("Preferences")
            self.app.print_colored(
                f"  1) Show Node Status on Main Menu: [{current_status}]", 'white'
            )
            self.app.print_colored(
                f"     When ON, auto-checks live node statuses on main menu ({cooldown}s cooldown)",
                'white'
            )
            print()
            self.app.print_colored(
                f"  2) Connection Timeout: [{self.connection_timeout}s]", 'white'
            )
            self.app.print_colored(
                f"     Base timeout for SSH/playbook operations on remote nodes (30-600s)",
                'white'
            )
            print()
            self.app.print_colored("  0) Back", 'white')
            print()

            choice = self.app.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice == '1':
                new_value = not self.get('show_node_status')
                self.set('show_node_status', new_value)
                state = "ON" if new_value else "OFF"
                self.app.print_colored(f"Live node status display: {state}", 'green')
                self.app.wait_for_enter()
            elif choice == '2':
                val = self.app.get_input(
                    f"Enter connection timeout in seconds (30-600, current: {self.connection_timeout}s)",
                    str(self.connection_timeout)
                )
                try:
                    val = int(val)
                    if 30 <= val <= 600:
                        self.set('connection_timeout', val)
                        self.app.print_colored(f"Connection timeout set to {val}s (SSH connect timeout: {self.ssh_connect_timeout}s)", 'green')
                    else:
                        self.app.print_colored("Invalid value. Must be between 30 and 600 seconds.", 'red')
                except ValueError:
                    self.app.print_colored("Invalid input. Please enter a number.", 'red')
                self.app.wait_for_enter()
            else:
                self.app.print_colored("Invalid option. Valid choices are 0-2.", 'red')
                self.app.wait_for_enter()


class ConfigurationManager:
    """Handles all configuration persistence: load/save/switch/export/import.

    Accesses from self.app (R1Setup):
        - active_config_file, configs_dir, config_file, config_dir, vars_file (paths)
        - inventory (read/write)
        - real_user
        - print_colored(), print_debug(), print_section(), print_header()
        - get_input()
        - check_hosts_config()
        - _select_network_environment(), _get_valid_hostname(), _configure_single_node()
        - set_mnl_app_env()
    """

    def __init__(self, app):
        self.app = app
        self.active_config = {
            'config_name': None,
            'environment': None,
            'created_at': None,
            'nodes_count': 0,
            'last_deployed_date': None,
            'last_deployed_network': None,
            'deployment_status': 'never_deployed',
            'last_deleted_date': None,
            'last_deployment_type': None
        }

    def _load_active_config(self) -> None:
        """Load the active configuration settings"""
        self.app.print_debug(f"Loading active config from: {self.app.active_config_file}")

        self.active_config = {
            'config_name': None,
            'environment': None,
            'created_at': None,
            'nodes_count': 0,
            'last_deployed_date': None,
            'last_deployed_network': None,
            'deployment_status': 'never_deployed',
            'last_deleted_date': None,
            'last_deployment_type': None
        }

        if self.app.active_config_file.exists():
            try:
                with open(self.app.active_config_file) as f:
                    loaded_config = json.load(f)
                    self.app.print_debug(f"Loaded active config from file: {loaded_config}")
                    self.active_config.update(loaded_config)
                    self.app.print_debug(f"Final active config after update: {self.active_config}")

                # Ensure network environment is synchronized after loading active config
                env = self.active_config.get('environment')
                if env:
                    self.set_mnl_app_env(env)
                    self.app.print_debug(f"Network environment synchronized after loading active config: {env}")
            except Exception as e:
                self.app.print_colored(f"Warning: Could not load active config: {e}", 'yellow')
                self.app.print_debug(f"Exception loading active config: {e}")
        else:
            self.app.print_debug(f"Active config file does not exist: {self.app.active_config_file}")

    def _save_active_config(self) -> None:
        """Save the active configuration settings"""
        try:
            self.app.print_debug(f"Saving active config to: {self.app.active_config_file}")
            self.app.print_debug(f"Active config being saved: {self.active_config}")
            with open(self.app.active_config_file, 'w') as f:
                json.dump(self.active_config, f, indent=2)
            self.app.print_debug(f"Successfully saved active config")
        except Exception as e:
            self.app.print_colored(f"Error saving active config: {e}", 'red')
            self.app.print_debug(f"Exception saving active config: {e}")

    def _generate_config_name(self, nodes_count: int, custom_name: str = None) -> str:
        """Generate a configuration name with user input, timestamp and metadata"""
        if not custom_name:
            self.app.print_colored("\n📝 Configuration Naming", 'cyan', bold=True)
            self.app.print_colored("Give your configuration a meaningful name to identify it later.", 'yellow')
            self.app.print_colored("Examples: 'production-cluster', 'test-env', 'gpu-farm-1'", 'white')

            while True:
                custom_name = self.app.get_input("Enter configuration name (letters, numbers, -, _)", required=True)
                # Validate name (allow letters, numbers, hyphens, underscores)
                if re.match(r'^[a-zA-Z0-9_-]+$', custom_name):
                    break
                self.app.print_colored("Invalid name. Use only letters, numbers, hyphens (-), and underscores (_)", 'red')

        # Generate timestamp components
        now = datetime.now()
        date_str = now.strftime('%Y%m%d')  # YYYYMMDD
        time_str = now.strftime('%H%M')    # HHMM

        # Create the final config name: customname_YYYYMMDD_HHMM_Nnodes
        config_name = f"{custom_name}_{date_str}_{time_str}_{nodes_count}n"

        return config_name

    def _list_available_configs(self) -> List[Tuple[str, Dict]]:
        """List all available configurations with their metadata"""
        configs = []
        # Look for all .yml files in configs directory (not just hosts_config_*)
        for config_file in self.app.configs_dir.glob("*.yml"):
            try:
                with open(config_file) as f:
                    config_data = yaml.safe_load(f)

                # Check if this is a valid configuration file by looking for the expected structure
                if not config_data or 'all' not in config_data:
                    continue
                if 'children' not in config_data.get('all', {}) or 'gpu_nodes' not in config_data.get('all', {}).get('children', {}):
                    continue

                # Extract metadata from config
                hosts = _get_gpu_hosts(config_data)
                metadata_file = config_file.with_suffix('.json')

                metadata = {
                    'nodes_count': len(hosts),
                    'environment': 'unknown',
                    'created_at': config_file.stat().st_mtime
                }

                if metadata_file.exists():
                    try:
                        with open(metadata_file) as f:
                            metadata.update(json.load(f))
                    except Exception:
                        pass

                configs.append((config_file.name, metadata))
            except Exception:
                continue

        # Sort by creation time, newest first
        configs.sort(key=lambda x: x[1]['created_at'], reverse=True)
        return configs

    def _save_config_with_metadata(self, config_name: str, environment: str, nodes_count: int, update_symlink: bool = True) -> None:
        """Save configuration with metadata"""
        config_path = self.app.configs_dir / f"{config_name}.yml"
        metadata_path = self.app.configs_dir / f"{config_name}.json"

        # Save the inventory configuration
        inventory_to_save = dict(self.app.inventory)
        # Remove environment from inventory as it's stored in metadata
        if 'vars' in inventory_to_save['all']:
            inventory_to_save['all']['vars'].pop('mnl_app_env', None)
            if not inventory_to_save['all']['vars']:
                inventory_to_save['all'].pop('vars', None)

        with open(config_path, 'w') as f:
            yaml.safe_dump(inventory_to_save, f, default_flow_style=False)

        os.chmod(config_path, 0o600)

        # Load existing metadata if it exists, otherwise create new
        if metadata_path.exists():
            try:
                with open(metadata_path) as f:
                    metadata = json.load(f)

                # Update specific fields
                metadata['environment'] = environment
                metadata['nodes_count'] = nodes_count
            except (json.JSONDecodeError, IOError):
                # If file is corrupted or unreadable, create a new one
                metadata = {}
        else:
            metadata = {}

        # Fill in any missing metadata fields
        if 'created_at' not in metadata:
            metadata['created_at'] = datetime.now().isoformat()
        if 'config_name' not in metadata:
            metadata['config_name'] = config_name
        if 'description' not in metadata:
            metadata['description'] = f"Configuration with {nodes_count} node(s) for {environment} network"
        if 'last_deployed_date' not in metadata:
            metadata['last_deployed_date'] = None
        if 'last_deployed_network' not in metadata:
            metadata['last_deployed_network'] = None
        if 'deployment_status' not in metadata:
            metadata['deployment_status'] = 'never_deployed'
        if 'last_deleted_date' not in metadata:
            metadata['last_deleted_date'] = None
        if 'last_deployment_type' not in metadata:
            metadata['last_deployment_type'] = None

        # Always update these fields
        metadata['environment'] = environment
        metadata['nodes_count'] = nodes_count


        with open(metadata_path, 'w') as f:
            json.dump(metadata, f, indent=2)

        # Update active config
        self.active_config.update(metadata)
        self._save_active_config()

        # Create/update symlink to active configuration only if requested
        if update_symlink:
            self._update_hosts_symlink(config_path)

    def _update_hosts_symlink(self, config_path: Path) -> None:
        """Update the hosts.yml symlink to point to the active configuration"""
        # Ensure the config directory exists
        self.app.config_dir.mkdir(parents=True, exist_ok=True)

        # Remove existing hosts.yml if it exists
        if self.app.config_file.exists() or self.app.config_file.is_symlink():
            self.app.config_file.unlink()

        # Create symlink to the active configuration
        try:
            self.app.config_file.symlink_to(config_path)
            self.app.print_colored(f"Active configuration linked to: {config_path.name}", 'green')
        except Exception as e:
            self.app.print_colored(f"Error creating symlink: {e}", 'red')

    def _load_config_by_name(self, config_name: str) -> bool:
        """Load a specific configuration by name"""
        config_path = self.app.configs_dir / f"{config_name}.yml"
        metadata_path = self.app.configs_dir / f"{config_name}.json"

        if not config_path.exists():
            return False

        try:
            # Load inventory
            with open(config_path) as f:
                self.app.inventory = yaml.safe_load(f) or self.app.inventory

            # Load metadata
            if metadata_path.exists():
                with open(metadata_path) as f:
                    metadata = json.load(f)
                self.active_config.update(metadata)
                self._save_active_config()

            # Update symlink
            self._update_hosts_symlink(config_path)

            # Set environment variable
            env = self.active_config.get('environment')
            if env:
                self.set_mnl_app_env(env)

            return True
        except Exception as e:
            self.app.print_colored(f"Error loading configuration: {e}", 'red')
            return False

    def load_configuration(self) -> bool:
        """Load existing configuration"""
        if not self.app.config_file.exists():
            return False

        try:
            with open(self.app.config_file) as f:
                self.app.inventory = yaml.safe_load(f) or self.app.inventory

            # Ensure network environment is synchronized with active configuration
            env = self.active_config.get('environment')
            if env:
                self.set_mnl_app_env(env)
                self.app.print_debug(f"Network environment synchronized to: {env}")

            # Initialize status fields for existing nodes that don't have them
            hosts = _get_gpu_hosts(self.app.inventory)
            updated = False
            self.app.print_debug(f"Checking status field initialization for {len(hosts)} nodes")

            for host_name, host_config in hosts.items():
                needs_update = False
                timestamp = datetime.now().isoformat()

                if 'node_status' not in host_config:
                    host_config['node_status'] = 'unknown'
                    needs_update = True

                if 'last_status_update' not in host_config:
                    host_config['last_status_update'] = timestamp
                    needs_update = True

                normalized_service_version = self.get_host_service_file_version(host_config)
                if host_config.get(SERVICE_FILE_VERSION_FIELD) != normalized_service_version:
                    host_config[SERVICE_FILE_VERSION_FIELD] = normalized_service_version
                    needs_update = True

                # Remove legacy last_status_check field if it exists
                if 'last_status_check' in host_config:
                    del host_config['last_status_check']
                    needs_update = True

                if needs_update:
                    updated = True
                    self.app.print_debug(f"Initialized missing status fields for node: {host_name}")
                    self.app.print_debug(f"  status={host_config.get('node_status')}, update={host_config.get('last_status_update')}")
                else:
                    current_status = host_config.get('node_status', 'unknown')
                    current_update = host_config.get('last_status_update', 'none')
                    self.app.print_debug(f"Node {host_name} has all status fields: status={current_status}, update={current_update}")

            # Save configuration if we updated any status fields
            if updated:
                self.app.print_debug(f"Saving configuration after initializing {sum(1 for host_config in hosts.values() if 'node_status' not in host_config)} status fields")
                self._save_configuration()
            else:
                self.app.print_debug("All nodes already have status fields, no updates needed")

            return True
        except Exception as e:
            self.app.print_colored(f"Error loading configuration: {e}", 'red')
            return False

    def get_mnl_app_env(self) -> Optional[str]:
        """Get the current network environment setting"""
        if self.app.vars_file.exists():
            try:
                with open(self.app.vars_file) as f:
                    data = yaml.safe_load(f) or {}
                    return data.get('mnl_app_env')
            except Exception:
                pass
        return self.app.inventory.get('all', {}).get('vars', {}).get('mnl_app_env')

    def set_mnl_app_env(self, env_value: str) -> None:
        """Set the network environment"""
        data = {}
        if self.app.vars_file.exists():
            try:
                with open(self.app.vars_file) as f:
                    data = yaml.safe_load(f) or {}
            except Exception:
                data = {}

        data['mnl_app_env'] = env_value
        self.app.vars_file.parent.mkdir(parents=True, exist_ok=True)

        with open(self.app.vars_file, 'w') as f:
            yaml.safe_dump(data, f, default_flow_style=False)

        self.app.inventory['all'].setdefault('vars', {})['mnl_app_env'] = env_value

    def get_mnl_service_version(self) -> str:
        """Get the current service-template version from group_vars/mnl.yml."""
        service_vars_file = self.app.config_dir / 'group_vars' / 'mnl.yml'
        if service_vars_file.exists():
            try:
                with open(service_vars_file) as f:
                    data = yaml.safe_load(f) or {}
                version = str(data.get('mnl_service_version') or '').strip()
                if version:
                    return version
            except Exception as e:
                self.app.print_debug(f"Unable to read service version from {service_vars_file}: {e}")
        return DEFAULT_SERVICE_FILE_VERSION

    @staticmethod
    def get_host_service_file_version(host_config: Dict[str, Any]) -> str:
        """Return the stored per-node service version, defaulting missing/blank values to v0."""
        version = str(host_config.get(SERVICE_FILE_VERSION_FIELD) or '').strip()
        return version or DEFAULT_SERVICE_FILE_VERSION

    def record_service_file_version(self, host_names: List[str], service_version: Optional[str] = None) -> None:
        """Persist the applied service-template version for the selected hosts."""
        applied_version = str(service_version or self.get_mnl_service_version()).strip() or DEFAULT_SERVICE_FILE_VERSION
        self.record_service_file_versions({host_name: applied_version for host_name in host_names})

    def record_service_file_versions(self, host_versions: Dict[str, str]) -> None:
        """Persist discovered service-template versions for the given hosts."""
        hosts = _get_gpu_hosts(self.app.inventory)
        changed = False

        for host_name, version in host_versions.items():
            normalized_version = str(version or '').strip()
            if normalized_version.lower() in UNKNOWN_SERVICE_FILE_VERSION_MARKERS:
                continue

            host_config = hosts.get(host_name)
            if host_config is None:
                continue

            if host_config.get(SERVICE_FILE_VERSION_FIELD) != normalized_version:
                host_config[SERVICE_FILE_VERSION_FIELD] = normalized_version
                changed = True

        if changed:
            self._save_configuration()

    def _save_configuration(self) -> None:
        """Save configuration to file (legacy method - now uses new config management)"""
        # Get current environment and node count
        env = self.get_mnl_app_env() or 'mainnet'
        hosts = _get_gpu_hosts(self.app.inventory)
        nodes_count = len(hosts)

        # Generate config name if not set
        if not self.active_config.get('config_name'):
            config_name = self._generate_config_name(nodes_count)
            self._save_config_with_metadata(config_name, env, nodes_count, update_symlink=True)
        else:
            # Update existing config - no need to update symlink since we're not switching configs
            config_name = self.active_config['config_name']
            self._save_config_with_metadata(config_name, env, nodes_count, update_symlink=False)

    def manage_configurations_menu(self) -> None:
        """Show configuration management submenu"""
        while True:
            self.app.print_header("Configuration Management")

            # List available configurations
            configs = self._list_available_configs()
            active_config_name = self.active_config.get('config_name')

            if configs:
                self.app.print_section(f"Available Configurations ({len(configs)})")
                for i, (config_name, metadata) in enumerate(configs, 1):
                    # Remove .yml extension and format display
                    display_name = config_name.replace('.yml', '')
                    env = metadata.get('environment', 'unknown')
                    nodes = metadata.get('nodes_count', 0)
                    created = metadata.get('created_at', '')
                    description = metadata.get('description', '')

                    # Extract custom name from the config name (everything before the first timestamp)
                    # Format: customname_YYYYMMDD_HHMM_Nnodes_environment
                    custom_name = display_name
                    if '_' in display_name:
                        parts = display_name.split('_')
                        if len(parts) >= 2:
                            # Find where the timestamp starts (8 digits)
                            for idx, part in enumerate(parts):
                                if len(part) == 8 and part.isdigit():
                                    custom_name = '_'.join(parts[:idx])
                                    break

                    # Format creation date
                    created_str = _parse_iso_datetime(created) or "Unknown"

                    # Mark active configuration
                    active_marker = " ← ACTIVE" if display_name == active_config_name else ""

                    self.app.print_colored(f"  {i}. {custom_name}", 'cyan' if active_marker else 'white', bold=bool(active_marker))
                    info_line = f"     {env} | {nodes} node(s) | {created_str}{active_marker}"
                    if description:
                        info_line = f"     {description} | {created_str}{active_marker}"

                    # Add deployment status info
                    deployment_status = metadata.get('deployment_status', 'never_deployed')
                    last_deployed_date = metadata.get('last_deployed_date')
                    last_deployed_network = metadata.get('last_deployed_network')
                    last_deployment_type = metadata.get('last_deployment_type')
                    last_deleted_date = metadata.get('last_deleted_date')

                    if deployment_status == 'deployed' and last_deployed_date:
                        deployed_str = _parse_iso_datetime(last_deployed_date)
                        if deployed_str:
                            deployment_info = f"🚀 Last deployed: {deployed_str}"
                            if last_deployed_network:
                                deployment_info += f" ({last_deployed_network}"
                            if last_deployment_type:
                                deployment_info += f", {last_deployment_type})"
                            elif last_deployed_network:
                                deployment_info += ")"
                        else:
                            deployment_info = "🚀 Deployed"
                    elif deployment_status == 'deleted' and last_deleted_date:
                        deleted_str = _parse_iso_datetime(last_deleted_date)
                        if deleted_str:
                            deployment_info = f"🗑️ Last deleted: {deleted_str}"
                        else:
                            deployment_info = "🗑️ Deleted"
                    else:
                        deployment_info = "📋 Never deployed"

                    self.app.print_colored(info_line, 'green' if active_marker else 'white')
                    deployment_color = 'cyan' if deployment_status == 'deployed' else 'red' if deployment_status == 'deleted' else 'yellow'
                    self.app.print_colored(f"     {deployment_info}", deployment_color)
                print()

            self.app.print_colored("Manage Configurations Menu", 'cyan', bold=True)
            print()
            self.app.print_colored("  1) Create New Configuration       - Set up new configuration")
            if configs:
                self.app.print_colored("  2) Switch Configuration          - Activate different configuration")
                self.app.print_colored("  3) Delete Configuration          - Remove saved configuration")
                self.app.print_colored("  4) Rename Configuration          - Change configuration name")
                self.app.print_colored("")
                self.app.print_colored("  5) Backup Configuration          - Save configuration backup")
                self.app.print_colored("  6) Restore Configuration         - Restore from backup")
                self.app.print_colored("  7) Export Configuration          - Create portable config file")
                self.app.print_colored("  8) Quick Export Current Config   - Export active config")
            else:
                self.app.print_colored("  7) Export Configuration          - Create portable config file (no configs)")
                self.app.print_colored("  8) Quick Export Current          - Export active config")
            self.app.print_colored("  9) Import Configuration          - Import from portable file")
            print()
            self.app.print_colored("  0) Back to Main Menu")
            print()

            choice = self.app.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice == '1':
                self._create_new_configuration_with_management()
            elif choice == '2' and configs:
                self._switch_configuration(configs)
            elif choice == '3' and configs:
                self._delete_configuration(configs)
            elif choice == '4' and configs:
                self._rename_configuration(configs)
            elif choice == '5' and configs:
                self._backup_configuration(configs)
            elif choice == '6' and configs:
                self._restore_configuration()
            elif choice == '7':
                if configs:
                    self._export_configuration(configs)
                else:
                    self.app.print_colored("No configurations available to export.", 'yellow')
                    self.app.wait_for_enter()
            elif choice == '8':
                self._quick_export_current()
            elif choice == '9':
                self._import_configuration()
            else:
                self.app.print_colored("Invalid option. Valid choices are 0-9.", 'red')
                self.app.wait_for_enter()

    def _create_new_configuration_with_management(self) -> None:
        """Create a new configuration with proper management"""
        self.app.print_section("Create New Configuration")

        # First step: Get configuration name
        self.app.print_colored("\n📝 Configuration Naming", 'cyan', bold=True)
        self.app.print_colored("Give your configuration a meaningful name to identify it later.", 'yellow')
        self.app.print_colored("Examples: 'production-cluster', 'test-env', 'gpu-farm-1'", 'white')

        while True:
            custom_name = self.app.get_input("Enter configuration name (letters, numbers, -, _)", required=True)
            # Validate name (allow letters, numbers, hyphens, underscores)
            if re.match(r'^[a-zA-Z0-9_-]+$', custom_name):
                break
            self.app.print_colored("Invalid name. Use only letters, numbers, hyphens (-), and underscores (_)", 'red')

        # Select network environment
        env = self.app._select_network_environment()

        # Get number of nodes
        while True:
            try:
                num_nodes = int(self.app.get_input("How many nodes do you want to configure", "1"))
                if num_nodes <= 0:
                    self.app.print_colored("Please enter a positive number", 'red')
                    continue
                break
            except ValueError:
                self.app.print_colored("Please enter a valid number", 'red')

        # Generate configuration name with the custom name
        config_name = self._generate_config_name(num_nodes, custom_name)

        # Reset inventory for new configuration
        self.app.inventory = {
            'all': {
                'vars': {},
                'children': {
                    'gpu_nodes': {
                        'hosts': {}
                    }
                }
            }
        }

        # Configure each node
        hosts = self.app.inventory['all']['children']['gpu_nodes']['hosts']
        previous_config = None
        configured_nodes = []
        for i in range(num_nodes):
            # Show progress of previously configured nodes
            if configured_nodes:
                self.app.print_colored("")
                for idx, (n, ip, user) in enumerate(configured_nodes, 1):
                    self.app.print_colored(f"  \u2713 {idx}/{num_nodes}: {n} ({user}@{ip})", 'green')
            self.app.print_section(f"Configuring Node {i + 1} of {num_nodes}")
            name = self.app._get_valid_hostname(f"Enter name for node {i + 1}", f"gpu-node-{i + 1}")
            node_config = self.app._configure_single_node(previous_config=previous_config)
            hosts[name] = node_config
            previous_config = node_config
            configured_nodes.append((name, node_config.get('ansible_host', ''), node_config.get('ansible_user', '')))
            self.app.print_colored(f"\u2713 Node {i + 1}/{num_nodes} configured: {name} ({node_config.get('ansible_user', '')}@{node_config.get('ansible_host', '')})", 'green')

        # Save configuration with metadata
        self._save_config_with_metadata(config_name, env, num_nodes)
        self.app.print_colored(f"Configuration '{config_name}' created and activated!", 'green')

        # Offer to deploy immediately
        self.app.print_colored("\n\U0001f4a1 Your nodes are configured and ready for deployment!", 'cyan')
        deploy_choice = self.app.get_input("Would you like to deploy now? (y/n)", "y")
        if deploy_choice.lower() == 'y':
            self.app.wait_for_enter("Press Enter to continue to deployment...")
            self.app.deployment_menu()
            return
        self.app.wait_for_enter()

    def _switch_configuration(self, configs: List[Tuple[str, Dict]]) -> None:
        """Switch to a different configuration"""
        self.app.print_section("Switch Configuration")

        for i, (config_name, metadata) in enumerate(configs, 1):
            display_name = config_name.replace('.yml', '')
            env = metadata.get('environment', 'unknown')
            nodes = metadata.get('nodes_count', 0)
            self.app.print_colored(f"  {i}) {display_name} ({env}, {nodes} nodes)")

        while True:
            try:
                choice = int(self.app.get_input("Select configuration number", "1")) - 1
                if 0 <= choice < len(configs):
                    selected_config = configs[choice][0].replace('.yml', '')
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        if self._load_config_by_name(selected_config):
            self.app.print_colored(f"Switched to configuration: {selected_config}", 'green')
        else:
            self.app.print_colored("Failed to switch configuration", 'red')

        self.app.wait_for_enter()

    def _delete_configuration(self, configs: List[Tuple[str, Dict]]) -> None:
        """Delete a configuration"""
        self.app.print_section("Delete Configuration")

        for i, (config_name, metadata) in enumerate(configs, 1):
            display_name = config_name.replace('.yml', '')
            env = metadata.get('environment', 'unknown')
            nodes = metadata.get('nodes_count', 0)
            self.app.print_colored(f"  {i}) {display_name} ({env}, {nodes} nodes)")

        while True:
            try:
                choice = int(self.app.get_input("Select configuration number to delete", "1")) - 1
                if 0 <= choice < len(configs):
                    selected_config = configs[choice][0].replace('.yml', '')
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        if self.app.get_input(f"Delete configuration '{selected_config}'? (y/n)", "n").lower() == 'y':
            config_path = self.app.configs_dir / f"{selected_config}.yml"
            metadata_path = self.app.configs_dir / f"{selected_config}.json"

            try:
                # Delete files
                if config_path.exists():
                    config_path.unlink()
                if metadata_path.exists():
                    metadata_path.unlink()

                # If this was the active config, clear it
                if self.active_config.get('config_name') == selected_config:
                    self.active_config = {
                        'config_name': None,
                        'environment': None,
                        'created_at': None,
                        'nodes_count': 0,
                        'last_deployed_date': None,
                        'last_deployed_network': None,
                        'deployment_status': 'never_deployed',
                        'last_deleted_date': None,
                        'last_deployment_type': None
                    }
                    self._save_active_config()

                    # Remove symlink
                    if self.app.config_file.is_symlink():
                        self.app.config_file.unlink()

                self.app.print_colored(f"Configuration '{selected_config}' deleted successfully!", 'green')
            except Exception as e:
                self.app.print_colored(f"Error deleting configuration: {e}", 'red')

        self.app.wait_for_enter()

    def _rename_configuration(self, configs: List[Tuple[str, Dict]]) -> None:
        """Rename a configuration"""
        self.app.print_section("Rename Configuration")

        for i, (config_name, metadata) in enumerate(configs, 1):
            display_name = config_name.replace('.yml', '')
            env = metadata.get('environment', 'unknown')
            nodes = metadata.get('nodes_count', 0)
            self.app.print_colored(f"  {i}) {display_name} ({env}, {nodes} nodes)")

        while True:
            try:
                choice = int(self.app.get_input("Select configuration number to rename", "1")) - 1
                if 0 <= choice < len(configs):
                    old_config_name = configs[choice][0].replace('.yml', '')
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        new_name = self.app.get_input(f"Enter new name for '{old_config_name}'", required=True)

        # Validate new name
        if not re.match(r'^[a-zA-Z0-9_-]+$', new_name):
            self.app.print_colored("Invalid name. Use only letters, numbers, underscore, and hyphen.", 'red')
            self.app.wait_for_enter()
            return

        # Generate new config name with timestamp and metadata (like the original creation)
        # Extract environment and node count from existing metadata
        old_metadata_path = self.app.configs_dir / f"{old_config_name}.json"
        nodes_count = 1
        environment = 'mainnet'

        if old_metadata_path.exists():
            try:
                with open(old_metadata_path) as f:
                    metadata = json.load(f)
                nodes_count = metadata.get('nodes_count', 1)
                environment = metadata.get('environment', 'mainnet')
            except Exception:
                pass

        new_config_name = self._generate_config_name(nodes_count, new_name)

        # Check if new name already exists
        if (self.app.configs_dir / f"{new_config_name}.yml").exists():
            self.app.print_colored("A configuration with this name already exists!", 'red')
            self.app.wait_for_enter()
            return

        try:
            # Rename files
            old_config_path = self.app.configs_dir / f"{old_config_name}.yml"
            old_metadata_path = self.app.configs_dir / f"{old_config_name}.json"
            new_config_path = self.app.configs_dir / f"{new_config_name}.yml"
            new_metadata_path = self.app.configs_dir / f"{new_config_name}.json"

            old_config_path.rename(new_config_path)
            if old_metadata_path.exists():
                old_metadata_path.rename(new_metadata_path)

                # Update metadata
                with open(new_metadata_path) as f:
                    metadata = json.load(f)
                metadata['config_name'] = new_config_name

                with open(new_metadata_path, 'w') as f:
                    json.dump(metadata, f, indent=2)

            # Update active config if this was the active one
            if self.active_config.get('config_name') == old_config_name:
                self.active_config['config_name'] = new_config_name
                self._save_active_config()
                self._update_hosts_symlink(new_config_path)

            self.app.print_colored(f"Configuration renamed from '{old_config_name}' to '{new_config_name}'!", 'green')
        except Exception as e:
            self.app.print_colored(f"Error renaming configuration: {e}", 'red')

        self.app.wait_for_enter()

    def _backup_configuration(self, configs: List[Tuple[str, Dict]]) -> None:
        """Create a backup of a configuration"""
        self.app.print_section("Backup Configuration")

        for i, (config_name, metadata) in enumerate(configs, 1):
            display_name = config_name.replace('.yml', '')
            env = metadata.get('environment', 'unknown')
            nodes = metadata.get('nodes_count', 0)
            self.app.print_colored(f"  {i}) {display_name} ({env}, {nodes} nodes)")

        while True:
            try:
                choice = int(self.app.get_input("Select configuration number to backup", "1")) - 1
                if 0 <= choice < len(configs):
                    config_name = configs[choice][0].replace('.yml', '')
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        try:
            # Create backup directory if it doesn't exist
            backup_dir = self.app.configs_dir / 'backups'
            backup_dir.mkdir(parents=True, exist_ok=True)

            # Generate backup filename with timestamp
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            backup_name = f"{config_name}_backup_{timestamp}"

            # Copy configuration files
            config_path = self.app.configs_dir / f"{config_name}.yml"
            metadata_path = self.app.configs_dir / f"{config_name}.json"

            backup_config_path = backup_dir / f"{backup_name}.yml"
            backup_metadata_path = backup_dir / f"{backup_name}.json"

            if config_path.exists():
                shutil.copy2(config_path, backup_config_path)
            if metadata_path.exists():
                shutil.copy2(metadata_path, backup_metadata_path)

            self.app.print_colored(f"✅ Configuration '{config_name}' backed up as '{backup_name}'", 'green')
            self.app.print_colored(f"📁 Backup location: {backup_dir}", 'cyan')

        except Exception as e:
            self.app.print_colored(f"❌ Error creating backup: {e}", 'red')

        self.app.wait_for_enter()

    def _restore_configuration(self) -> None:
        """Restore a configuration from backup"""
        self.app.print_section("Restore Configuration")

        # Check if backup directory exists
        backup_dir = self.app.configs_dir / 'backups'
        if not backup_dir.exists():
            self.app.print_colored("No backups found. Backup directory doesn't exist.", 'yellow')
            self.app.wait_for_enter()
            return

        # Get list of backup files
        backup_files = list(backup_dir.glob('*_backup_*.yml'))
        if not backup_files:
            self.app.print_colored("No backup files found.", 'yellow')
            self.app.wait_for_enter()
            return

        # Parse backup information
        backups = []
        for backup_file in backup_files:
            backup_name = backup_file.stem
            # Extract original name and timestamp from backup name
            # Format: originalname_backup_YYYYMMDD_HHMMSS
            parts = backup_name.split('_backup_')
            if len(parts) == 2:
                original_name = parts[0]
                timestamp_str = parts[1]
                try:
                    timestamp = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')
                    backups.append((backup_name, original_name, timestamp))
                except ValueError:
                    continue

        if not backups:
            self.app.print_colored("No valid backup files found.", 'yellow')
            self.app.wait_for_enter()
            return

        # Sort backups by timestamp (newest first)
        backups.sort(key=lambda x: x[2], reverse=True)

        # Show available backups
        self.app.print_colored("Available Backups:")
        for i, (backup_name, original_name, timestamp) in enumerate(backups, 1):
            timestamp_str = timestamp.strftime('%Y-%m-%d %H:%M:%S')
            self.app.print_colored(f"  {i}) {original_name} (backed up on {timestamp_str})")

        while True:
            try:
                choice = int(self.app.get_input("Select backup number to restore", "1")) - 1
                if 0 <= choice < len(backups):
                    selected_backup = backups[choice]
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        backup_name, original_name, timestamp = selected_backup

        # Get new name for restored configuration
        self.app.print_colored(f"\nRestoring backup of '{original_name}' from {timestamp.strftime('%Y-%m-%d %H:%M:%S')}")
        new_name = self.app.get_input(f"Enter name for restored configuration (default: {original_name}_restored)",
                                  f"{original_name}_restored")

        # Validate new name
        if not re.match(r'^[a-zA-Z0-9_-]+$', new_name):
            self.app.print_colored("Invalid name. Use only letters, numbers, underscore, and hyphen.", 'red')
            self.app.wait_for_enter()
            return

        try:
            # Load backup metadata to get proper configuration structure
            backup_metadata_path = backup_dir / f"{backup_name}.json"
            nodes_count = 1
            environment = 'mainnet'

            if backup_metadata_path.exists():
                try:
                    with open(backup_metadata_path) as f:
                        metadata = json.load(f)
                    nodes_count = metadata.get('nodes_count', 1)
                    environment = metadata.get('environment', 'mainnet')
                except Exception:
                    pass

            # Generate proper configuration name
            restored_config_name = self._generate_config_name(nodes_count, new_name)

            # Check if restored name already exists
            if (self.app.configs_dir / f"{restored_config_name}.yml").exists():
                self.app.print_colored("A configuration with this name already exists!", 'red')
                self.app.wait_for_enter()
                return

            # Copy backup files to main config directory
            backup_config_path = backup_dir / f"{backup_name}.yml"
            backup_metadata_path = backup_dir / f"{backup_name}.json"

            restored_config_path = self.app.configs_dir / f"{restored_config_name}.yml"
            restored_metadata_path = self.app.configs_dir / f"{restored_config_name}.json"

            if backup_config_path.exists():
                shutil.copy2(backup_config_path, restored_config_path)
            if backup_metadata_path.exists():
                shutil.copy2(backup_metadata_path, restored_metadata_path)

                # Update metadata with new name
                with open(restored_metadata_path) as f:
                    metadata = json.load(f)
                metadata['config_name'] = restored_config_name
                metadata['restored_from'] = backup_name
                metadata['restored_at'] = datetime.now().isoformat()

                with open(restored_metadata_path, 'w') as f:
                    json.dump(metadata, f, indent=2)

            self.app.print_colored(f"✅ Configuration restored as '{restored_config_name}'", 'green')
            self.app.print_colored("💡 Use 'Switch Configuration' to activate the restored configuration.", 'cyan')

        except Exception as e:
            self.app.print_colored(f"❌ Error restoring configuration: {e}", 'red')

        self.app.wait_for_enter()

    def _export_configuration(self, configs: List[Tuple[str, Dict]]) -> None:
        """Export configuration to a portable file for transfer between machines"""
        self.app.print_section("Export Configuration")

        if not configs:
            self.app.print_colored("No configurations available to export.", 'yellow')
            self.app.wait_for_enter()
            return

        # Show available configurations
        self.app.print_colored("Available configurations to export:")
        for i, (config_name, metadata) in enumerate(configs, 1):
            display_name = config_name.replace('.yml', '')
            env = metadata.get('environment', 'unknown')
            nodes = metadata.get('nodes_count', 0)
            created = metadata.get('created_at', 'unknown')
            created = _parse_iso_datetime(created) or created
            self.app.print_colored(f"  {i}) {display_name} ({env}, {nodes} nodes, created: {created})")

        # Select configuration to export
        while True:
            try:
                choice = int(self.app.get_input("Select configuration number to export", "1")) - 1
                if 0 <= choice < len(configs):
                    config_name = configs[choice][0].replace('.yml', '')
                    config_metadata = configs[choice][1]
                    break
                self.app.print_colored("Invalid selection", 'red')
            except ValueError:
                self.app.print_colored("Please enter a number", 'red')

        # Get export destination
        default_filename = f"{config_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.r1config"
        filename = self.app.get_input(f"Export filename [{default_filename}]", default_filename)

        # Ensure .r1config extension
        if not filename.endswith('.r1config'):
            filename += '.r1config'

        export_path = Path(filename)
        if not export_path.is_absolute():
            export_path = Path.cwd() / filename

        try:
            # Load configuration files
            config_path = self.app.configs_dir / f"{config_name}.yml"
            metadata_path = self.app.configs_dir / f"{config_name}.json"

            # Read configuration data
            with open(config_path) as f:
                config_data = yaml.safe_load(f)

            with open(metadata_path) as f:
                metadata = json.load(f)

            # Read current environment variables
            env_data = {}
            if self.app.vars_file.exists():
                with open(self.app.vars_file) as f:
                    env_data = yaml.safe_load(f) or {}

            # Create unified export structure
            export_data = {
                'format_version': '1.0',
                'exported_at': datetime.now().isoformat(),
                'exported_by': self.app.real_user,
                'export_source': 'r1setup',
                'configuration': {
                    'name': config_name,
                    'metadata': metadata,
                    'inventory': config_data,
                    'environment_vars': env_data,
                    'ansible_vars': {}
                }
            }

            # Include relevant ansible variables if they exist
            ansible_vars_files = [
                self.app.config_dir / 'group_vars/all.yml',
                self.app.config_dir / 'group_vars/mnl.yml'
            ]

            for var_file in ansible_vars_files:
                if var_file.exists():
                    with open(var_file) as f:
                        var_data = yaml.safe_load(f) or {}
                        export_data['configuration']['ansible_vars'][var_file.name] = var_data

            # Write export file
            with open(export_path, 'w') as f:
                json.dump(export_data, f, indent=2, default=str)

            self.app.print_colored(f"✅ Configuration '{config_name}' exported successfully!", 'green')
            self.app.print_colored(f"📁 Export file: {export_path}", 'cyan')
            self.app.print_colored(f"📝 File size: {export_path.stat().st_size} bytes", 'white')

            # Show what was exported
            hosts_count = len(_get_gpu_hosts(config_data))
            self.app.print_colored(f"🔧 Exported: {hosts_count} nodes, {metadata.get('environment', 'unknown')} environment", 'cyan')

            self.app.print_colored("\n💡 To import on another machine:", 'yellow')
            self.app.print_colored(f"   1. Copy {filename} to the target machine", 'white')
            self.app.print_colored(f"   2. Run 'r1setup' and select Configuration Management → Import Configuration", 'white')

        except Exception as e:
            self.app.print_colored(f"❌ Error exporting configuration: {e}", 'red')

        self.app.wait_for_enter()

    def _import_configuration(self) -> None:
        """Import configuration from a portable file"""
        self.app.print_section("Import Configuration")

        self.app.print_colored("This will import a configuration from an .r1config file.", 'cyan')
        self.app.print_colored("The file should have been created using the Export Configuration option.", 'yellow')

        # Get import file path
        import_file = self.app.get_input("Enter path to .r1config file")
        import_path = Path(import_file)

        if not import_path.is_absolute():
            import_path = Path.cwd() / import_file

        if not import_path.exists():
            self.app.print_colored(f"❌ File not found: {import_path}", 'red')
            self.app.wait_for_enter()
            return

        try:
            # Read and validate import file
            with open(import_path) as f:
                import_data = json.load(f)

            # Validate format
            if not isinstance(import_data, dict) or 'configuration' not in import_data:
                self.app.print_colored("❌ Invalid configuration file format", 'red')
                self.app.wait_for_enter()
                return

            config_info = import_data['configuration']
            metadata = config_info.get('metadata', {})
            inventory = config_info.get('inventory', {})
            env_vars = config_info.get('environment_vars', {})
            ansible_vars = config_info.get('ansible_vars', {})

            # Show import details
            original_name = config_info.get('name', 'unknown')
            nodes_count = len(_get_gpu_hosts(inventory))
            environment = metadata.get('environment', 'unknown')
            exported_at = import_data.get('exported_at', 'unknown')
            exported_by = import_data.get('exported_by', 'unknown')

            self.app.print_colored(f"\n📋 Configuration Details:", 'cyan', bold=True)
            self.app.print_colored(f"   Original name: {original_name}")
            self.app.print_colored(f"   Environment: {environment}")
            self.app.print_colored(f"   Nodes: {nodes_count}")
            self.app.print_colored(f"   Exported: {exported_at}")
            self.app.print_colored(f"   Exported by: {exported_by}")

            # Show nodes that will be imported
            hosts = _get_gpu_hosts(inventory)
            if hosts:
                self.app.print_colored(f"\n🖥️  Nodes to import:", 'yellow', bold=True)
                for hostname, config in hosts.items():
                    ip = config.get('ansible_host', 'unknown')
                    user = config.get('ansible_user', 'unknown')
                    self.app.print_colored(f"   • {hostname} ({ip}) - user: {user}")

            # Confirm import
            if self.app.get_input(f"\nProceed with import? (y/n)", "y").lower() != 'y':
                self.app.print_colored("Import cancelled.", 'yellow')
                self.app.wait_for_enter()
                return

            # Get new configuration name
            suggested_name = self.app.get_input(f"Configuration name [{original_name}]", original_name)

            # Generate proper config filename
            final_config_name = self._generate_config_name(nodes_count, suggested_name)

            # Check if config already exists
            if (self.app.configs_dir / f"{final_config_name}.yml").exists():
                if self.app.get_input(f"Configuration '{final_config_name}' already exists. Overwrite? (y/n)", "n").lower() != 'y':
                    self.app.print_colored("Import cancelled.", 'yellow')
                    self.app.wait_for_enter()
                    return

            # Save imported configuration
            config_path = self.app.configs_dir / f"{final_config_name}.yml"
            metadata_path = self.app.configs_dir / f"{final_config_name}.json"

            # Update metadata
            updated_metadata = dict(metadata)
            updated_metadata['config_name'] = final_config_name
            updated_metadata['imported_at'] = datetime.now().isoformat()
            updated_metadata['imported_from'] = str(import_path)
            updated_metadata['original_name'] = original_name

            # Save configuration files
            with open(config_path, 'w') as f:
                yaml.safe_dump(inventory, f, default_flow_style=False)

            with open(metadata_path, 'w') as f:
                json.dump(updated_metadata, f, indent=2)

            os.chmod(config_path, 0o600)

            # Update environment variables if included
            if env_vars:
                current_env_vars = {}
                if self.app.vars_file.exists():
                    try:
                        with open(self.app.vars_file) as f:
                            current_env_vars = yaml.safe_load(f) or {}
                    except (yaml.YAMLError, IOError, OSError):
                        pass

                # Merge environment variables (imported ones take precedence)
                current_env_vars.update(env_vars)

                # Ensure directory exists
                self.app.vars_file.parent.mkdir(parents=True, exist_ok=True)

                with open(self.app.vars_file, 'w') as f:
                    yaml.safe_dump(current_env_vars, f, default_flow_style=False)

            # Apply ansible variables if included
            for var_filename, var_content in ansible_vars.items():
                var_file_path = self.app.config_dir / 'group_vars' / var_filename
                var_file_path.parent.mkdir(parents=True, exist_ok=True)

                # Only update if the imported file has content
                if var_content:
                    with open(var_file_path, 'w') as f:
                        yaml.safe_dump(var_content, f, default_flow_style=False)

            self.app.print_colored(f"✅ Configuration imported successfully as '{final_config_name}'!", 'green')
            self.app.print_colored(f"📁 Saved to: {config_path}", 'cyan')

            # Ask if user wants to activate the imported configuration
            if self.app.get_input("Activate this configuration now? (y/n)", "y").lower() == 'y':
                self.app.inventory = inventory
                self.active_config.update(updated_metadata)
                self._save_active_config()
                self._update_hosts_symlink(config_path)

                # Set environment
                env = updated_metadata.get('environment')
                if env:
                    self.set_mnl_app_env(env)

                self.app.print_colored("✅ Configuration activated!", 'green')
            else:
                self.app.print_colored("💡 Use 'Switch Configuration' to activate it later.", 'cyan')

        except json.JSONDecodeError:
            self.app.print_colored("❌ Invalid JSON in configuration file", 'red')
        except yaml.YAMLError as e:
            self.app.print_colored(f"❌ Invalid YAML in configuration: {e}", 'red')
        except Exception as e:
            self.app.print_colored(f"❌ Error importing configuration: {e}", 'red')

        self.app.wait_for_enter()

    def _quick_export_current(self) -> None:
        """Quick export of current active configuration"""
        if not self.app.check_hosts_config():
            self.app.print_colored("No active configuration to export.", 'yellow')
            self.app.wait_for_enter()
            return

        # Generate filename based on active config
        config_name = self.active_config.get('config_name', 'current_config')
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        filename = f"{config_name}_{timestamp}.r1config"

        try:
            # Create export in current directory
            export_path = Path.cwd() / filename

            # Read current configuration
            with open(self.app.config_file) as f:
                config_data = yaml.safe_load(f)

            # Read environment variables
            env_data = {}
            if self.app.vars_file.exists():
                with open(self.app.vars_file) as f:
                    env_data = yaml.safe_load(f) or {}

            # Create export structure
            export_data = {
                'format_version': '1.0',
                'exported_at': datetime.now().isoformat(),
                'exported_by': self.app.real_user,
                'export_source': 'r1setup_quick',
                'configuration': {
                    'name': config_name,
                    'metadata': dict(self.active_config),
                    'inventory': config_data,
                    'environment_vars': env_data,
                    'ansible_vars': {}
                }
            }

            # Include ansible variables
            ansible_vars_files = [
                self.app.config_dir / 'group_vars/all.yml',
                self.app.config_dir / 'group_vars/mnl.yml'
            ]

            for var_file in ansible_vars_files:
                if var_file.exists():
                    with open(var_file) as f:
                        var_data = yaml.safe_load(f) or {}
                        export_data['configuration']['ansible_vars'][var_file.name] = var_data

            # Write export file
            with open(export_path, 'w') as f:
                json.dump(export_data, f, indent=2, default=str)

            self.app.print_colored(f"✅ Current configuration exported to: {filename}", 'green')
            nodes = len(_get_gpu_hosts(config_data))
            env = self.active_config.get('environment', 'unknown')
            self.app.print_colored(f"🔧 Exported: {nodes} nodes, {env} environment", 'cyan')

        except Exception as e:
            self.app.print_colored(f"❌ Error exporting configuration: {e}", 'red')

        self.app.wait_for_enter()




class NodeStatusTracker:
    """Tracks and displays node deployment/service status.

    Accesses from self.app (R1Setup):
        - inventory (read)
        - config_dir, config_file (paths)
        - print_colored(), print_debug(), print_header(), print_section()
        - get_input()
        - run_command()
        - _save_configuration()
        - _format_timestamp_ago()
        - load_configuration()
        - check_hosts_config()
    """

    def __init__(self, app):
        self.app = app

    def _should_preserve_node_status(self, node_name: str) -> bool:
        """Check if a node's status should be preserved (e.g., deleted nodes)"""
        current_status = self._get_node_status_info(node_name)['status']
        return current_status == 'deleted'

    def _update_node_status(self, node_name: str, status: str) -> None:
        """Update the status of a specific node"""
        hosts = _get_gpu_hosts(self.app.inventory)
        if node_name in hosts:
            old_status = hosts[node_name].get('node_status', 'unknown')

            # Preserve certain statuses (e.g., deleted nodes)
            if self._should_preserve_node_status(node_name) and status not in ['deploying', 'error']:
                self.app.print_debug(f"Preserving {node_name} status: {old_status} (requested: {status})")
                return

            old_update = hosts[node_name].get('last_status_update', 'never')
            new_update = datetime.now().isoformat()

            hosts[node_name]['node_status'] = status
            hosts[node_name]['last_status_update'] = new_update
            self.app._save_configuration()

            self.app.print_debug(f"Updated {node_name} status: {old_status} → {status}")
            self.app.print_debug(f"  Previous update: {old_update}")
            self.app.print_debug(f"  New update: {new_update}")

    def _get_node_status_info(self, node_name: str) -> Dict[str, str]:
        """Get status information for a node"""
        hosts = _get_gpu_hosts(self.app.inventory)
        if node_name in hosts:
            status = hosts[node_name].get('node_status', 'unknown')
            last_update = hosts[node_name].get('last_status_update', '')
            self.app.print_debug(f"Retrieved {node_name} status: {status} (updated: {last_update})")
            return {'status': status, 'last_update': last_update}
        self.app.print_debug(f"Node {node_name} not found in hosts configuration")
        return {'status': 'unknown', 'last_update': ''}

    @staticmethod
    def _resolve_node_status(service_status, container_status):
        """Map service/container status to overall (status, result, overwrite) tuple.

        overwrite=True means the result should overwrite existing data
        (container info takes priority over service-only info).
        Returns None if no resolution is possible.
        """
        if container_status == 'RUNNING':
            return ('running', 'Container is running', True)
        elif container_status == 'NOT_RUNNING':
            if service_status == 'ACTIVE':
                return ('stopped', 'Service active but container not running', True)
            elif service_status == 'NOT_FOUND':
                return ('not_deployed', 'Service not found - not deployed', True)
            else:
                return ('stopped', 'Container not running', True)
        elif service_status == 'ACTIVE':
            return ('running', 'Service is active', False)
        elif service_status in ('INACTIVE', 'FAILED'):
            return ('stopped', 'Service is inactive/failed', False)
        elif service_status == 'NOT_FOUND':
            return ('not_deployed', 'Service not found - not deployed', False)
        return None

    def _apply_resolved_status(self, node_status_data, current_host, service_status, container_status):
        """Apply resolved status to node_status_data, respecting priority rules."""
        resolved = self._resolve_node_status(service_status, container_status)
        if resolved:
            status, result, overwrite = resolved
            if overwrite or current_host not in node_status_data:
                existing = node_status_data.get(current_host, {})
                node_status_data[current_host] = {'status': status, 'result': result}
                if existing.get('service_file_version'):
                    node_status_data[current_host]['service_file_version'] = existing['service_file_version']
                self.app.print_debug(f"Set {current_host} to {status} ({result})")

    @staticmethod
    def _parse_service_file_version(text):
        """Extract a service file version from status output text."""
        import re

        patterns = (
            r'Service File Version:\s*([^\s"\\]+)',
            r'R1SETUP_SERVICE_FILE_VERSION=([^\s"\\]+)',
        )

        for pattern in patterns:
            match = re.search(pattern, text)
            if not match:
                continue
            version = match.group(1).strip()
            if version.lower() not in UNKNOWN_SERVICE_FILE_VERSION_MARKERS:
                return version
        return None

    def _apply_service_file_version(self, node_status_data, current_host, service_file_version):
        """Attach discovered service version metadata to the current host result."""
        if not current_host or not service_file_version:
            return
        node_status_data.setdefault(current_host, {})
        node_status_data[current_host]['service_file_version'] = service_file_version
        self.app.print_debug(f"Set {current_host} service file version to {service_file_version}")

    def _run_status_playbook(self):
        """Run the ansible service status playbook.

        Returns (success, output) tuple.
        """
        playbook_path = self.app.config_dir / 'playbooks/service_status.yml'
        if not playbook_path.exists():
            self.app.print_colored(f"❌ Service status playbook not found: {playbook_path}", 'red')
            return False, ''

        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.app.config_file} {playbook_path}")

        return self.app.run_command(cmd, show_output=False, timeout=self.app.connection_timeout)

    def _parse_status_fields(self, text):
        """Parse service and container status from a text block.

        Returns (service_status, container_status) tuple.
        """
        service_status = None
        container_status = None

        if 'Service Status: ACTIVE' in text:
            service_status = 'ACTIVE'
        elif 'Service Status: INACTIVE' in text or 'Service Status: INACTIVE/FAILED' in text:
            service_status = 'INACTIVE'
        elif 'Service Status: FAILED' in text:
            service_status = 'FAILED'
        elif 'Service Status: NOT FOUND' in text:
            service_status = 'NOT_FOUND'

        if 'Container Status: RUNNING' in text:
            container_status = 'RUNNING'
        elif 'Container Status: NOT RUNNING' in text:
            container_status = 'NOT_RUNNING'

        return service_status, container_status

    def _parse_ansible_status_lines(self, lines):
        """Parse ansible playbook output lines for node status information.

        Tries multiple parsing strategies:
        1. Structured message output (msg-based)
        2. Direct status lines
        3. Aggressive fallback (broad search)

        Returns dict of {hostname: {status, result}}.
        """
        import re
        node_status_data = {}
        current_host = None
        task_pattern = re.compile(r'ok: \[([^\]]+)\] => \{')

        for i, line in enumerate(lines):
            line = line.strip()

            # Track current host from task results
            task_match = task_pattern.match(line)
            if task_match:
                current_host = task_match.group(1)
                self.app.print_debug(f"Found task result for host: {current_host}")
                continue

            # Handle unreachable nodes
            if 'unreachable:' in line and '[' in line and ']' in line:
                unreachable_match = re.search(r'unreachable: \[([^\]]+)\]', line)
                if unreachable_match:
                    hostname = unreachable_match.group(1)
                    node_status_data[hostname] = {
                        'status': 'unreachable',
                        'result': 'Node unreachable'
                    }
                    self.app.print_debug(f"Set {hostname} to unreachable")
                    continue

            # Handle fatal unreachable nodes
            if 'fatal:' in line and 'UNREACHABLE!' in line and '[' in line and ']' in line:
                fatal_match = re.search(r'fatal: \[([^\]]+)\]: UNREACHABLE!', line)
                if fatal_match:
                    hostname = fatal_match.group(1)
                    node_status_data[hostname] = {
                        'status': 'unreachable',
                        'result': 'Node unreachable - connection failed'
                    }
                    self.app.print_debug(f"Set {hostname} to unreachable (fatal)")
                    continue

            # Look for structured message output from the playbook
            if current_host and '"msg":' in line:
                self.app.print_debug(f"Processing message line for {current_host}: {line}")
                msg_match = re.search(r'"msg":\s*"([^"]*(?:\\.[^"]*)*)"', line)
                if msg_match:
                    msg_content = msg_match.group(1)
                    msg_content = msg_content.replace('\\n', '\n').replace('\\"', '"')
                    self.app.print_debug(f"Extracted message for {current_host}: {msg_content}")

                    # Handle truncated messages
                    if msg_content.endswith('\\n') or not msg_content.endswith('"'):
                        self.app.print_debug(f"Message appears truncated for {current_host}, looking for continuation")
                        for j in range(i + 1, min(i + 10, len(lines))):
                            next_line = lines[j].strip()
                            if next_line.startswith('"') and next_line.endswith('"'):
                                msg_content += next_line[1:-1].replace('\\n', '\n').replace('\\"', '"')
                                break
                            elif next_line.startswith('"'):
                                msg_content += next_line[1:].replace('\\n', '\n').replace('\\"', '"')
                            else:
                                break
                        self.app.print_debug(f"Complete message for {current_host}: {msg_content}")

                    service_file_version = self._parse_service_file_version(msg_content)
                    if service_file_version:
                        self._apply_service_file_version(node_status_data, current_host, service_file_version)

                    if 'Service Status:' in msg_content or 'Container Status:' in msg_content:
                        self.app.print_debug(f"Found status information in message for {current_host}")
                    else:
                        self.app.print_debug(f"No status information found in message for {current_host}")
                        continue

                    service_status, container_status = self._parse_status_fields(msg_content)
                    self.app.print_debug(f"Parsed {current_host}: service={service_status}, container={container_status}")
                    self._apply_resolved_status(node_status_data, current_host, service_status, container_status)

            # Direct status lines
            elif current_host and ('Service Status:' in line or 'Container Status:' in line):
                self.app.print_debug(f"Processing status line for {current_host}: {line}")
                service_status, container_status = self._parse_status_fields(line)
                self.app.print_debug(f"Parsed {current_host}: service={service_status}, container={container_status}")
                self._apply_resolved_status(node_status_data, current_host, service_status, container_status)

            elif current_host and ('Service File Version:' in line or 'R1SETUP_SERVICE_FILE_VERSION=' in line):
                service_file_version = self._parse_service_file_version(line)
                self._apply_service_file_version(node_status_data, current_host, service_file_version)

        self.app.print_debug(f"Initial parse node status data: {node_status_data}")

        # Aggressive fallback if no data found
        if not node_status_data:
            self.app.print_debug("No status data found, trying aggressive parsing approach")
            current_host = None
            for i, line in enumerate(lines):
                line = line.strip()
                host_match = re.search(r'ok:\s*\[([^\]]+)\]', line)
                if host_match:
                    current_host = host_match.group(1)
                    self.app.print_debug(f"Found host mention: {current_host}")

                if current_host and ('Service Status:' in line or 'Container Status:' in line):
                    self.app.print_debug(f"Found status line for {current_host}: {line}")
                    service_status, container_status = self._parse_status_fields(line)
                    self.app.print_debug(f"Aggressive parsing for {current_host}: service={service_status}, container={container_status}")
                    self._apply_resolved_status(node_status_data, current_host, service_status, container_status)
                elif current_host and ('Service File Version:' in line or 'R1SETUP_SERVICE_FILE_VERSION=' in line):
                    service_file_version = self._parse_service_file_version(line)
                    self._apply_service_file_version(node_status_data, current_host, service_file_version)

        self.app.print_debug(f"Final node status data: {node_status_data}")
        return node_status_data

    def _record_discovered_service_file_versions(self, node_status_data) -> None:
        """Persist service versions discovered during a status check."""
        host_versions = {
            host_name: data.get('service_file_version')
            for host_name, data in node_status_data.items()
            if data.get('service_file_version')
        }
        if host_versions:
            self.app.record_service_file_versions(host_versions)

    @staticmethod
    def _fill_missing_host_statuses(data, hosts):
        """Add 'unknown' status for any hosts not in the status data."""
        for hostname in hosts.keys():
            if hostname not in data:
                data[hostname] = {
                    'status': 'unknown',
                    'result': 'Unable to determine status'
                }
        return data

    def _get_real_time_node_status(self) -> Dict[str, Dict[str, str]]:
        """Get real-time status for all nodes by running the service status playbook"""
        success, output = self._run_status_playbook()
        if not output and not success:
            return {}

        if success:
            self.app.print_debug(f"Playbook output length: {len(output)} characters")
            self.app.print_debug("=== PLAYBOOK OUTPUT ===")
            self.app.print_debug(output)
            self.app.print_debug("=== END OUTPUT ===")
        else:
            self.app.print_debug(f"Status check failed with output length: {len(output) if output else 0}")
            if output and "timed out" in output.lower():
                self.app.print_debug("Status check timed out - some hosts may be offline")
            elif output:
                self.app.print_debug(f"Status check failed with error: {output}")
                self.app.print_debug("=== PARTIAL PLAYBOOK OUTPUT ===")
                self.app.print_debug(output)
                self.app.print_debug("=== END PARTIAL OUTPUT ===")

        lines = output.split('\n') if output else []
        data = self._parse_ansible_status_lines(lines)
        self._record_discovered_service_file_versions(data)
        hosts = _get_gpu_hosts(self.app.inventory)
        return self._fill_missing_host_statuses(data, hosts)

    def _get_status_display_info(self, status: str) -> Tuple[str, str, str]:
        """Get display information for a status (emoji, color, description)"""
        status_info = {
            'running': ('🟢', 'green', 'Running'),
            'stopped': ('🔴', 'red', 'Stopped'),
            'pending_restart': ('🟡', 'yellow', 'Pending Restart'),
            'unknown': ('❓', 'white', 'Unknown'),
            'deploying': ('🔄', 'cyan', 'Deploying'),
            'error': ('❌', 'red', 'Error'),
            'never_deployed': ('⚪', 'white', 'Never Deployed'),
            'deleted': ('🗑️', 'red', 'Deleted'),
            'unreachable': ('🔌', 'red', 'Unreachable'),
            'not_deployed': ('📦', 'yellow', 'Not Deployed')
        }
        return status_info.get(status, ('❓', 'white', 'Unknown'))

    def _display_node_status(self, node_name: str, compact: bool = False) -> None:
        """Display the status of a node"""
        status_info = self._get_node_status_info(node_name)
        status = status_info['status']
        emoji, color, description = self._get_status_display_info(status)

        if compact:
            self.app.print_colored(f"{emoji} {description}", color, end='')
        else:
            self.app.print_colored(f"Status: {emoji} {description}", color)

    def check_and_update_node_status(self) -> None:
        """Check actual service status and update node statuses accordingly"""
        if not self.app.check_hosts_config():
            self.app.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.app.wait_for_enter()
            return

        self.app.print_header("Check & Update Node Status")

        # Load configuration to show current statuses
        self.app.load_configuration()
        hosts = _get_gpu_hosts(self.app.inventory)

        # Show current statuses
        self.app.print_colored(f"🔍 Current Node Statuses:", 'cyan', bold=True)
        self.app.print_debug(f"Debug: Checking status for {len(hosts)} nodes")
        for name in hosts.keys():
            status_info = self._get_node_status_info(name)
            status = status_info['status']
            last_update = status_info['last_update']
            emoji, color, description = self._get_status_display_info(status)
            last_update_str = self.app._format_timestamp_ago(last_update)
            self.app.print_colored(f"   • {name}: {emoji} {description}", color, end='')
            self.app.print_colored(f" (Last updated: {last_update_str})", 'white')
            self.app.print_debug(f"  {name}: status={status}, last_update={last_update}")

        self.app.print_colored(f"\n📋 This will:", 'yellow', bold=True)
        self.app.print_colored("   • Check the actual service status on all nodes", 'yellow')
        self.app.print_colored("   • Update node statuses based on real service state", 'yellow')
        self.app.print_colored("   • Preserve 'Pending Restart' status (requires manual restart)", 'yellow')
        self.app.print_colored("   • Show differences between tracked and actual status", 'yellow')

        if self.app.get_input(f"\n🔍 Continue with status check on {len(hosts)} node(s)? (y/n)", "y").lower() != 'y':
            self.app.print_colored("Status check cancelled.", 'yellow')
            return

        playbook_path = self.app.config_dir / 'playbooks/service_status.yml'
        if not playbook_path.exists():
            self.app.print_colored(f"Service status playbook not found: {playbook_path}", 'red')
            self.app.wait_for_enter()
            return

        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.app.config_file} {playbook_path}")

        self.app.print_colored("\n🔍 Checking actual service status on all nodes...", 'cyan')
        self.app.print_debug(f"Running ansible command: {cmd}")
        success, output = self.app.run_command(cmd, show_output=False, timeout=self.app.connection_timeout)

        if success:
            self.app.print_colored("✅ Service status check completed!", 'green')
            self.app.print_debug(f"Ansible output length: {len(output)} characters")
            if DEBUG and output:
                self.app.print_debug("Raw ansible output (first 1000 chars):")
                self.app.print_debug(output[:1000] + ("..." if len(output) > 1000 else ""))

            parsed_status_data = self._parse_ansible_status_lines(output.split('\n'))
            self._record_discovered_service_file_versions(parsed_status_data)

            # Parse the output to determine actual service states
            actual_statuses = self._parse_service_status_output(output)

            # Update node statuses based on results
            status_changes = []
            self.app.print_debug(f"Processing status updates for {len(actual_statuses)} nodes")

            # Update last_update timestamp for all nodes that were checked
            for node_name in actual_statuses.keys():
                self._update_node_status(node_name, actual_statuses[node_name])

            for node_name, actual_status in actual_statuses.items():
                current_status_info = self._get_node_status_info(node_name)
                current_status = current_status_info['status']

                # Determine new status based on actual service state and current tracked status
                new_status = self._determine_updated_status(current_status, actual_status)

                self.app.print_debug(f"Node {node_name}: current={current_status}, actual={actual_status}, new={new_status}")

                if new_status != current_status:
                    self._update_node_status(node_name, new_status)
                    status_changes.append({
                        'node': node_name,
                        'old': current_status,
                        'new': new_status,
                        'actual': actual_status
                    })
                    self.app.print_debug(f"  Status change recorded for {node_name}")
                else:
                    self.app.print_debug(f"  No status change needed for {node_name}")

            # Show results
            self.app.print_colored(f"\n📊 Status Update Results:", 'cyan', bold=True)

            if status_changes:
                self.app.print_colored(f"Updated {len(status_changes)} node(s):", 'green')
                for change in status_changes:
                    old_emoji, old_color, old_desc = self._get_status_display_info(change['old'])
                    new_emoji, new_color, new_desc = self._get_status_display_info(change['new'])
                    self.app.print_colored(f"   • {change['node']}: ", 'white', end='')
                    self.app.print_colored(f"{old_emoji} {old_desc}", old_color, end='')
                    self.app.print_colored(" → ", 'white', end='')
                    self.app.print_colored(f"{new_emoji} {new_desc}", new_color)
                    if change['actual'] == 'service_missing':
                        self.app.print_colored(f"     (Service not found - node may not be deployed)", 'yellow')
                    elif change['actual'] == 'connection_failed':
                        self.app.print_colored(f"     (Connection failed - node may be unreachable)", 'yellow')
            else:
                self.app.print_colored("No status changes needed - all nodes are correctly tracked.", 'green')

            # Show final status summary
            self.app.print_colored(f"\n🖥️  Final Node Statuses:", 'cyan', bold=True)
            for name in hosts.keys():
                status_info = self._get_node_status_info(name)
                status = status_info['status']
                last_update = status_info['last_update']
                emoji, color, description = self._get_status_display_info(status)
                last_update_str = self.app._format_timestamp_ago(last_update)
                self.app.print_colored(f"   • {name}: {emoji} {description}", color, end='')
                self.app.print_colored(f" (Last updated: {last_update_str})", 'white')

        else:
            self.app.print_colored("❌ Service status check failed. Please check network connectivity and node access.", 'red')
            self.app.print_colored("Output for debugging:", 'yellow')
            if output:
                print(output[:500] + "..." if len(output) > 500 else output)

        self.app.wait_for_enter()

    def _parse_service_status_output(self, output: str) -> Dict[str, str]:
        """Parse ansible service status output to determine actual service states"""
        node_statuses = {}

        try:
            lines = output.split('\n')
            current_node = None
            self.app.print_debug(f"Parsing ansible output with {len(lines)} lines")

            for i, line in enumerate(lines):
                line = line.strip()

                # Look for node task execution patterns
                if 'TASK [' in line and 'Check service status' in line:
                    self.app.print_debug(f"Line {i}: Found task header: {line}")
                    continue

                # Look for node results
                if line.startswith(('ok: [', 'changed: [', 'fatal: [', 'unreachable: [')):
                    start = line.find('[') + 1
                    end = line.find(']')
                    if start > 0 and end > start:
                        current_node = line[start:end]
                        self.app.print_debug(f"Line {i}: Found node result for {current_node}: {line}")

                        if line.startswith('unreachable:'):
                            node_statuses[current_node] = 'connection_failed'
                            self.app.print_debug(f"  Set {current_node} to connection_failed")
                        elif line.startswith('fatal:'):
                            if 'could not be found' in line.lower() or 'not found' in line.lower():
                                node_statuses[current_node] = 'service_missing'
                                self.app.print_debug(f"  Set {current_node} to service_missing")
                            else:
                                node_statuses[current_node] = 'error'
                                self.app.print_debug(f"  Set {current_node} to error")
                        continue

                # Look for systemctl status output patterns
                # Avoid Ansible summary lines (e.g., "my-node : ok=10 changed=3 failed=0")
                if current_node and not (':' in line and 'ok=' in line and 'changed=' in line):
                    if any(keyword in line.lower() for keyword in ['active', 'inactive', 'failed', 'running']):
                        self.app.print_debug(f"Line {i}: Status line for {current_node}: {line}")
                        if 'active (running)' in line.lower():
                            node_statuses[current_node] = 'running'
                            self.app.print_debug(f"  Set {current_node} to running")
                        elif 'inactive' in line.lower() or 'stopped' in line.lower():
                            node_statuses[current_node] = 'stopped'
                            self.app.print_debug(f"  Set {current_node} to stopped")
                        elif 'failed' in line.lower() and 'failed=' not in line.lower():
                            node_statuses[current_node] = 'error'
                            self.app.print_debug(f"  Set {current_node} to error")

                # Look for service status in structured output
                elif current_node and 'service status:' in line.lower():
                    self.app.print_debug(f"Line {i}: Service status line for {current_node}: {line}")
                    if 'active' in line.lower():
                        node_statuses[current_node] = 'running'
                        self.app.print_debug(f"  Set {current_node} to running (from service status)")
                    elif 'inactive' in line.lower() or 'inactive/failed' in line.lower():
                        node_statuses[current_node] = 'stopped'
                        self.app.print_debug(f"  Set {current_node} to stopped (from service status)")

            # Broader fallback approach
            if not node_statuses:
                self.app.print_debug("No statuses parsed, trying broader approach")
                import re
                node_pattern = r'(?:ok|changed|fatal|unreachable): \[([^\]]+)\]'
                matches = re.findall(node_pattern, output)
                self.app.print_debug(f"Found node mentions: {matches}")
                for node_name in set(matches):
                    if node_name not in node_statuses:
                        node_statuses[node_name] = 'unknown'
                        self.app.print_debug(f"  Set {node_name} to unknown (fallback)")

        except Exception as e:
            self.app.print_debug(f"Error parsing service status output: {e}")

        self.app.print_debug(f"Final parsed node statuses: {node_statuses}")
        return node_statuses

    def _determine_updated_status(self, current_status: str, actual_status: str) -> str:
        """Determine the new status based on current tracked status and actual service state"""

        self.app.print_debug(f"Determining status: current='{current_status}', actual='{actual_status}'")

        # Special handling for pending_restart
        if current_status == 'pending_restart':
            if actual_status == 'stopped' or actual_status == 'service_missing':
                result = 'stopped' if actual_status == 'stopped' else 'never_deployed'
                self.app.print_debug(f"  Pending restart cleared: {current_status} → {result}")
                return result
            else:
                self.app.print_debug(f"  Pending restart preserved (service state: {actual_status})")
                return 'pending_restart'

        # For other statuses, update based on actual service state
        status_mapping = {
            'running': 'running',
            'stopped': 'stopped',
            'error': 'error',
            'service_missing': 'never_deployed',
            'connection_failed': 'error',
            'unknown': 'unknown'
        }

        result = status_mapping.get(actual_status, 'unknown')
        self.app.print_debug(f"  Status mapping: {actual_status} → {result}")
        return result





class DeploymentService:
    """Handles deployment, deletion, and deployment status operations.

    Accesses from self.app (R1Setup):
        - inventory (read)
        - config_dir, config_file, configs_dir (paths)
        - active_config (read/write)
        - print_colored(), print_debug(), print_header(), print_section()
        - get_input()
        - run_command()
        - check_hosts_config()
        - load_configuration()
        - _save_active_config(), _save_configuration()
        - get_mnl_app_env()
        - select_hosts()
        - _get_node_status_info(), _get_status_display_info()
        - _update_node_status(), _display_node_status()
        - _get_real_time_node_status()
        - _display_copy_friendly_addresses()
        - _load_active_config()
    """

    def __init__(self, app):
        self.app = app

    def deploy_full(self) -> None:
        """Deploy full setup (Docker + NVIDIA + GPU)"""
        self._deploy_setup("site.yml", "Full Deployment", "Docker + NVIDIA drivers + GPU setup")

    def deploy_docker_only(self) -> None:
        """Deploy Docker only"""
        self._deploy_setup("site.yml", "Docker-Only Deployment", "Docker without GPU setup", extra_vars="skip_gpu=true")

    def delete_edge_node(self) -> None:
        """Delete deployed Edge Node with host selection"""
        if not self.app.check_hosts_config():
            self.app.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.app.wait_for_enter()
            return

        self.app.print_header("Delete Edge Node Deployment")

        # Load configuration to show target hosts
        self.app.load_configuration()
        all_hosts = _get_gpu_hosts(self.app.inventory)
        env = self.app.get_mnl_app_env()

        # Interactive host selection for deletion
        selected_host_names = self.app.select_hosts(all_hosts, "Edge Node deletion", preselect_mode='all')

        if not selected_host_names:
            self.app.print_colored("Deletion cancelled - no hosts selected.", 'yellow')
            self.app.wait_for_enter()
            return

        # Filter hosts to only include selected ones
        selected_hosts = {name: config for name, config in all_hosts.items() if name in selected_host_names}

        # Show deletion details
        self.app.print_colored(f"🗑️  Deletion Details:", 'cyan', bold=True)
        self.app.print_colored(f"   • Network: {env if env else 'Not set'}", 'white')
        self.app.print_colored(f"   • Selected Nodes: {len(selected_hosts)}/{len(all_hosts)}", 'white')

        self.app.print_colored(f"\n🖥️  Edge Nodes will be deleted from these selected machines:", 'cyan', bold=True)
        for name in selected_host_names:
            config = all_hosts[name]
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            # Show current status
            status_info = self.app._get_node_status_info(name)
            status_emoji, _, status_desc = self.app._get_status_display_info(status_info['status'])
            self.app.print_colored(f"   • {name}: {user}@{ip} [{status_emoji} {status_desc}]", 'white')

        self.app.print_colored("\n⚠️  WARNING: This will completely remove the Edge Node deployment including:", 'red', bold=True)
        self.app.print_colored("   • Systemd service and Docker containers", 'yellow')
        self.app.print_colored("   • Docker images and application data", 'yellow')
        self.app.print_colored("   • Created command scripts", 'yellow')
        self.app.print_colored("   • Docker daemon configuration", 'yellow')

        if self.app.get_input(f"\n⚠️  Are you sure you want to delete Edge Node deployment from {len(selected_hosts)} selected machine(s) (y/n)", "n").lower() != 'y':
            self.app.print_colored("Deletion cancelled.", 'yellow')
            return

        # Final confirmation
        if self.app.get_input(f"⚠️  Type 'DELETE' to confirm deletion from {len(selected_hosts)} selected node(s)", "").upper() != 'DELETE':
            self.app.print_colored("Deletion cancelled - confirmation not received.", 'yellow')
            return

        playbook_path = self.app.config_dir / 'playbooks/delete_edge_node.yml'
        if not playbook_path.exists():
            self.app.print_colored(f"Delete Edge Node playbook not found: {playbook_path}", 'red')
            self.app.wait_for_enter()
            return

        # Create host limit for selected hosts
        host_limit = ','.join(selected_host_names)
        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.app.config_file} --limit {host_limit} {playbook_path}")

        # Update selected node statuses to deploying (deletion is a deployment operation)
        for host_name in selected_host_names:
            self.app._update_node_status(host_name, 'deploying')

        self.app.print_colored("\nStarting Edge Node deletion on selected nodes...", 'cyan')
        success, _ = self.app.run_command(cmd, show_output=True)

        if success:
            self.app.print_colored(f"\n✅ Deletion succeeded on {len(selected_host_names)} node(s)", 'green')
            # Update deployment metadata after successful deletion
            self._update_deletion_metadata()

            # Update selected node statuses to deleted after successful deletion
            for host_name in selected_host_names:
                self.app._update_node_status(host_name, 'deleted')

            # Show updated statuses
            self.app.print_colored(f"\n📊 Node Deletion Status:", 'cyan', bold=True)
            for host_name in selected_host_names:
                self.app.print_colored(f"   • {host_name}: ", 'white', end='')
                self.app._display_node_status(host_name, compact=True)
                print()  # New line after each status

            self.app.print_colored(f"\n💡 Note: Deleted nodes will maintain their 'deleted' status.", 'cyan')
            self.app.print_colored("   This status will not change automatically to preserve the deletion history.", 'white')
        else:
            self.app.print_colored(f"\n❌ Deletion failed. Check output above for details.", 'red')
            # Update selected node statuses to error after failed deletion
            for host_name in selected_host_names:
                self.app._update_node_status(host_name, 'error')
            self.app.print_colored(f"\n📊 Selected node statuses updated to Error due to deletion failure.", 'yellow')

        self.app.wait_for_enter()

    def _deploy_setup(self, playbook: str, title: str, description: str, extra_vars: str = "") -> None:
        """Common deployment logic with host selection"""
        if not self.app.check_hosts_config():
            self.app.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.app.wait_for_enter()
            return

        self.app.print_header(title)

        # Load configuration to show deployment details
        self.app.load_configuration()
        all_hosts = _get_gpu_hosts(self.app.inventory)
        env = self.app.get_mnl_app_env()

        if not env:
            self.app.print_colored("\n⚠️  WARNING: Network environment is not set!", 'red', bold=True)
            self.app.print_colored("   Please set the network environment before deploying.", 'red')
            self.app.wait_for_enter()
            return

        # Interactive host selection with preselection of never-deployed nodes
        selected_host_names = self.app.select_hosts(all_hosts, title.lower(), preselect_mode='undeployed')

        if not selected_host_names:
            self.app.print_colored("Deployment cancelled - no hosts selected.", 'yellow')
            self.app.wait_for_enter()
            return

        # Filter hosts to only include selected ones
        selected_hosts = {name: config for name, config in all_hosts.items() if name in selected_host_names}

        # Show deployment details
        self.app.print_colored(f"📋 Deployment Details:", 'cyan', bold=True)
        self.app.print_colored(f"   • Action: {description}", 'white')
        self.app.print_colored(f"   • Network: {env}", 'green')
        self.app.print_colored(f"   • Selected Nodes: {len(selected_hosts)}/{len(all_hosts)}", 'white')

        self.app.print_colored(f"\n🖥️  Selected Target Machines:", 'cyan', bold=True)
        for name in selected_host_names:
            config = all_hosts[name]
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            # Show status
            status_info = self.app._get_node_status_info(name)
            status_emoji, _, status_desc = self.app._get_status_display_info(status_info['status'])
            self.app.print_colored(f"   • {name}: {user}@{ip} [{status_emoji} {status_desc}]", 'white')

        self.app.print_colored(f"\n⚠️  This will:", 'yellow', bold=True)
        if "Docker + NVIDIA" in description:
            self.app.print_colored("   • Install Docker and Docker Compose", 'yellow')
            self.app.print_colored("   • Install NVIDIA drivers and CUDA toolkit", 'yellow')
            self.app.print_colored("   • Configure GPU access for containers", 'yellow')
            self.app.print_colored("   • Deploy and start the Edge Node", 'yellow')
        elif "Docker only" in description:
            self.app.print_colored("   • Install Docker and Docker Compose", 'yellow')
            self.app.print_colored("   • Deploy and start the Edge Node (CPU mode)", 'yellow')
        else:
            self.app.print_colored(f"   • {description}", 'yellow')

        # Check for network change warning for selected hosts
        deployment_status = self.app.active_config.get('deployment_status', 'never_deployed')
        last_deployed_network = self.app.active_config.get('last_deployed_network')

        if deployment_status == 'deployed' and last_deployed_network and last_deployed_network != env:
            self.app.print_colored(f"\n🚨 NETWORK CHANGE DETECTED!", 'red', bold=True)
            self.app.print_colored(f"   This configuration was previously deployed on: {last_deployed_network}", 'yellow')
            self.app.print_colored(f"   You are now deploying to: {env}", 'cyan')
            self.app.print_colored(f"\n⚠️  Important Information:", 'yellow', bold=True)
            self.app.print_colored(f"   • The Edge Node will run on the NEW network: {env}", 'white')
            self.app.print_colored(f"   • Selected node addresses will be used:", 'white')
            for name in selected_host_names:
                config = all_hosts[name]
                ip = config.get('ansible_host', 'Unknown')
                self.app.print_colored(f"     - {name}: {ip}", 'white')
            self.app.print_colored(f"   • Node configuration and credentials remain the same", 'white')
            self.app.print_colored(f"   • Only the blockchain network environment changes", 'white')

            self.app.print_colored(f"\n❓ Type 'yes' to confirm deployment to {env} (different from previous {last_deployed_network})", 'yellow')
            if self.app.get_input("Confirm network change", "").lower() != 'yes':
                self.app.print_colored("Deployment cancelled.", 'yellow')
                return

        if self.app.get_input(f"\n🚀 Continue with deployment to {len(selected_hosts)} selected node(s) on {env}? (y/n)", "y").lower() != 'y':
            self.app.print_colored("Deployment cancelled.", 'yellow')
            return

        playbook_path = self.app.config_dir / f'playbooks/{playbook}'
        if not playbook_path.exists():
            self.app.print_colored(f"Playbook not found: {playbook_path}", 'red')
            self.app.wait_for_enter()
            return

        # Create host limit for selected hosts
        host_limit = ','.join(selected_host_names)
        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.app.config_file} --limit {host_limit} {playbook_path}")

        if extra_vars:
            cmd += f' --extra-vars "{extra_vars}"'

        # Update selected node statuses to deploying
        for host_name in selected_host_names:
            self.app._update_node_status(host_name, 'deploying')

        self.app.print_colored(f"\nStarting {title.lower()} on selected nodes...", 'cyan')
        success, _ = self.app.run_command(cmd, show_output=True)

        if success:
            self.app.print_colored(f"\n✅ Deployment succeeded on {len(selected_host_names)} node(s)", 'green')
            # Update deployment metadata after successful deployment
            deployment_type = "full" if "NVIDIA" in description else "docker_only"
            self._update_deployment_metadata(deployment_type)
            self.app.record_service_file_version(selected_host_names)

            # Update selected node statuses to running after successful deployment
            for host_name in selected_host_names:
                self.app._update_node_status(host_name, 'running')

            # Show updated statuses
            self.app.print_colored(f"\n📊 Node Deployment Status:", 'cyan', bold=True)
            for host_name in selected_host_names:
                self.app.print_colored(f"   • {host_name}: ", 'white', end='')
                self.app._display_node_status(host_name, compact=True)
                print()  # New line after each status

            # Display copy-friendly node addresses after successful deployment
            self.app._display_copy_friendly_addresses(selected_host_names)
        else:
            self.app.print_colored(f"\n❌ Deployment failed. Check output above for details.", 'red')
            # Update selected node statuses to error after failed deployment
            for host_name in selected_host_names:
                self.app._update_node_status(host_name, 'error')
            self.app.print_colored(f"\n📊 Selected node statuses updated to Error due to deployment failure.", 'yellow')

        self.app.wait_for_enter()

    def _update_deployment_metadata(self, deployment_type: str) -> None:
        """Update deployment metadata after successful deployment"""
        self.app.print_debug(f"Updating deployment metadata with type: {deployment_type}")
        self.app.print_debug(f"Active config: {self.app.active_config}")

        if not self.app.active_config.get('config_name'):
            self.app.print_debug("No config_name in active_config, cannot update deployment metadata")
            self.app.print_colored("Warning: No configuration name found, cannot update deployment tracking", 'yellow')
            return

        config_name = self.app.active_config['config_name']
        metadata_path = self.app.configs_dir / f"{config_name}.json"
        self.app.print_debug(f"Looking for metadata file: {metadata_path}")

        if not metadata_path.exists():
            self.app.print_debug(f"Metadata file does not exist: {metadata_path}")
            self.app.print_colored(f"Warning: Metadata file not found: {metadata_path}", 'yellow')
            return

        try:
            # Load existing metadata
            with open(metadata_path) as f:
                metadata = json.load(f)

            self.app.print_debug(f"Loaded existing metadata: {metadata}")

            # Update deployment info
            current_network = self.app.get_mnl_app_env()
            metadata['last_deployed_date'] = datetime.now().isoformat()
            metadata['last_deployed_network'] = current_network
            metadata['deployment_status'] = 'deployed'
            metadata['last_deployment_type'] = deployment_type

            self.app.print_debug(f"Updated metadata: {metadata}")

            # Save updated metadata
            with open(metadata_path, 'w') as f:
                json.dump(metadata, f, indent=2)

            # Update active config
            self.app.active_config.update(metadata)
            self.app._save_active_config()

            self.app.print_colored(f"✅ Deployment tracking updated for configuration: {config_name}", 'green')
            self.app.print_debug(f"Successfully updated deployment metadata for {config_name}")
        except Exception as e:
            self.app.print_colored(f"Warning: Could not update deployment metadata: {e}", 'yellow')
            self.app.print_debug(f"Error updating deployment metadata: {e}")

    def _update_deletion_metadata(self) -> None:
        """Update deployment metadata after successful deletion"""
        if not self.app.active_config.get('config_name'):
            return

        config_name = self.app.active_config['config_name']
        metadata_path = self.app.configs_dir / f"{config_name}.json"

        if not metadata_path.exists():
            return

        try:
            # Load existing metadata
            with open(metadata_path) as f:
                metadata = json.load(f)

            # Update deletion info
            metadata['last_deleted_date'] = datetime.now().isoformat()
            metadata['deployment_status'] = 'deleted'
            # Keep the last deployment info for reference but mark as deleted

            # Save updated metadata
            with open(metadata_path, 'w') as f:
                json.dump(metadata, f, indent=2)

            # Update active config
            self.app.active_config.update(metadata)
            self.app._save_active_config()

            self.app.print_colored(f"✅ Deletion tracking updated for configuration: {config_name}", 'green')
        except Exception as e:
            self.app.print_colored(f"Warning: Could not update deletion metadata: {e}", 'yellow')

    def deployment_status(self) -> None:
        """Show deployment status for all nodes"""
        if not self.app.check_hosts_config():
            self.app.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.app.wait_for_enter()
            return

        # Reload active config to ensure deployment status is current
        self.app._load_active_config()

        self.app.print_header("Deployment Status")

        # Load configuration
        self.app.load_configuration()
        hosts = _get_gpu_hosts(self.app.inventory)

        if not hosts:
            self.app.print_colored("No nodes configured.", 'yellow')
            self.app.wait_for_enter()
            return

        # Show deployment status overview
        deployment_status = self.app.active_config.get('deployment_status', 'never_deployed')
        last_deployed_date = self.app.active_config.get('last_deployed_date')
        last_deployed_network = self.app.active_config.get('last_deployed_network')
        last_deployment_type = self.app.active_config.get('last_deployment_type')
        last_deleted_date = self.app.active_config.get('last_deleted_date')

        # Overall deployment status
        self.app.print_section("Overall Deployment Status")

        if deployment_status == 'deployed' and last_deployed_date:
            deployed_str = _parse_iso_datetime(last_deployed_date)
            if deployed_str:
                self.app.print_colored(f"🚀 Status: Deployed on {deployed_str}", 'green')
                if last_deployed_network:
                    self.app.print_colored(f"🌐 Network: {last_deployed_network}", 'cyan')
                if last_deployment_type:
                    self.app.print_colored(f"🔧 Type: {last_deployment_type}", 'cyan')
            else:
                self.app.print_colored("🚀 Status: Deployed", 'green')
        elif deployment_status == 'deleted' and last_deleted_date:
            deleted_str = _parse_iso_datetime(last_deleted_date)
            if deleted_str:
                self.app.print_colored(f"🗑️ Status: Deleted on {deleted_str}", 'red')
            else:
                self.app.print_colored("🗑️ Status: Deleted", 'red')
        else:
            self.app.print_colored("📋 Status: Never deployed", 'yellow')

        # Individual node status - check in real-time
        self.app.print_section(f"Node Status Overview ({len(hosts)} nodes)")
        self.app.print_colored(f"🔍 Checking current status (max {self.app.connection_timeout}s timeout)...", 'cyan')

        # Get real-time status for each node
        real_time_status = self.app._get_real_time_node_status()

        print()  # Add a blank line after the checking message

        for host_name in hosts.keys():
            if host_name in real_time_status:
                status = real_time_status[host_name]['status']
                result = real_time_status[host_name]['result']
                emoji, color, description = self.app._get_status_display_info(status)

                self.app.print_colored(f"  • {host_name}: {emoji} {description}", color, end='')
                self.app.print_colored(f" (Current status)", 'white')
            else:
                self.app.print_colored(f"  • {host_name}: ❓ Unable to check", 'yellow', end='')
                self.app.print_colored(f" (Current status)", 'white')

        self.app.wait_for_enter()



class SSHKeyManager:
    """Handles SSH key management workflows and metadata migration."""

    def __init__(self, app):
        self.app = app

    def _get_default_host_metadata(self, host_config: Dict[str, Any]) -> Dict[str, Any]:
        """Return default SSH metadata for a host based on current auth fields."""
        if 'ansible_ssh_pass' in host_config:
            mode = SSH_AUTH_MODE_PASSWORD_ONLY
            requires_revalidation = False
        elif 'ansible_ssh_private_key_file' in host_config:
            mode = SSH_AUTH_MODE_KEY_CONFIGURED_LEGACY
            requires_revalidation = True
        else:
            mode = SSH_AUTH_MODE_VERIFICATION_FAILED
            requires_revalidation = True

        return {
            'r1setup_ssh_auth_mode': mode,
            'r1setup_ssh_primary_key_fingerprint': host_config.get('r1setup_ssh_primary_key_fingerprint'),
            'r1setup_ssh_primary_key_path': host_config.get('ansible_ssh_private_key_file'),
            'r1setup_ssh_key_auth_verified_at': host_config.get('r1setup_ssh_key_auth_verified_at'),
            'r1setup_ssh_last_verified_fingerprint': host_config.get('r1setup_ssh_last_verified_fingerprint'),
            'r1setup_ssh_last_verification_status': host_config.get('r1setup_ssh_last_verification_status', 'not_checked'),
            'r1setup_password_auth_disabled': host_config.get('r1setup_password_auth_disabled', False),
            'r1setup_ssh_hardening_applied_at': host_config.get('r1setup_ssh_hardening_applied_at'),
            'r1setup_ssh_requires_revalidation': host_config.get('r1setup_ssh_requires_revalidation', requires_revalidation),
            'r1setup_managed_public_keys': host_config.get('r1setup_managed_public_keys', []),
        }

    def _migrate_host_metadata(self, host_config: Dict[str, Any]) -> bool:
        """Populate missing SSH metadata for a host."""
        defaults = self._get_default_host_metadata(host_config)
        changed = False
        for key, value in defaults.items():
            if key not in host_config:
                host_config[key] = value
                changed = True
        return changed

    def migrate_legacy_ssh_metadata(self) -> None:
        """Add SSH metadata to saved configurations without changing active auth."""
        migrated_configs = 0
        migrated_hosts = 0

        for config_path in self.app.configs_dir.glob("*.yml"):
            try:
                with open(config_path) as f:
                    config_data = yaml.safe_load(f) or {}
            except Exception as e:
                self.app.print_debug(f"Skipping SSH metadata migration for {config_path}: {e}")
                continue

            if not config_data or 'all' not in config_data:
                continue

            all_section = config_data.setdefault('all', {})
            all_vars = all_section.setdefault('vars', {})
            children = all_section.setdefault('children', {})
            gpu_nodes = children.setdefault('gpu_nodes', {})
            hosts = gpu_nodes.setdefault('hosts', {})

            changed = False
            if all_vars.get('r1setup_schema_version') != SSH_SCHEMA_VERSION:
                all_vars['r1setup_schema_version'] = SSH_SCHEMA_VERSION
                changed = True

            for host_config in hosts.values():
                if self._migrate_host_metadata(host_config):
                    migrated_hosts += 1
                    changed = True

            if changed:
                with open(config_path, 'w') as f:
                    yaml.safe_dump(config_data, f, default_flow_style=False)
                os.chmod(config_path, 0o600)
                migrated_configs += 1

        if migrated_configs:
            self.app.print_colored("SSH key management metadata initialized for existing configurations.", 'cyan')
            self.app.print_colored(f"Updated {migrated_hosts} host definition(s) across {migrated_configs} configuration(s).", 'cyan')

    def check_ssh_key_tooling(self) -> Tuple[bool, List[str]]:
        """Check whether required local SSH tooling is available."""
        missing = []
        for cmd, description in SSH_KEY_MANAGEMENT_REQUIRED_TOOLS.items():
            if shutil.which(cmd) is None:
                missing.append(f"{cmd} ({description})")
        return len(missing) == 0, missing

    def check_feature_capabilities(self) -> Tuple[bool, List[str]]:
        """Check local tools and required playbooks for SSH key management."""
        ok, missing = self.check_ssh_key_tooling()
        issues = list(missing)

        for rel_path in SSH_KEY_MANAGEMENT_REQUIRED_PLAYBOOKS:
            playbook_path = self.app.config_dir / rel_path
            if not playbook_path.exists():
                issues.append(f"missing playbook: {playbook_path}")

        return ok and not any(item.startswith('missing playbook:') for item in issues), issues

    def _show_feature_unavailable(self, issues: List[str]) -> None:
        self.app.print_colored("SSH Key Management is unavailable.", 'red')
        for issue in issues:
            self.app.print_colored(f"  • {issue}", 'yellow')
        self.app.print_colored("Ensure the latest collection is installed and required local tools are available.", 'yellow')
        self.app.wait_for_enter()

    @staticmethod
    def _resolve_abs_path(path_value: str) -> Path:
        """Resolve a local path to an absolute path."""
        return Path(os.path.expanduser(path_value)).resolve()

    def _validate_public_key_content(self, public_key: str) -> Dict[str, Any]:
        """Validate public key material and extract a fingerprint."""
        tmp_path = None
        if not public_key or not public_key.strip():
            return {'valid': False, 'error': 'Public key is empty'}

        try:
            with tempfile.NamedTemporaryFile(mode='w', delete=False) as tmp:
                tmp.write(public_key.strip() + '\n')
                tmp_path = tmp.name

            result = subprocess.run(
                ['ssh-keygen', '-l', '-f', tmp_path],
                capture_output=True,
                text=True,
                timeout=10
            )
            if result.returncode != 0:
                return {'valid': False, 'error': result.stderr.strip() or 'Invalid public key'}

            fingerprint = result.stdout.strip().split()[1] if result.stdout.strip() else None
            return {'valid': True, 'fingerprint': fingerprint}
        except Exception as e:
            return {'valid': False, 'error': str(e)}
        finally:
            if tmp_path:
                try:
                    os.unlink(tmp_path)
                except Exception:
                    pass

    def _validate_public_key_file(self, public_key_path: str) -> Dict[str, Any]:
        """Validate a public key file on disk."""
        resolved = self._resolve_abs_path(public_key_path)
        if not resolved.exists():
            return {'valid': False, 'error': f"Public key file does not exist: {resolved}"}
        if not resolved.is_file():
            return {'valid': False, 'error': f"Public key path is not a file: {resolved}"}

        try:
            with open(resolved) as f:
                content = f.read().strip()
        except Exception as e:
            return {'valid': False, 'error': str(e)}

        result = self._validate_public_key_content(content)
        result.update({'path': str(resolved), 'content': content})
        return result

    def _derive_public_key(self, private_key_path: str) -> Dict[str, Any]:
        """Derive public key material from a private key using ssh-keygen."""
        resolved = self._resolve_abs_path(private_key_path)
        if not resolved.exists():
            return {'valid': False, 'error': f"Private key file does not exist: {resolved}"}
        if not resolved.is_file():
            return {'valid': False, 'error': f"Private key path is not a file: {resolved}"}

        try:
            result = subprocess.run(
                ['ssh-keygen', '-y', '-f', str(resolved)],
                capture_output=True,
                text=True,
                timeout=10
            )
        except Exception as e:
            return {'valid': False, 'error': str(e)}

        if result.returncode != 0:
            return {'valid': False, 'error': result.stderr.strip() or 'Unable to derive public key from private key'}

        public_key = result.stdout.strip()
        validation = self._validate_public_key_content(public_key)
        if not validation['valid']:
            return validation

        validation.update({'content': public_key, 'path': str(resolved)})
        return validation

    def _validate_keypair(self, private_key_path: str, public_key_path: str = '') -> Dict[str, Any]:
        """Validate a migration keypair and ensure the public key matches when provided."""
        private_path = self._resolve_abs_path(private_key_path)
        if not private_path.exists():
            return {'valid': False, 'error': f"Private key file does not exist: {private_path}"}
        if not private_path.is_file():
            return {'valid': False, 'error': f"Private key path is not a file: {private_path}"}
        if not os.access(private_path, os.R_OK):
            return {'valid': False, 'error': f"Private key file is not readable: {private_path}"}

        derived = self._derive_public_key(str(private_path))
        if not derived['valid']:
            return {'valid': False, 'error': derived['error']}

        resolved_public_path = ''
        chosen_public = public_key_path.strip()
        if chosen_public:
            public_result = self._validate_public_key_file(chosen_public)
            if not public_result['valid']:
                return {'valid': False, 'error': public_result['error']}
            resolved_public_path = public_result['path']
            public_content = public_result['content']
            fingerprint = public_result['fingerprint']
        else:
            sibling_public = Path(f"{private_path}.pub")
            if sibling_public.exists():
                public_result = self._validate_public_key_file(str(sibling_public))
                if not public_result['valid']:
                    return {'valid': False, 'error': public_result['error']}
                resolved_public_path = public_result['path']
                public_content = public_result['content']
                fingerprint = public_result['fingerprint']
            else:
                public_content = derived['content']
                fingerprint = derived['fingerprint']

        if derived['content'].strip() != public_content.strip():
            return {'valid': False, 'error': "Public key does not match the selected private key"}

        return {
            'valid': True,
            'private_key_path': str(private_path),
            'public_key_path': resolved_public_path,
            'public_key': public_content,
            'fingerprint': fingerprint,
        }

    def _generate_keypair(self) -> Optional[Dict[str, Any]]:
        """Generate a new local ed25519 keypair."""
        self.app.print_header("Generate SSH Keypair")
        default_path = self._resolve_abs_path("~/.ssh/r1setup_ed25519")
        path_value = self.app.get_input("Private key path", str(default_path))
        private_key_path = self._resolve_abs_path(path_value)
        public_key_path = Path(f"{private_key_path}.pub")

        if private_key_path.exists() or public_key_path.exists():
            if self.app.get_input("Key files already exist. Overwrite? (y/n)", "n").lower() != 'y':
                self.app.print_colored("Key generation cancelled.", 'yellow')
                return None

        private_key_path.parent.mkdir(parents=True, exist_ok=True)
        comment = self.app.get_input("Key comment", f"r1setup@{os.uname().nodename}")

        try:
            result = subprocess.run(
                ['ssh-keygen', '-t', 'ed25519', '-f', str(private_key_path), '-N', '', '-C', comment],
                capture_output=True,
                text=True,
                timeout=30
            )
        except Exception as e:
            self.app.print_colored(f"Key generation failed: {e}", 'red')
            return None

        if result.returncode != 0:
            self.app.print_colored(result.stderr.strip() or "Key generation failed.", 'red')
            return None

        validation = self._validate_keypair(str(private_key_path), str(public_key_path))
        if not validation['valid']:
            self.app.print_colored(f"Generated key validation failed: {validation['error']}", 'red')
            return None

        self.app.print_colored("New SSH keypair generated.", 'green')
        self.app.print_colored(f"  • Private key: {validation['private_key_path']}", 'cyan')
        self.app.print_colored(f"  • Public key:  {validation['public_key_path']}", 'cyan')
        self.app.print_colored("Keep the private key secure. r1setup will use this exact file for future authentication.", 'yellow')
        return validation

    def _select_migration_keypair(self) -> Optional[Dict[str, Any]]:
        """Select or generate the keypair used for SSH migration."""
        self.app.print_header("SSH Key Source")
        self.app.print_colored("  1) Use existing SSH keypair", 'white')
        self.app.print_colored("  2) Generate new SSH keypair", 'white')
        self.app.print_colored("  0) Cancel", 'white')
        print()

        choice = self.app.get_input("Select option", "1")
        if choice == '0':
            return None
        if choice == '2':
            return self._generate_keypair()
        if choice != '1':
            self.app.print_colored("Invalid option.", 'red')
            self.app.wait_for_enter()
            return None

        private_key_path = self.app.get_input("Private key path", "~/.ssh/id_ed25519")
        public_key_path = self.app.get_input("Public key path (Enter for matching .pub)", "")
        validation = self._validate_keypair(private_key_path, public_key_path)
        if not validation['valid']:
            self.app.print_colored(f"Key validation failed: {validation['error']}", 'red')
            self.app.wait_for_enter()
            return None

        self.app.print_colored("SSH keypair validated.", 'green')
        self.app.print_colored(f"  • Private key: {validation['private_key_path']}", 'cyan')
        if validation['public_key_path']:
            self.app.print_colored(f"  • Public key:  {validation['public_key_path']}", 'cyan')
        else:
            self.app.print_colored("  • Public key:  derived from selected private key", 'cyan')
        self.app.print_colored(f"  • Fingerprint: {validation['fingerprint']}", 'cyan')
        return validation

    def _verify_ssh_login(self, host_config: Dict[str, Any], private_key_path: str) -> Tuple[bool, str]:
        """Verify controller-side SSH access using a selected private key."""
        host = host_config.get('ansible_host')
        user = host_config.get('ansible_user')
        port = host_config.get('ansible_port', 22)

        if not host or not user:
            return False, "Missing ansible_host or ansible_user"

        cmd = [
            'ssh',
            '-i', private_key_path,
            '-o', 'BatchMode=yes',
            '-o', 'StrictHostKeyChecking=no',
            '-o', 'UserKnownHostsFile=/dev/null',
            '-o', f'ConnectTimeout={self.app.ssh_connect_timeout}',
        ]
        if port != 22:
            cmd.extend(['-p', str(port)])
        cmd.extend([f"{user}@{host}", 'true'])

        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=self.app.connection_timeout)
        except subprocess.TimeoutExpired:
            return False, f"SSH verification timed out after {self.app.connection_timeout}s"
        except Exception as e:
            return False, str(e)

        if result.returncode == 0:
            return True, "SSH verification succeeded"

        stderr = result.stderr.strip() or result.stdout.strip() or "SSH verification failed"
        return False, stderr

    def _get_hosts_by_auth_mode(self, modes: List[str]) -> Dict[str, Dict[str, Any]]:
        """Return hosts whose SSH auth mode matches one of the supplied modes."""
        hosts = _get_gpu_hosts(self.app.inventory)
        filtered = {}
        for host_name, config in hosts.items():
            mode = config.get('r1setup_ssh_auth_mode')
            if not mode:
                mode = self._get_default_host_metadata(config)['r1setup_ssh_auth_mode']
            if mode in modes:
                filtered[host_name] = config
        return filtered

    def _get_hosts_ready_for_password_disable(self) -> Dict[str, Dict[str, Any]]:
        """Return hosts that are safe candidates for disabling password authentication."""
        candidates = self._get_hosts_by_auth_mode([SSH_AUTH_MODE_KEY_VERIFIED])
        ready = {}
        for host_name, config in candidates.items():
            fingerprint = config.get('r1setup_ssh_primary_key_fingerprint')
            verified_fingerprint = config.get('r1setup_ssh_last_verified_fingerprint')
            requires_revalidation = config.get('r1setup_ssh_requires_revalidation', False)
            if not requires_revalidation and fingerprint and fingerprint == verified_fingerprint:
                ready[host_name] = config
        return ready

    def _set_host_ssh_metadata(self, host_name: str, updates: Dict[str, Any]) -> None:
        """Update in-memory SSH metadata for a host."""
        hosts = _get_gpu_hosts(self.app.inventory)
        if host_name in hosts:
            hosts[host_name].update(updates)

    def _run_playbook_for_hosts(
        self,
        playbook_relative_path: str,
        selected_hosts: List[str],
        extra_vars: Optional[Dict[str, Any]] = None,
        show_output: bool = True,
    ) -> Tuple[bool, str]:
        """Run an SSH management playbook for the selected hosts."""
        playbook_path = self.app.config_dir / playbook_relative_path
        host_limit = ','.join(selected_hosts)
        extra_vars_path = None
        try:
            cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
                   f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
                   f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
                   f"ansible-playbook -i {self.app.config_file} --limit {host_limit} {playbook_path}")

            if extra_vars:
                with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.json') as tmp:
                    json.dump(extra_vars, tmp)
                    extra_vars_path = tmp.name
                cmd = f"{cmd} --extra-vars @{extra_vars_path}"

            return self.app.run_command(cmd, show_output=show_output)
        finally:
            if extra_vars_path:
                try:
                    os.unlink(extra_vars_path)
                except OSError:
                    pass

    def _apply_successful_key_migration(self, host_name: str, private_key_path: str, fingerprint: str) -> None:
        """Apply in-memory inventory changes after successful SSH verification."""
        hosts = _get_gpu_hosts(self.app.inventory)
        if host_name not in hosts:
            return

        host = hosts[host_name]
        host['ansible_ssh_private_key_file'] = private_key_path
        host.pop('ansible_ssh_pass', None)
        host['r1setup_ssh_auth_mode'] = SSH_AUTH_MODE_KEY_VERIFIED
        host['r1setup_ssh_primary_key_path'] = private_key_path
        host['r1setup_ssh_primary_key_fingerprint'] = fingerprint
        host['r1setup_ssh_last_verified_fingerprint'] = fingerprint
        host['r1setup_ssh_key_auth_verified_at'] = datetime.now().isoformat()
        host['r1setup_ssh_last_verification_status'] = 'success'
        host['r1setup_ssh_requires_revalidation'] = False

    def _apply_failed_key_verification(self, host_name: str, private_key_path: str, fingerprint: str = None) -> None:
        """Record a failed SSH key verification without switching active auth."""
        updates = {
            'r1setup_ssh_auth_mode': SSH_AUTH_MODE_VERIFICATION_FAILED,
            'r1setup_ssh_primary_key_path': private_key_path,
            'r1setup_ssh_last_verification_status': 'failed',
            'r1setup_ssh_requires_revalidation': True,
        }
        if fingerprint:
            updates['r1setup_ssh_primary_key_fingerprint'] = fingerprint
        self._set_host_ssh_metadata(host_name, updates)

    def _apply_successful_password_hardening(self, host_name: str) -> None:
        """Record that password authentication was disabled successfully."""
        self._set_host_ssh_metadata(host_name, {
            'r1setup_ssh_auth_mode': SSH_AUTH_MODE_PASSWORD_DISABLED,
            'r1setup_password_auth_disabled': True,
            'r1setup_ssh_hardening_applied_at': datetime.now().isoformat(),
            'r1setup_ssh_last_verification_status': 'success',
            'r1setup_ssh_requires_revalidation': False,
        })

    def _apply_failed_password_hardening(self, host_name: str) -> None:
        """Record that SSH hardening failed and the host needs revalidation."""
        self._set_host_ssh_metadata(host_name, {
            'r1setup_ssh_auth_mode': SSH_AUTH_MODE_VERIFICATION_FAILED,
            'r1setup_password_auth_disabled': False,
            'r1setup_ssh_last_verification_status': 'failed',
            'r1setup_ssh_requires_revalidation': True,
        })

    def _get_ssh_state_guidance(self, host_config: Dict[str, Any]) -> str:
        """Return the recommended next action for a host's SSH state."""
        mode = host_config.get('r1setup_ssh_auth_mode', self._get_default_host_metadata(host_config)['r1setup_ssh_auth_mode'])
        requires_revalidation = host_config.get('r1setup_ssh_requires_revalidation', False)

        if mode == SSH_AUTH_MODE_PASSWORD_ONLY:
            return "Next: Install Key / Migrate Password Hosts."
        if mode == SSH_AUTH_MODE_KEY_CONFIGURED_LEGACY:
            return "Next: Validate Key Authentication before hardening."
        if mode == SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED:
            return "Next: Validate Key Authentication to confirm the installed key."
        if mode == SSH_AUTH_MODE_VERIFICATION_FAILED:
            return "Next: Fix key access, then rerun Validate Key Authentication."
        if mode == SSH_AUTH_MODE_PASSWORD_DISABLED:
            return "State is hardened. Keep at least one recovery key secured outside this host."
        if mode == SSH_AUTH_MODE_KEY_VERIFIED and not requires_revalidation:
            return "Ready: Disable Password Authentication is allowed."
        if mode == SSH_AUTH_MODE_KEY_VERIFIED and requires_revalidation:
            return "Next: Revalidate key authentication before hardening."
        return "Next: Review host SSH settings before changing authentication."

    def ssh_key_management_menu(self) -> None:
        """Show Phase 1 SSH key management menu."""
        while True:
            self.app.print_header("SSH Key Management")

            if not self.app.check_hosts_config():
                self.app.print_colored("No nodes configured. Configure nodes first.", 'red')
                self.app.wait_for_enter()
                return

            ok, issues = self.check_feature_capabilities()
            if not ok:
                self._show_feature_unavailable(issues)
                return

            self.app.load_configuration()
            hosts = _get_gpu_hosts(self.app.inventory)
            self.app.print_section(f"Available for {len(hosts)} configured node(s)")
            self.app.print_colored("  1) Install Key / Migrate Password Hosts", 'white')
            self.app.print_colored("  2) Add Extra Public Key", 'white')
            self.app.print_colored("  3) Validate Key Authentication", 'white')
            self.app.print_colored("  4) Disable Password Authentication", 'white')
            self.app.print_colored("  5) Show SSH Auth Status", 'white')
            self.app.print_colored("  0) Back", 'white')
            print()

            choice = self.app.get_input("Select option", "0")
            if choice == '0':
                return
            if choice == '1':
                self.install_key_and_migrate_hosts()
            elif choice == '2':
                self.add_extra_public_keys()
            elif choice == '3':
                self.validate_key_authentication()
            elif choice == '4':
                self.disable_password_authentication()
            elif choice == '5':
                self.show_ssh_auth_status()
            else:
                self.app.print_colored("Invalid option.", 'red')
                self.app.wait_for_enter()

    def install_key_and_migrate_hosts(self) -> None:
        """Install a public key on password-auth hosts and migrate inventory after verification."""
        self.app.load_configuration()
        password_hosts = self._get_hosts_by_auth_mode([SSH_AUTH_MODE_PASSWORD_ONLY])
        if not password_hosts:
            self.app.print_colored("No password-auth hosts available for SSH key migration.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_header("Install Key / Migrate Password Hosts")
        self.app.print_colored("⚠️  WARNING", 'red', bold=True)
        self.app.print_colored("This installs a public key on selected hosts and then tests key-based login.", 'yellow')
        self.app.print_colored("Inventory will switch to SSH key auth only after verification succeeds.", 'yellow')
        self.app.print_colored("Some cloud providers also require the public key in the provider dashboard or instance metadata.", 'yellow')
        print()

        selected_hosts = self.app.select_hosts(password_hosts, "ssh key migration", preselect_mode='all')
        if not selected_hosts:
            self.app.print_colored("No hosts selected. Migration cancelled.", 'yellow')
            self.app.wait_for_enter()
            return

        keypair = self._select_migration_keypair()
        if not keypair:
            return

        self.app.print_colored("\nInstalling public key on selected hosts...", 'cyan')
        success, _ = self._run_playbook_for_hosts(
            'playbooks/ssh_install_key.yml',
            selected_hosts,
            {'ssh_target_public_key': keypair['public_key']},
            show_output=True,
        )
        if not success:
            self.app.print_colored("Public key installation failed. Inventory was not changed.", 'red')
            self.app.wait_for_enter()
            return

        verified_hosts = []
        failed_hosts = []
        for host_name in selected_hosts:
            host_config = password_hosts[host_name]
            self._set_host_ssh_metadata(host_name, {
                'r1setup_ssh_auth_mode': SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED,
                'r1setup_ssh_primary_key_path': keypair['private_key_path'],
                'r1setup_ssh_primary_key_fingerprint': keypair['fingerprint'],
                'r1setup_ssh_requires_revalidation': True,
            })
            ok, message = self._verify_ssh_login(host_config, keypair['private_key_path'])
            if ok:
                verified_hosts.append(host_name)
                self._apply_successful_key_migration(host_name, keypair['private_key_path'], keypair['fingerprint'])
            else:
                failed_hosts.append((host_name, message))
                self._apply_failed_key_verification(host_name, keypair['private_key_path'], keypair['fingerprint'])

        self.app._save_configuration()

        self.app.print_section("Migration Summary")
        if verified_hosts:
            self.app.print_colored(f"✅ Verified and migrated: {', '.join(verified_hosts)}", 'green')
        if failed_hosts:
            self.app.print_colored("❌ Verification failed for:", 'red')
            for host_name, message in failed_hosts:
                self.app.print_colored(f"   • {host_name}: {message}", 'yellow')

        if verified_hosts:
            self.app.print_colored("Successful hosts now use SSH key authentication in r1setup.", 'cyan')
        if failed_hosts:
            self.app.print_colored("Failed hosts kept their previous inventory auth and were marked for revalidation.", 'yellow')

        self.app.wait_for_enter()

    def add_extra_public_keys(self) -> None:
        """Install an extra public key on selected hosts without switching primary auth."""
        self.app.load_configuration()
        hosts = _get_gpu_hosts(self.app.inventory)
        if not hosts:
            self.app.print_colored("No hosts configured.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_header("Add Extra Public Key")
        selected_hosts = self.app.select_hosts(hosts, "add extra public key", preselect_mode='all')
        if not selected_hosts:
            self.app.print_colored("No hosts selected.", 'yellow')
            self.app.wait_for_enter()
            return

        public_key_path = self.app.get_input("Public key path", "~/.ssh/id_ed25519.pub")
        validation = self._validate_public_key_file(public_key_path)
        if not validation['valid']:
            self.app.print_colored(f"Public key validation failed: {validation['error']}", 'red')
            self.app.wait_for_enter()
            return

        label = self.app.get_input("Label for this key", Path(validation['path']).name)
        self.app.print_colored("Installing extra public key on selected hosts...", 'cyan')
        success, _ = self._run_playbook_for_hosts(
            'playbooks/ssh_add_extra_keys.yml',
            selected_hosts,
            {'ssh_extra_public_keys': [validation['content']]},
            show_output=True,
        )
        if not success:
            self.app.print_colored("Extra public key installation failed.", 'red')
            self.app.wait_for_enter()
            return

        for host_name in selected_hosts:
            host = hosts[host_name]
            managed_keys = host.get('r1setup_managed_public_keys', [])
            entry = {
                'fingerprint': validation['fingerprint'],
                'label': label,
                'added_at': datetime.now().isoformat(),
                'source': validation['path'],
            }
            if not any(k.get('fingerprint') == validation['fingerprint'] for k in managed_keys):
                managed_keys.append(entry)
            host['r1setup_managed_public_keys'] = managed_keys

        self.app._save_configuration()
        self.app.print_colored("Extra public key installed successfully.", 'green')
        self.app.wait_for_enter()

    def validate_key_authentication(self) -> None:
        """Re-validate SSH key authentication for configured key-auth hosts."""
        self.app.load_configuration()
        candidate_hosts = self._get_hosts_by_auth_mode([
            SSH_AUTH_MODE_KEY_CONFIGURED_LEGACY,
            SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED,
            SSH_AUTH_MODE_KEY_VERIFIED,
            SSH_AUTH_MODE_VERIFICATION_FAILED,
        ])

        if not candidate_hosts:
            self.app.print_colored("No key-auth hosts available for validation.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_header("Validate Key Authentication")
        selected_hosts = self.app.select_hosts(candidate_hosts, "validate key auth", preselect_mode='all')
        if not selected_hosts:
            self.app.print_colored("No hosts selected.", 'yellow')
            self.app.wait_for_enter()
            return

        successes = []
        failures = []
        for host_name in selected_hosts:
            host_config = candidate_hosts[host_name]
            key_path = host_config.get('ansible_ssh_private_key_file') or host_config.get('r1setup_ssh_primary_key_path')
            if not key_path:
                failures.append((host_name, "No SSH private key path configured"))
                self._apply_failed_key_verification(host_name, '')
                continue

            fingerprint = host_config.get('r1setup_ssh_primary_key_fingerprint')
            derived = self._derive_public_key(key_path)
            if derived['valid']:
                fingerprint = derived['fingerprint']

            ok, message = self._verify_ssh_login(host_config, key_path)
            if ok:
                self._set_host_ssh_metadata(host_name, {
                    'r1setup_ssh_auth_mode': SSH_AUTH_MODE_KEY_VERIFIED,
                    'r1setup_ssh_key_auth_verified_at': datetime.now().isoformat(),
                    'r1setup_ssh_primary_key_fingerprint': fingerprint,
                    'r1setup_ssh_last_verified_fingerprint': fingerprint,
                    'r1setup_ssh_last_verification_status': 'success',
                    'r1setup_ssh_requires_revalidation': False,
                    'r1setup_ssh_primary_key_path': key_path,
                })
                successes.append(host_name)
            else:
                self._apply_failed_key_verification(host_name, key_path, fingerprint)
                failures.append((host_name, message))

        self.app._save_configuration()

        self.app.print_section("Validation Summary")
        if successes:
            self.app.print_colored(f"✅ Verified: {', '.join(successes)}", 'green')
        if failures:
            self.app.print_colored("❌ Verification failed for:", 'red')
            for host_name, message in failures:
                self.app.print_colored(f"   • {host_name}: {message}", 'yellow')
        self.app.wait_for_enter()

    def disable_password_authentication(self) -> None:
        """Disable SSH password authentication on hosts with verified key-based access."""
        self.app.load_configuration()
        candidate_hosts = self._get_hosts_ready_for_password_disable()
        if not candidate_hosts:
            self.app.print_colored("No hosts are ready for password-auth disable. Validate key authentication first.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_header("Disable Password Authentication")
        self.app.print_colored("⚠️  WARNING", 'red', bold=True)
        self.app.print_colored("This changes the remote SSH daemon policy for the selected machine(s), not just r1setup.", 'yellow')
        self.app.print_colored("Only proceed if key-based SSH login is already verified and you understand the lockout risk.", 'yellow')
        self.app.print_colored("A timed remote rollback will be scheduled automatically in case controller-side verification fails.", 'yellow')
        print()

        selected_hosts = self.app.select_hosts(candidate_hosts, "disable password authentication", preselect_mode='none')
        if not selected_hosts:
            self.app.print_colored("No hosts selected.", 'yellow')
            self.app.wait_for_enter()
            return

        confirm = self.app.get_input("Type 'disable' to continue", "")
        if confirm.strip().lower() != 'disable':
            self.app.print_colored("SSH hardening cancelled.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_colored("Applying SSH hardening on selected hosts...", 'cyan')
        success, _ = self._run_playbook_for_hosts(
            'playbooks/ssh_disable_password_auth.yml',
            selected_hosts,
            {
                'ssh_password_auth_action': 'apply',
                'ssh_hardening_rollback_delay': max(self.app.connection_timeout, 90),
            },
            show_output=True,
        )
        if not success:
            self.app.print_colored("SSH hardening playbook failed. Existing host metadata was not promoted.", 'red')
            self.app.wait_for_enter()
            return

        confirmed_hosts = []
        rollback_pending = []
        confirm_failures = []

        for host_name in selected_hosts:
            host_config = candidate_hosts[host_name]
            key_path = host_config.get('ansible_ssh_private_key_file') or host_config.get('r1setup_ssh_primary_key_path')
            ok, message = self._verify_ssh_login(host_config, key_path) if key_path else (False, "No SSH private key path configured")
            if ok:
                confirm_ok, confirm_output = self._run_playbook_for_hosts(
                    'playbooks/ssh_disable_password_auth.yml',
                    [host_name],
                    {'ssh_password_auth_action': 'confirm'},
                    show_output=True,
                )
                if confirm_ok:
                    self._apply_successful_password_hardening(host_name)
                    confirmed_hosts.append(host_name)
                else:
                    self._apply_failed_password_hardening(host_name)
                    confirm_failures.append((host_name, confirm_output or "Unable to confirm SSH hardening"))
            else:
                self._apply_failed_password_hardening(host_name)
                rollback_ok, rollback_output = self._run_playbook_for_hosts(
                    'playbooks/ssh_disable_password_auth.yml',
                    [host_name],
                    {'ssh_password_auth_action': 'rollback'},
                    show_output=True,
                )
                rollback_message = message
                if not rollback_ok:
                    rollback_message = f"{message} | rollback attempt failed: {rollback_output or 'host unreachable'}"
                rollback_pending.append((host_name, rollback_message))

        self.app._save_configuration()

        self.app.print_section("Hardening Summary")
        if confirmed_hosts:
            self.app.print_colored(f"✅ Password authentication disabled: {', '.join(confirmed_hosts)}", 'green')
        if confirm_failures:
            self.app.print_colored("⚠️  Hardening applied, but confirmation failed for:", 'yellow')
            for host_name, message in confirm_failures:
                self.app.print_colored(f"   • {host_name}: {message}", 'yellow')
        if rollback_pending:
            self.app.print_colored("❌ Verification failed; rollback was attempted or left scheduled for:", 'red')
            for host_name, message in rollback_pending:
                self.app.print_colored(f"   • {host_name}: {message}", 'yellow')
            self.app.print_colored("If a host is temporarily unreachable, the remote rollback timer should restore SSH access automatically.", 'yellow')

        self.app.wait_for_enter()

    def show_ssh_auth_status(self) -> None:
        """Display SSH auth metadata for configured hosts."""
        self.app.load_configuration()
        hosts = _get_gpu_hosts(self.app.inventory)
        if not hosts:
            self.app.print_colored("No hosts configured.", 'yellow')
            self.app.wait_for_enter()
            return

        self.app.print_header("SSH Auth Status")
        for host_name, host_config in hosts.items():
            mode = host_config.get('r1setup_ssh_auth_mode', self._get_default_host_metadata(host_config)['r1setup_ssh_auth_mode'])
            key_path = host_config.get('r1setup_ssh_primary_key_path') or host_config.get('ansible_ssh_private_key_file') or 'N/A'
            verified_at = host_config.get('r1setup_ssh_key_auth_verified_at') or 'never'
            requires_revalidation = host_config.get('r1setup_ssh_requires_revalidation', False)
            fingerprint = host_config.get('r1setup_ssh_primary_key_fingerprint') or 'unknown'
            password_auth_disabled = host_config.get('r1setup_password_auth_disabled', False)
            hardening_applied_at = host_config.get('r1setup_ssh_hardening_applied_at') or 'never'
            auth_type = "Password" if 'ansible_ssh_pass' in host_config else "SSH Key"
            self.app.print_colored(f"  • {host_name}", 'cyan', bold=True)
            self.app.print_colored(f"     Current inventory auth: {auth_type}", 'white')
            self.app.print_colored(f"     SSH state: {mode}", 'white')
            self.app.print_colored(f"     Key path: {key_path}", 'white')
            self.app.print_colored(f"     Key fingerprint: {fingerprint}", 'white')
            self.app.print_colored(f"     Last verified: {verified_at}", 'white')
            self.app.print_colored(f"     Requires revalidation: {'yes' if requires_revalidation else 'no'}", 'white')
            self.app.print_colored(f"     Password auth disabled: {'yes' if password_auth_disabled else 'no'}", 'white')
            self.app.print_colored(f"     Hardening applied at: {hardening_applied_at}", 'white')
            self.app.print_colored(f"     Guidance: {self._get_ssh_state_guidance(host_config)}", 'yellow')
            managed_keys = host_config.get('r1setup_managed_public_keys', [])
            if managed_keys:
                self.app.print_colored(f"     Extra managed keys: {len(managed_keys)}", 'white')
            print()

        self.app.wait_for_enter()


class R1Setup:
    def __init__(self):
        self.colors = {
            'red': '\033[91m',
            'green': '\033[92m',
            'yellow': '\033[93m',
            'blue': '\033[94m',
            'cyan': '\033[96m',
            'magenta': '\033[95m',
            'white': '\033[97m',
            'end': '\033[0m'
        }

        # Get the real user's home directory when running with sudo
        if 'SUDO_USER' in os.environ:
            import pwd
            real_user = os.environ['SUDO_USER']
            self.real_home = Path(pwd.getpwnam(real_user).pw_dir)
            self.real_user = real_user
        else:
            self.real_home = Path.home()
            self.real_user = os.environ.get('USER', 'unknown')

        # Detect OS
        self.os_type = self._detect_os()

        # Set up paths
        self.ratio1_base_dir = self.real_home / '.ratio1'
        self.r1_setup_dir = self.ratio1_base_dir / 'r1_setup'
        self.ansible_config_root = self.ratio1_base_dir / 'ansible_config'
        self.config_dir = self.ansible_config_root / 'collections/ansible_collections/ratio1/multi_node_launcher'

        # Configuration management paths
        self.configs_dir = self.r1_setup_dir / 'configs'
        self.active_config_file = self.r1_setup_dir / 'active_config.json'
        self.config_file = self.config_dir / 'hosts.yml'
        self.vars_file = self.config_dir / 'group_vars/variables.yml'

        # Create configs directory if it doesn't exist
        self.configs_dir.mkdir(parents=True, exist_ok=True)

        # Set installation directories based on OS
        if self.os_type == "macos":
            self.install_dir = self.real_home / "r1setup"
        else:
            self.install_dir = Path("/opt/r1setup")

        # Set up Ansible environment
        self._setup_ansible_env()

        # Initialize inventory
        self.inventory = {
            'all': {
                'vars': {},
                'children': {
                    'gpu_nodes': {
                        'hosts': {}
                    }
                }
            }
        }

        # Component instances (order matters)
        self.version_manager = VersionManager(self)
        self.config_manager = ConfigurationManager(self)
        self.status_tracker = NodeStatusTracker(self)
        self.deployment_service = DeploymentService(self)
        self.settings_manager = SettingsManager(self)
        self.ssh_key_manager = SSHKeyManager(self)
        self.settings_manager.load_settings()

        # Load or initialize active configuration
        self.config_manager._load_active_config()

    # -- Backward-compat property for active_config --
    @property
    def active_config(self):
        return self.config_manager.active_config

    @active_config.setter
    def active_config(self, value):
        self.config_manager.active_config = value

    def _detect_os(self) -> str:
        """Detect the operating system"""
        os_name = os.uname().sysname
        if os_name == "Darwin":
            return "macos"
        elif os_name == "Linux":
            return "linux"
        else:
            self.print_colored(f"Unsupported OS: {os_name}", 'red')
            sys.exit(1)

    def _setup_ansible_env(self):
        """Set up Ansible environment variables"""
        os.environ['ANSIBLE_CONFIG'] = str(self.ansible_config_root / 'ansible.cfg')
        os.environ['ANSIBLE_COLLECTIONS_PATH'] = str(self.ansible_config_root / 'collections')
        os.environ['ANSIBLE_HOME'] = str(self.ansible_config_root)

    def wait_for_enter(self, message: str = "Press Enter to continue...") -> None:
        input(f"\n{message}")

    def print_colored(self, text: str, color: str = 'white', bold: bool = False, end: str = '\n') -> None:
        """Print colored text"""
        color_code = self.colors.get(color, self.colors['white'])
        if bold:
            color_code = '\033[1m' + color_code
        print(f"{color_code}{text}{self.colors['end']}", end=end)

    def print_debug(self, text: str, color: str = 'cyan') -> None:
        """Print debug text only when DEBUG is enabled"""
        if DEBUG:
            self.print_colored(f"[DEBUG] {text}", color)

    def print_header(self, title: str) -> None:
        """Print a formatted header"""
        os.system('clear' if os.name != 'nt' else 'cls')
        self.print_colored("=" * 60, 'cyan')
        self.print_colored(f" {title.center(58)} ", 'cyan', bold=True)
        self.print_colored("=" * 60, 'cyan')

    def print_section(self, title: str) -> None:
        """Print a section header"""
        self.print_colored(f"\n{title}", 'yellow', bold=True)
        self.print_colored("-" * len(title), 'yellow')

    def get_input(self, prompt: str, default: str = '', required: bool = False) -> str:
        """Get user input with validation"""
        while True:
            default_str = f" [{default}]" if default else ""
            self.print_colored(f"{prompt}{default_str}: ", 'blue', end='')
            try:
                value = input().strip() or default
                if required and not value:
                    self.print_colored("This field cannot be empty. Please try again.", 'red')
                    continue
                return value
            except KeyboardInterrupt:
                self.print_colored("\nOperation cancelled by user.", 'yellow')
                sys.exit(0)

    def get_secure_input(self, prompt: str) -> str:
        """Get secure password input"""
        try:
            # Use getpass with the full prompt to avoid display issues
            return getpass.getpass(f"{prompt}: ")
        except (EOFError, KeyboardInterrupt):
            self.print_colored("\nOperation cancelled by user.", 'yellow')
            sys.exit(0)

    def validate_ip(self, ip: str) -> bool:
        """Validate IP address format"""
        pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
        if not re.match(pattern, ip):
            return False
        return all(0 <= int(part) <= 255 for part in ip.split('.'))

    def _validate_ssh_key_file(self, key_path: str) -> Dict[str, Any]:
        """Validate SSH private key file exists and is readable"""
        try:
            # Check if file exists
            if not os.path.exists(key_path):
                return {
                    'valid': False,
                    'error': f"File does not exist"
                }
            
            # Check if it's a file (not a directory)
            if not os.path.isfile(key_path):
                return {
                    'valid': False,
                    'error': f"Path is not a file"
                }
            
            # Check if file is readable
            if not os.access(key_path, os.R_OK):
                return {
                    'valid': False,
                    'error': f"File is not readable (check permissions)"
                }
            
            # Check file size (empty files are invalid)
            if os.path.getsize(key_path) == 0:
                return {
                    'valid': False,
                    'error': f"File is empty"
                }
            
            # Optional: Basic content validation for SSH key format
            try:
                with open(key_path, 'r') as f:
                    first_line = f.readline().strip()
                    # Check for common SSH private key headers
                    valid_headers = [
                        '-----BEGIN RSA PRIVATE KEY-----',
                        '-----BEGIN DSA PRIVATE KEY-----', 
                        '-----BEGIN EC PRIVATE KEY-----',
                        '-----BEGIN OPENSSH PRIVATE KEY-----',
                        '-----BEGIN PRIVATE KEY-----'
                    ]
                    
                    if not any(first_line.startswith(header) for header in valid_headers):
                        self.print_colored(f"Warning: File may not be a valid SSH private key format", 'yellow')
                        self.print_colored(f"Expected headers: RSA, DSA, EC, or OpenSSH format", 'yellow')
                        # Don't fail validation, just warn
            except (UnicodeDecodeError, IOError):
                # If we can't read as text, it might be a binary key format - that's okay
                pass
            
            return {
                'valid': True,
                'error': None
            }
            
        except Exception as e:
            return {
                'valid': False,
                'error': f"Unexpected error: {str(e)}"
            }

    def _get_valid_hostname(self, prompt: str, default: str = "") -> str:
        """Get a valid hostname with length and character restrictions"""
        self.print_colored("\n📝 Hostname Requirements:", 'cyan')
        self.print_colored("   • Maximum 15 characters", 'white')
        self.print_colored("   • Only letters (a-z, A-Z), numbers (0-9), hyphens (-), underscores (_)", 'white')
        self.print_colored("   • Cannot be empty", 'white')

        while True:
            hostname = self.get_input(prompt, default, required=True).strip()

            # Check if empty
            if not hostname:
                self.print_colored("Hostname cannot be empty. Please try again.", 'red')
                continue

            # Check character restrictions
            if not re.match(r'^[a-zA-Z0-9_-]+$', hostname):
                self.print_colored("Invalid characters in hostname.", 'red')
                self.print_colored("Only letters, numbers, hyphens (-), and underscores (_) are allowed.", 'red')
                continue

            # Check length
            if len(hostname) <= 15:
                return hostname

            # Hostname is too long, suggest shortened version
            shortened = hostname[:15]
            self.print_colored(f"Hostname '{hostname}' is too long ({len(hostname)} characters, max 15).", 'red')
            self.print_colored(f"Suggested shortened version: '{shortened}'", 'yellow')

            choice = self.get_input("Options:\n  1) Use shortened version\n  2) Enter a different name\nSelect option (1/2)", "1")

            if choice == '1':
                return shortened
            elif choice == '2':
                continue  # Ask for hostname again
            else:
                self.print_colored("Invalid choice. Please select 1 or 2.", 'red')
                continue

    def run_command(self, cmd: str, show_output: bool = True, shell: bool = True, timeout: int = None) -> tuple:
        """Run a shell command and return success status and output"""
        try:
            if show_output:
                self.print_colored(f"Running: {cmd}", 'cyan')

            if timeout and not show_output:
                # Use Popen for timeout cases where we want to capture partial output
                process = subprocess.Popen(
                    cmd,
                    shell=shell,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True
                )
                
                try:
                    stdout, _ = process.communicate(timeout=timeout)
                    return process.returncode == 0, stdout
                except subprocess.TimeoutExpired:
                    process.kill()
                    # Try to get any partial output
                    try:
                        stdout, _ = process.communicate(timeout=5)
                        self.print_colored(f"Command timed out after {timeout} seconds but captured partial output", 'yellow')
                        return False, stdout  # Return partial output even though it timed out
                    except subprocess.TimeoutExpired:
                        self.print_colored(f"Command timed out after {timeout} seconds: {cmd}", 'red')
                        return False, f"Command timed out after {timeout} seconds"
            else:
                # Use regular subprocess.run for other cases
                result = subprocess.run(
                    cmd,
                    shell=shell,
                    capture_output=not show_output,
                    text=True,
                    check=False,
                    timeout=timeout
                )

                if show_output:
                    return result.returncode == 0, ""
                else:
                    return result.returncode == 0, result.stdout
        except subprocess.TimeoutExpired:
            self.print_colored(f"Command timed out after {timeout} seconds: {cmd}", 'red')
            return False, f"Command timed out after {timeout} seconds"
        except Exception as e:
            self.print_colored(f"Error running command: {e}", 'red')
            return False, str(e)

    def check_ansible_installation(self) -> bool:
        """Check if Ansible is properly installed"""
        success, _ = self.run_command("ansible --version", show_output=False)
        if not success:
            self.print_colored("Ansible is not installed or not accessible!", 'red')
            return False

        # Check if collection is installed
        success, output = self.run_command(
            f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
            f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
            f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
            "ansible-galaxy collection list",
            show_output=False
        )

        if not success or "ratio1.multi_node_launcher" not in output:
            self.print_colored("Required Ansible collection is not installed!", 'red')
            return False

        return True

    def check_hosts_config(self) -> bool:
        """Check if hosts configuration exists and is valid"""
        if not self.config_file.exists():
            return False

        if self.config_file.stat().st_size == 0:
            return False

        try:
            with open(self.config_file) as f:
                config = yaml.safe_load(f)
                if not config or 'all' not in config:
                    return False
                hosts = _get_gpu_hosts(config)
                return len(hosts) > 0
        except Exception:
            return False

    # -- ConfigurationManager delegation stubs --
    def _load_active_config(self):
        return self.config_manager._load_active_config()

    def _save_active_config(self):
        return self.config_manager._save_active_config()

    def _generate_config_name(self, nodes_count, custom_name=None):
        return self.config_manager._generate_config_name(nodes_count, custom_name)

    def _list_available_configs(self):
        return self.config_manager._list_available_configs()

    def _save_config_with_metadata(self, config_name, environment, nodes_count, update_symlink=True):
        return self.config_manager._save_config_with_metadata(config_name, environment, nodes_count, update_symlink)

    def _update_hosts_symlink(self, config_path):
        return self.config_manager._update_hosts_symlink(config_path)

    def _load_config_by_name(self, config_name):
        return self.config_manager._load_config_by_name(config_name)

    def load_configuration(self):
        return self.config_manager.load_configuration()

    def get_mnl_app_env(self):
        return self.config_manager.get_mnl_app_env()

    def set_mnl_app_env(self, env_value):
        return self.config_manager.set_mnl_app_env(env_value)

    def _save_configuration(self):
        return self.config_manager._save_configuration()

    def manage_configurations_menu(self):
        return self.config_manager.manage_configurations_menu()

    def _create_new_configuration_with_management(self):
        return self.config_manager._create_new_configuration_with_management()

    def _switch_configuration(self, configs):
        return self.config_manager._switch_configuration(configs)

    def _delete_configuration(self, configs):
        return self.config_manager._delete_configuration(configs)

    def _rename_configuration(self, configs):
        return self.config_manager._rename_configuration(configs)

    def _backup_configuration(self, configs):
        return self.config_manager._backup_configuration(configs)

    def _restore_configuration(self):
        return self.config_manager._restore_configuration()

    def _export_configuration(self, configs):
        return self.config_manager._export_configuration(configs)

    def _import_configuration(self):
        return self.config_manager._import_configuration()

    def _quick_export_current(self):
        return self.config_manager._quick_export_current()

    # -- NodeStatusTracker delegation stubs --
    def _should_preserve_node_status(self, node_name):
        return self.status_tracker._should_preserve_node_status(node_name)

    def _update_node_status(self, node_name, status):
        return self.status_tracker._update_node_status(node_name, status)

    def _get_node_status_info(self, node_name):
        return self.status_tracker._get_node_status_info(node_name)

    def _get_real_time_node_status(self):
        return self.status_tracker._get_real_time_node_status()

    # -- SSHKeyManager delegation stubs --
    def migrate_legacy_ssh_metadata(self):
        return self.ssh_key_manager.migrate_legacy_ssh_metadata()

    def check_ssh_key_tooling(self):
        return self.ssh_key_manager.check_ssh_key_tooling()

    def ssh_key_management_menu(self):
        return self.ssh_key_manager.ssh_key_management_menu()

    def install_key_and_migrate_hosts(self):
        return self.ssh_key_manager.install_key_and_migrate_hosts()

    def add_extra_public_keys(self):
        return self.ssh_key_manager.add_extra_public_keys()

    def validate_key_authentication(self):
        return self.ssh_key_manager.validate_key_authentication()

    def disable_password_authentication(self):
        return self.ssh_key_manager.disable_password_authentication()

    def show_ssh_auth_status(self):
        return self.ssh_key_manager.show_ssh_auth_status()

    def _get_status_display_info(self, status):
        return self.status_tracker._get_status_display_info(status)

    def _display_node_status(self, node_name, compact=False):
        return self.status_tracker._display_node_status(node_name, compact)

    def check_and_update_node_status(self):
        return self.status_tracker.check_and_update_node_status()

    def _parse_service_status_output(self, output):
        return self.status_tracker._parse_service_status_output(output)

    def _determine_updated_status(self, current_status, actual_status):
        return self.status_tracker._determine_updated_status(current_status, actual_status)

    # -- DeploymentService delegation stubs --
    def deploy_full(self):
        return self.deployment_service.deploy_full()

    def deploy_docker_only(self):
        return self.deployment_service.deploy_docker_only()

    def delete_edge_node(self):
        return self.deployment_service.delete_edge_node()

    def deployment_status(self):
        return self.deployment_service.deployment_status()

    # -- SettingsManager delegation stub --
    def settings_menu(self):
        return self.settings_manager.settings_menu()

    @property
    def connection_timeout(self) -> int:
        return self.settings_manager.connection_timeout

    @property
    def ssh_connect_timeout(self) -> int:
        return self.settings_manager.ssh_connect_timeout

    # -- Service override helpers --

    def _get_service_overrides(self) -> dict:
        """Return the current service_overrides dict from active_config (may be empty)."""
        return dict(self.active_config.get('service_overrides', {}))

    def _save_service_overrides(self, overrides: dict) -> None:
        """Persist *overrides* into the active config metadata JSON and in-memory dict."""
        if overrides:
            self.config_manager.active_config['service_overrides'] = overrides
        else:
            self.config_manager.active_config.pop('service_overrides', None)
        self.config_manager._save_active_config()

    def get_mnl_service_version(self) -> str:
        return self.config_manager.get_mnl_service_version()

    def get_host_service_file_version(self, host_config: Dict[str, Any]) -> str:
        return self.config_manager.get_host_service_file_version(host_config)

    def record_service_file_version(self, host_names: List[str], service_version: Optional[str] = None) -> None:
        return self.config_manager.record_service_file_version(host_names, service_version)

    def record_service_file_versions(self, host_versions: Dict[str, str]) -> None:
        return self.config_manager.record_service_file_versions(host_versions)

    def _get_suggested_action(self):
        """Return (default_option, hint_text) based on current state."""
        has_config = self.check_hosts_config()
        if not has_config:
            return '1', '\U0001f4a1 Suggested: Configure your nodes first'
        deployment_status = self.active_config.get('deployment_status', 'never_deployed')
        if deployment_status in ('never_deployed', 'deleted'):
            return '2', '\U0001f4a1 Suggested: Deploy your configured nodes'
        hosts = _get_gpu_hosts(self.inventory)
        error_count = sum(1 for h in hosts.values() if h.get('node_status') == 'error')
        if error_count > 0:
            return '4', f'\U0001f4a1 Suggested: Check {error_count} node(s) with errors'
        return '', ''

    def show_main_menu(self) -> None:
        """Display the main menu"""
        # Reload active config to ensure deployment status is current
        self._load_active_config()
        
        self.print_header("Ratio1 Multi-Node Launcher Setup")

        # Show compact status line
        has_config = self.check_hosts_config()
        current_env = self.get_mnl_app_env() or 'not set'
        active_config_name = self.active_config.get('config_name', 'None')

        # Line 1: config | network | deployment
        deployment_status = self.active_config.get('deployment_status', 'never_deployed')
        last_deployed_date = self.active_config.get('last_deployed_date')
        last_deployed_network = self.active_config.get('last_deployed_network')
        last_deleted_date = self.active_config.get('last_deleted_date')

        if deployment_status == 'deployed' and last_deployed_date:
            deployed_str = _parse_iso_datetime(last_deployed_date)
            if deployed_str:
                deploy_str = f"\U0001f680 deployed {deployed_str}"
                if last_deployed_network:
                    deploy_str += f" ({last_deployed_network})"
            else:
                deploy_str = "\u2713 deployed"
        elif deployment_status == 'deleted':
            deploy_str = "\U0001f5d1\ufe0f deleted"
        else:
            deploy_str = "\u2717 not deployed"

        config_label = active_config_name if active_config_name != 'None' else 'none'
        self.print_colored(f"  {config_label} | {current_env} | {deploy_str}", 'cyan')

        # Line 2: node status summary (only when configured)
        if has_config:
            self.load_configuration()
            hosts = _get_gpu_hosts(self.inventory)
            if hosts:
                status_counts = {}
                oldest_update = None
                for host_name in hosts.keys():
                    status_info = self._get_node_status_info(host_name)
                    status = status_info['status']
                    status_counts[status] = status_counts.get(status, 0) + 1
                    last_update = status_info.get('last_update', '')
                    if last_update:
                        if oldest_update is None or last_update < oldest_update:
                            oldest_update = last_update

                status_summary = []
                for status, count in status_counts.items():
                    emoji, color, description = self._get_status_display_info(status)
                    status_summary.append(f"{emoji}{count} {description.lower()}")

                age_str = self._format_timestamp_ago(oldest_update) if oldest_update else "never checked"
                self.print_colored(
                    f"  Nodes ({len(hosts)}): {', '.join(status_summary)} ({age_str})",
                    'cyan'
                )

        self.print_section("R1Setup Main Menu")
        print()
        self.print_colored("\U0001f4cb CONFIGURATION", 'cyan', bold=True)
        self.print_colored("  1) Configuration Menu      - Node setup, environments, and management")
        print()
        self.print_colored("\U0001f680 DEPLOYMENT", 'cyan', bold=True)
        self.print_colored("  2) Deployment Menu         - Deploy, delete, and manage deployments")
        print()
        self.print_colored("\U0001f527 OPERATIONS", 'cyan', bold=True)
        self.print_colored("  3) Operations Menu         - Start, stop, restart service and test connectivity")
        print()
        self.print_colored("\U0001f4ca MONITORING & INFO", 'cyan', bold=True)
        self.print_colored("  4) Node Status & Info      - Get latest info and show detailed status")
        self.print_colored("  5) Node Addresses & Export - Display addresses and export to CSV")
        print()
        self.print_colored("\u2699\ufe0f  SETTINGS & TOOLS", 'cyan', bold=True)
        self.print_colored("  6) Settings                - Toggle live status display and preferences")
        self.print_colored("  7) Advanced Menu           - SSH, logs, and security tools")
        print()
        self.print_colored("  0) Exit")
        print()

        # Show contextual suggestion
        _, hint = self._get_suggested_action()
        if hint:
            self.print_colored(hint, 'cyan')
            print()

    def configuration_menu(self) -> None:
        """Show configuration submenu"""
        while True:
            self.print_header("Configuration Menu")

            # Show current configuration status
            has_config = self.check_hosts_config()
            current_env = self.get_mnl_app_env()
            active_config_name = self.active_config.get('config_name', 'None')

            self.print_section("Current Status")
            self.print_colored(f"Active Config: {active_config_name if active_config_name != 'None' else '✗ No active config'}",
                               'green' if active_config_name != 'None' else 'red')
            self.print_colored(f"Configuration: {'✓ Configured' if has_config else '✗ Not configured'}",
                               'green' if has_config else 'red')
            self.print_colored(f"Network: {current_env if current_env else '✗ Not set'}",
                               'green' if current_env else 'red')

            # Show node count if configured
            if has_config:
                self.load_configuration()
                hosts = _get_gpu_hosts(self.inventory)
                self.print_colored(f"Nodes: {len(hosts)} configured", 'cyan')

            self.print_colored("Configuration Menu", 'cyan', bold=True)
            print()
            self.print_colored("  1) Configure Nodes        - Node connection setup and management")
            self.print_colored("  2) Manage Configurations  - Switch, backup, restore configurations")
            self.print_colored("  3) View Configuration     - Display current configuration")
            self.print_colored("  4) Switch Environment     - Change network environment (mainnet/testnet/devnet)")
            print()
            self.print_colored("  0) Back to Main Menu")
            print()

            choice = self.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice == '1':
                self.configure_nodes_menu()
            elif choice == '2':
                self.manage_configurations_menu()
            elif choice == '3':
                self.view_configuration()
            elif choice == '4':
                self.switch_environment()
            else:
                self.print_colored("Invalid option. Valid choices are 0-4.", 'red')
                self.wait_for_enter()

    def configure_nodes_menu(self) -> None:
        """Show node configuration submenu"""
        while True:
            self.print_header("Node Configuration")

            # Load current configuration
            self.load_configuration()
            hosts = _get_gpu_hosts(self.inventory)

            if hosts:
                self.print_section(f"Current Nodes ({len(hosts)})")
                for i, (name, config) in enumerate(hosts.items(), 1):
                    ip = config.get('ansible_host', 'Unknown')
                    user = config.get('ansible_user', 'Unknown')
                    
                    # Get status information
                    status_info = self._get_node_status_info(name)
                    status = status_info['status']
                    status_emoji, status_color, status_desc = self._get_status_display_info(status)
                    
                    last_update = status_info.get('last_update', '')
                    age_str = self._format_timestamp_ago(last_update) if last_update else "Never"

                    self.print_colored(f"  {i}. {name} ({user}@{ip}) ", 'white', end='')
                    self.print_colored(f"[{status_emoji} {status_desc}]", status_color, end='')
                    self.print_colored(f" ({age_str})", 'white')
                print()

            self.print_colored("Configure Nodes Menu", 'cyan', bold=True)
            print()
            if not hosts:
                self.print_colored("  1) Create Initial Configuration    - First-time setup")
            else:
                self.print_colored("  1) Add New Node                   - Add node to existing config")
                self.print_colored("  2) Edit Existing Node             - Modify node settings")
                self.print_colored("  3) Remove Node                    - Delete node from config")
                self.print_colored("  4) Create New Configuration       - Start fresh configuration")
            print()
            self.print_colored("  0) Back to Main Menu")
            print()

            choice = self.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice == '1':
                if not hosts:
                    self._create_initial_configuration()
                else:
                    self._add_node()
            elif choice == '2' and hosts:
                self._update_node()
            elif choice == '3' and hosts:
                self._delete_node()
            elif choice == '4' and hosts:
                self._create_new_configuration()
            else:
                self.print_colored("Invalid option. Valid choices are 0-4.", 'red')
                self.wait_for_enter()

    def deployment_menu(self) -> None:
        """Show deployment submenu"""
        while True:
            # Reload active config to ensure deployment status is current
            self._load_active_config()
            
            self.print_header("Deployment Menu")

            # Show deployment status overview
            deployment_status = self.active_config.get('deployment_status', 'never_deployed')
            last_deployed_date = self.active_config.get('last_deployed_date')
            last_deployed_network = self.active_config.get('last_deployed_network')
            last_deployment_type = self.active_config.get('last_deployment_type')
            last_deleted_date = self.active_config.get('last_deleted_date')

            # Show current deployment status
            self.print_section("Current Deployment Status")
            if deployment_status == 'deployed' and last_deployed_date:
                deployed_str = _parse_iso_datetime(last_deployed_date)
                if deployed_str:
                    self.print_colored(f"🚀 Status: Deployed on {deployed_str}", 'green')
                    if last_deployed_network:
                        self.print_colored(f"🌐 Network: {last_deployed_network}", 'cyan')
                    if last_deployment_type:
                        self.print_colored(f"🔧 Type: {last_deployment_type}", 'cyan')
                else:
                    self.print_colored("🚀 Status: Deployed", 'green')
            elif deployment_status == 'deleted' and last_deleted_date:
                deleted_str = _parse_iso_datetime(last_deleted_date)
                if deleted_str:
                    self.print_colored(f"🗑️ Status: Deleted on {deleted_str}", 'red')
                else:
                    self.print_colored("🗑️ Status: Deleted", 'red')
            else:
                self.print_colored("📋 Status: Never deployed", 'yellow')

            self.print_colored("Deployment Menu", 'cyan', bold=True)
            print()
            self.print_colored("  1) Deploy with GPU        - Deploy full Edge Node with GPU support")
            self.print_colored("  2) Deploy CPU Only        - Deploy Edge Node without GPU capabilities")
            self.print_colored("  3) Delete Deployment      - Remove deployed Edge Node")
            self.print_colored("  4) Deployment Status      - Check detailed deployment status")
            print()
            self.print_colored("  0) Back to Main Menu")
            print()

            deploy_default = '2' if deployment_status in ('never_deployed', 'deleted') else '0'
            choice = self.get_input("Select option", deploy_default)

            if choice == '0':
                break
            elif choice == '1':
                self.deploy_full()
            elif choice == '2':
                self.deploy_docker_only()
            elif choice == '3':
                self.delete_edge_node()
            elif choice == '4':
                self.deployment_status()
            else:
                self.print_colored("Invalid option. Valid choices are 0-4.", 'red')
                self.wait_for_enter()

    def operations_menu(self) -> None:
        """Show operations submenu for service management and connectivity"""
        while True:
            self.print_header("Operations Menu")

            # Show if configuration exists
            has_config = self.check_hosts_config()
            if has_config:
                self.load_configuration()
                hosts = _get_gpu_hosts(self.inventory)
                self.print_section(f"Available for {len(hosts)} configured node(s)")
            else:
                self.print_section("No nodes configured")
                self.print_colored("\u26a0\ufe0f  Configure nodes first to use operations tools", 'yellow')

            self.print_colored("Operations Menu", 'cyan', bold=True)
            print()
            self.print_colored("\U0001f527 SERVICE MANAGEMENT", 'cyan', bold=True)
            self.print_colored("  1) Start Service          - Start the Edge Node service")
            self.print_colored("  2) Stop Service           - Stop the Edge Node service")
            self.print_colored("  3) Restart Service        - Restart the Edge Node service")
            print()
            self.print_colored("\U0001f4e1 CONNECTIVITY", 'cyan', bold=True)
            self.print_colored("  4) Test Connectivity      - Verify connection to configured nodes")
            print()
            self.print_colored("  0) Back to Main Menu")
            print()

            choice = self.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice in ('1', '2', '3', '4') and not has_config:
                self.print_colored("No nodes configured. Please configure nodes first (Main Menu \u2192 1).", 'red')
                self.wait_for_enter()
                continue
            elif choice == '1':
                self.start_edge_node_service()
            elif choice == '2':
                self.stop_edge_node_service()
            elif choice == '3':
                self.restart_edge_node_service()
            elif choice == '4':
                self.test_connectivity()
            else:
                self.print_colored("Invalid option. Valid choices are 0-4.", 'red')
                self.wait_for_enter()

    # -- Customize Service --

    def customize_service(self) -> None:
        """Entry point: show disclaimer then open sub-menu."""
        self.print_header("Customize Service")
        self.print_colored("⚠️  WARNING", 'red', bold=True)
        self.print_colored("This feature lets you override service template variables", 'yellow')
        self.print_colored("(e.g. Docker image, GPU flags) and re-deploy the service", 'yellow')
        self.print_colored("file WITHOUT a full site.yml run.", 'yellow')
        print()
        self.print_colored("Overrides take precedence over group_vars defaults.", 'yellow')
        self.print_colored("A collection update will auto-clear all overrides.", 'yellow')
        print()

        confirm = self.get_input("Type 'yes' to continue", "")
        if confirm.lower() != 'yes':
            self.print_colored("Cancelled.", 'yellow')
            self.wait_for_enter()
            return

        self._customize_service_menu()

    def _customize_service_menu(self) -> None:
        """Sub-menu loop for managing service overrides."""
        while True:
            self.print_header("Customize Service")

            overrides = self._get_service_overrides()
            if overrides:
                self.print_colored("📋 Active Overrides:", 'cyan', bold=True)
                for var, val in overrides.items():
                    desc = CUSTOMIZABLE_VARS.get(var, {}).get('description', var)
                    self.print_colored(f"   {desc} ({var})", 'white')
                    self.print_colored(f"     = {val}", 'green')
            else:
                self.print_colored("No active overrides — defaults from group_vars are used.", 'white')
            print()

            self.print_colored("Options:", 'cyan', bold=True)
            self.print_colored("  1) Set/Edit Override    - Pick a variable and set a new value")
            self.print_colored("  2) Remove Override      - Remove a single override")
            self.print_colored("  3) Clear All Overrides  - Reset all to defaults")
            self.print_colored("  4) Apply Overrides      - Re-template & restart on selected nodes")
            self.print_colored("  0) Back")
            print()

            choice = self.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice == '1':
                self._set_service_override()
            elif choice == '2':
                self._remove_service_override()
            elif choice == '3':
                self._clear_all_overrides()
            elif choice == '4':
                self._apply_service_overrides()
            else:
                self.print_colored("Invalid option.", 'red')
                self.wait_for_enter()

    def _set_service_override(self) -> None:
        """Pick a variable from the whitelist and enter a new value."""
        self.print_header("Set/Edit Override")

        var_names = list(CUSTOMIZABLE_VARS.keys())
        overrides = self._get_service_overrides()

        for idx, var in enumerate(var_names, 1):
            info = CUSTOMIZABLE_VARS[var]
            current = overrides.get(var)
            line = f"  {idx}) {info['description']} ({var})"
            if current is not None:
                line += f"  [current: {current}]"
            else:
                line += f"  [default: {info['default']}]"
            self.print_colored(line)
        print()

        pick = self.get_input(f"Select variable (1-{len(var_names)}, 0=cancel)", "0")
        if pick == '0':
            return
        try:
            idx = int(pick) - 1
            if idx < 0 or idx >= len(var_names):
                raise ValueError
        except ValueError:
            self.print_colored("Invalid selection.", 'red')
            self.wait_for_enter()
            return

        var = var_names[idx]
        info = CUSTOMIZABLE_VARS[var]

        self.print_colored(f"\nVariable : {var}", 'cyan')
        self.print_colored(f"Desc     : {info['description']}", 'white')
        self.print_colored(f"Example  : {info['example']}", 'white')
        current = overrides.get(var)
        if current is not None:
            self.print_colored(f"Current  : {current}", 'green')
        print()

        value = self.get_input("Enter new value (empty=cancel)")
        if not value:
            self.print_colored("Cancelled.", 'yellow')
            self.wait_for_enter()
            return

        overrides[var] = value
        self._save_service_overrides(overrides)
        self.print_colored(f"✅ Override saved: {var} = {value}", 'green')
        self.wait_for_enter()

    def _remove_service_override(self) -> None:
        """Remove a single override."""
        overrides = self._get_service_overrides()
        if not overrides:
            self.print_colored("No overrides to remove.", 'yellow')
            self.wait_for_enter()
            return

        self.print_header("Remove Override")

        keys = list(overrides.keys())
        for idx, var in enumerate(keys, 1):
            desc = CUSTOMIZABLE_VARS.get(var, {}).get('description', var)
            self.print_colored(f"  {idx}) {desc} ({var}) = {overrides[var]}")
        print()

        pick = self.get_input(f"Select override to remove (1-{len(keys)}, 0=cancel)", "0")
        if pick == '0':
            return
        try:
            idx = int(pick) - 1
            if idx < 0 or idx >= len(keys):
                raise ValueError
        except ValueError:
            self.print_colored("Invalid selection.", 'red')
            self.wait_for_enter()
            return

        var = keys[idx]
        del overrides[var]
        self._save_service_overrides(overrides)
        self.print_colored(f"✅ Override removed: {var}", 'green')
        self.wait_for_enter()

    def _clear_all_overrides(self) -> None:
        """Clear all overrides after confirmation."""
        overrides = self._get_service_overrides()
        if not overrides:
            self.print_colored("No overrides to clear.", 'yellow')
            self.wait_for_enter()
            return

        self.print_colored(f"This will remove {len(overrides)} override(s):", 'yellow')
        for var, val in overrides.items():
            self.print_colored(f"   {var} = {val}", 'white')
        print()

        confirm = self.get_input("Type 'yes' to confirm", "")
        if confirm.lower() != 'yes':
            self.print_colored("Cancelled.", 'yellow')
            self.wait_for_enter()
            return

        self._save_service_overrides({})
        self.print_colored("✅ All overrides cleared.", 'green')
        self.wait_for_enter()

    def _apply_service_overrides(self) -> None:
        """Select nodes, build ansible command with --extra-vars, run customize_service.yml."""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured!", 'red')
            self.wait_for_enter()
            return

        self.load_configuration()
        all_hosts = _get_gpu_hosts(self.inventory)
        if not all_hosts:
            self.print_colored("No hosts found in inventory.", 'red')
            self.wait_for_enter()
            return

        overrides = self._get_service_overrides()

        self.print_header("Apply Service Overrides")
        if overrides:
            self.print_colored("📋 Overrides to apply:", 'cyan', bold=True)
            for var, val in overrides.items():
                desc = CUSTOMIZABLE_VARS.get(var, {}).get('description', var)
                self.print_colored(f"   {desc}: {val}", 'green')
        else:
            self.print_colored("No overrides set — defaults from group_vars will be applied.", 'white')
        print()

        selected_host_names = self.select_hosts(all_hosts, "apply service overrides", preselect_mode='all')
        if not selected_host_names:
            self.print_colored("Cancelled — no hosts selected.", 'yellow')
            self.wait_for_enter()
            return

        self.print_colored(f"\n🖥️  Target nodes ({len(selected_host_names)}):", 'cyan', bold=True)
        for name in selected_host_names:
            config = all_hosts[name]
            ip = config.get('ansible_host', '?')
            self.print_colored(f"   • {name}: {ip}", 'white')

        if self.get_input("\nProceed? (y/n)", "y").lower() != 'y':
            self.print_colored("Cancelled.", 'yellow')
            self.wait_for_enter()
            return

        playbook_path = self.config_dir / 'playbooks/customize_service.yml'
        if not playbook_path.exists():
            self.print_colored(f"Playbook not found: {playbook_path}", 'red')
            self.wait_for_enter()
            return

        host_limit = ','.join(selected_host_names)
        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.config_file} --limit {host_limit} {playbook_path}")

        if overrides:
            extra = json.dumps(overrides)
            cmd += f" --extra-vars '{extra}'"

        self.print_colored("\nApplying service customization...", 'cyan')
        success, _ = self.run_command(cmd, show_output=True)

        if success:
            self.print_colored(f"\n✅ Service customization applied on {len(selected_host_names)} node(s).", 'green')
            self.record_service_file_version(selected_host_names)
            for host_name in selected_host_names:
                self._update_node_status(host_name, 'running')
        else:
            self.print_colored("\n❌ Customization encountered errors. Check the output above.", 'red')
            for host_name in selected_host_names:
                self._update_node_status(host_name, 'error')

        self.wait_for_enter()

    def advanced_menu(self) -> None:
        """Show advanced menu with utilities and expert tools"""
        while True:
            self.print_header("Advanced Menu")

            # Show if configuration exists
            has_config = self.check_hosts_config()
            if has_config:
                self.load_configuration()
                hosts = _get_gpu_hosts(self.inventory)
                self.print_section(f"Available for {len(hosts)} configured node(s)")
            else:
                self.print_section("No nodes configured")
                self.print_colored("⚠️  Configure nodes first to use advanced tools", 'yellow')

            self.print_colored("Advanced Menu", 'cyan', bold=True)
            print()
            self.print_colored("🔧 OPERATIONAL TOOLS", 'cyan', bold=True)
            self.print_colored("  1) SSH to Node           - Connect to node via SSH")
            self.print_colored("  2) Get Logs              - Stream logs from nodes")
            self.print_colored("  3) Write Logs to File    - Save node logs to local file")
            print()
            self.print_colored("\U0001f510 SECURITY TOOLS", 'red', bold=True)
            self.print_colored("  4) Import Private Keys   - Collect private keys from all nodes")
            self.print_colored("  5) SSH Key Management    - Install, validate, and track SSH keys")
            print()
            self.print_colored("⚙️  CUSTOMIZATION", 'cyan', bold=True)
            self.print_colored("  6) Customize Service     - Override service template variables")
            print()
            self.print_colored("  0) Back to Main Menu")
            print()

            choice = self.get_input("Select option", "0")

            if choice == '0':
                break
            elif choice in ('1', '2', '3', '4', '5', '6') and not has_config:
                self.print_colored("No nodes configured. Please configure nodes first (Main Menu \u2192 1).", 'red')
                self.wait_for_enter()
                continue
            elif choice == '1':
                self.ssh_into_node_machine()
            elif choice == '2':
                self.get_logs()
            elif choice == '3':
                self.write_logs_to_file()
            elif choice == '4':
                self.import_nodes_private_keys()
            elif choice == '5':
                self.ssh_key_management_menu()
            elif choice == '6':
                self.customize_service()
            else:
                self.print_colored("Invalid option. Valid choices are 0-6.", 'red')
                self.wait_for_enter()



    def import_nodes_private_keys(self) -> None:
        """Import private keys from all configured nodes"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("Import Nodes Private Keys")
        
        # WARNING MESSAGE
        self.print_section("⚠️  CRITICAL SECURITY WARNING")
        self.print_colored("This operation will collect private keys from all configured nodes.", 'red', bold=True)
        self.print_colored("Private keys provide full access to node wallets and should be handled with extreme care.", 'red')
        self.print_colored("Only proceed if you understand the security implications.", 'red')
        print()
        
        # Load configuration
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        
        if not hosts:
            self.print_colored("No nodes configured.", 'yellow')
            self.wait_for_enter()
            return
        
        # Show nodes that will be processed
        self.print_section(f"Target Nodes ({len(hosts)})")
        for host_name, config in hosts.items():
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            self.print_colored(f"  • {host_name}: {user}@{ip}", 'white')
        
        # Confirm operation
        print()
        self.print_colored("This will:", 'yellow', bold=True)
        self.print_colored("  1. Connect to each node via SSH", 'yellow')
        self.print_colored("  2. Retrieve the private key file: /var/cache/edge_node/_local_cache/_data/e2.pem", 'yellow')
        self.print_colored("  3. Create a local 'node_keys' folder", 'yellow')
        self.print_colored("  4. Save each key as: node_keys/{node_name}_e2.pem", 'yellow')
        print()
        
        # Final confirmation
        confirm = self.get_input("Type 'yes' to proceed", "")
        if confirm.lower() != 'yes':
            self.print_colored("Operation cancelled.", 'yellow')
            self.wait_for_enter()
            return
        
        # Create keys directory
        keys_dir = os.path.join(os.getcwd(), 'node_keys')
        try:
            os.makedirs(keys_dir, exist_ok=True)
            self.print_colored(f"Created keys directory: {keys_dir}", 'green')
        except Exception as e:
            self.print_colored(f"Failed to create keys directory: {e}", 'red')
            self.wait_for_enter()
            return
        
        # Process each node
        self.print_section("Collecting Private Keys")
        successful_imports = 0
        failed_imports = 0
        
        for host_name, config in hosts.items():
            self.print_colored(f"\n📡 Processing {host_name}...", 'cyan')
            
            # Get connection details
            ip = config.get('ansible_host')
            user = config.get('ansible_user')
            
            if not ip or not user:
                self.print_colored(f"❌ Missing connection details for {host_name}", 'red')
                failed_imports += 1
                continue
            
            # Build SSH command to retrieve the private key
            ssh_cmd = ['ssh']
            
            # Add port if specified
            ssh_port = config.get('ansible_port', 22)
            if ssh_port != 22:
                ssh_cmd.extend(['-p', str(ssh_port)])
            
            # Handle authentication
            if 'ansible_ssh_pass' in config:
                # For password authentication, we need to use sshpass
                ssh_cmd = ['sshpass', '-p', config['ansible_ssh_pass']] + ssh_cmd
            elif 'ansible_ssh_private_key_file' in config:
                key_file = config['ansible_ssh_private_key_file']
                if key_file.startswith('~'):
                    key_file = os.path.expanduser(key_file)
                ssh_cmd.extend(['-i', key_file])
            
            # Add SSH options
            ssh_cmd.extend([
                '-o', 'StrictHostKeyChecking=no',
                '-o', 'UserKnownHostsFile=/dev/null',
                '-o', f'ConnectTimeout={self.ssh_connect_timeout}',
                f"{user}@{ip}",
                'cat /var/cache/edge_node/_local_cache/_data/e2.pem'
            ])

            try:
                # Execute SSH command to get the private key
                result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=self.connection_timeout)
                
                if result.returncode == 0 and result.stdout.strip():
                    # Save the private key to file
                    key_filename = f"{host_name}_e2.pem"
                    key_filepath = os.path.join(keys_dir, key_filename)
                    
                    with open(key_filepath, 'w') as f:
                        f.write(result.stdout)
                    
                    # Set restrictive permissions on the key file
                    os.chmod(key_filepath, 0o600)
                    
                    self.print_colored(f"✅ Successfully imported key: {key_filename}", 'green')
                    successful_imports += 1
                else:
                    error_msg = result.stderr.strip() if result.stderr else "No key content received"
                    self.print_colored(f"❌ Failed to retrieve key: {error_msg}", 'red')
                    failed_imports += 1
                    
            except subprocess.TimeoutExpired:
                self.print_colored(f"❌ Connection timeout to {host_name}", 'red')
                failed_imports += 1
            except FileNotFoundError as e:
                if 'sshpass' in str(e):
                    self.print_colored(f"❌ sshpass not found - install with: sudo apt-get install sshpass", 'red')
                else:
                    self.print_colored(f"❌ SSH command failed: {e}", 'red')
                failed_imports += 1
            except Exception as e:
                self.print_colored(f"❌ Error processing {host_name}: {e}", 'red')
                failed_imports += 1
        
        # Summary
        self.print_section("Import Summary")
        self.print_colored(f"✅ Successful imports: {successful_imports}", 'green')
        self.print_colored(f"❌ Failed imports: {failed_imports}", 'red')
        self.print_colored(f"📁 Keys saved to: {keys_dir}", 'cyan')
        
        if successful_imports > 0:
            self.print_colored("\n🔐 SECURITY REMINDER:", 'red', bold=True)
            self.print_colored("  • Private keys have been saved locally", 'red')
            self.print_colored("  • Keep these files secure and delete when no longer needed", 'red')
            self.print_colored("  • These keys provide full access to node wallets", 'red')
        
        self.wait_for_enter()

    def _create_initial_configuration(self) -> None:
        """Create initial configuration"""
        self.print_section("Initial Configuration Setup")

        # First step: Get configuration name
        self.print_colored("\n📝 Configuration Naming", 'cyan', bold=True)
        self.print_colored("Give your configuration a meaningful name to identify it later.", 'yellow')
        self.print_colored("Examples: 'production-cluster', 'test-env', 'gpu-farm-1'", 'white')

        while True:
            custom_name = self.get_input("Enter configuration name (letters, numbers, -, _)", required=True)
            # Validate name (allow letters, numbers, hyphens, underscores)
            if re.match(r'^[a-zA-Z0-9_-]+$', custom_name):
                break
            self.print_colored("Invalid name. Use only letters, numbers, hyphens (-), and underscores (_)", 'red')

        # Select network environment
        env = self._select_network_environment()
        self.set_mnl_app_env(env)

        # Get number of nodes
        while True:
            try:
                num_nodes = int(self.get_input("How many nodes do you want to configure", "1"))
                if num_nodes <= 0:
                    self.print_colored("Please enter a positive number", 'red')
                    continue
                break
            except ValueError:
                self.print_colored("Please enter a valid number", 'red')

        # Generate configuration name with the custom name
        config_name = self._generate_config_name(num_nodes, custom_name)

        # Configure each node
        hosts = self.inventory['all']['children']['gpu_nodes']['hosts']
        for i in range(num_nodes):
            self.print_section(f"Configuring Node {i + 1} of {num_nodes}")
            while True:
                name = self._get_valid_hostname(f"Enter name for node {i + 1}", f"gpu-node-{i + 1}")
                if name in hosts:
                    self.print_colored(f"Node '{name}' already exists! Please choose a different name.", 'red')
                    continue
                break
            hosts[name] = self._configure_single_node()
            self.print_colored(f"Node '{name}' configured successfully!", 'green')

        # Save configuration with metadata (instead of legacy _save_configuration)
        self._save_config_with_metadata(config_name, env, num_nodes)
        self.print_colored(f"Configuration '{config_name}' created and activated!", 'green')

    def _select_network_environment(self) -> str:
        """Select network environment"""
        self.print_colored("\nNetwork Environment Options:")
        self.print_colored("  1) mainnet")
        self.print_colored("  2) testnet")
        self.print_colored("  3) devnet")

        current_env = self.get_mnl_app_env()
        if current_env:
            self.print_colored(f"Current: {current_env}", 'yellow')

        while True:
            choice = self.get_input("Select network environment (1-3)", "1")
            if choice == '1':
                return 'mainnet'
            elif choice == '2':
                return 'testnet'
            elif choice == '3':
                return 'devnet'
            else:
                self.print_colored("Invalid choice. Please enter 1, 2, or 3", 'red')

    def _configure_single_node(self, existing_config: Dict[str, Any] = None, previous_config: Dict[str, Any] = None) -> Dict[str, Any]:
        """Configure a single node"""
        while True:
            host = {}

            # "Use same credentials" shortcut for batch configuration
            if previous_config and not existing_config:
                prev_user = previous_config.get('ansible_user', '')
                prev_auth = 'SSH Key' if 'ansible_ssh_private_key_file' in previous_config else 'Password'
                self.print_colored(f"\nPrevious node: {prev_user}, {prev_auth} auth", 'cyan')
                use_same = self.get_input("Reuse these credentials for this node? (y/n)", "y")
                if use_same.lower() == 'y':
                    # Ask for IP and username (with previous as default)
                    host_address = self.get_input("Enter host (IP address or URL)", "", required=True)
                    username = self.get_input("Enter SSH username", prev_user)
                    # Copy credentials from previous config
                    host['ansible_host'] = host_address
                    host['ansible_user'] = username or prev_user
                    host['ansible_ssh_common_args'] = previous_config.get('ansible_ssh_common_args', '-o StrictHostKeyChecking=no')
                    for key in ('ansible_ssh_pass', 'ansible_become_password', 'ansible_ssh_private_key_file'):
                        if key in previous_config:
                            host[key] = previous_config[key]
                    # Set new node status
                    timestamp = datetime.now().isoformat()
                    host['node_status'] = 'never_deployed'
                    host['last_status_update'] = timestamp
                    if 'ansible_ssh_private_key_file' in host:
                        host['r1setup_ssh_auth_mode'] = SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED
                        host['r1setup_ssh_primary_key_path'] = host['ansible_ssh_private_key_file']
                        host['r1setup_ssh_requires_revalidation'] = True
                    else:
                        host['r1setup_ssh_auth_mode'] = SSH_AUTH_MODE_PASSWORD_ONLY
                        host['r1setup_ssh_primary_key_path'] = None
                        host['r1setup_ssh_requires_revalidation'] = False
                    host['r1setup_ssh_last_verification_status'] = 'not_checked'
                    host['r1setup_managed_public_keys'] = previous_config.get('r1setup_managed_public_keys', [])
                    host[SERVICE_FILE_VERSION_FIELD] = DEFAULT_SERVICE_FILE_VERSION
                    # Show summary and confirm
                    auth_type = 'SSH Key' if 'ansible_ssh_private_key_file' in host else 'Password'
                    self.print_colored("\nConfiguration Summary:", 'yellow')
                    self.print_colored(f"Host: {host['ansible_host']}")
                    self.print_colored(f"User: {host['ansible_user']}")
                    self.print_colored(f"Auth: {auth_type}")
                    if self.get_input("\nConfirm this configuration? (y/n)", "y").lower() == 'y':
                        return host
                    self.print_colored("OK, entering full configuration...", 'yellow')
                    previous_config = None  # Don't offer shortcut again
                    continue
                # If "no", fall through to normal flow with previous values as defaults

            # Get existing values for defaults
            existing_ip = existing_config.get('ansible_host', '') if existing_config else ''
            existing_user = existing_config.get('ansible_user', '') if existing_config else 'root'
            existing_auth_type = 'password' if existing_config and 'ansible_ssh_pass' in existing_config else 'key' if existing_config else None

            # Use previous config values as defaults when available
            if previous_config and not existing_config:
                existing_user = previous_config.get('ansible_user', existing_user)
                existing_auth_type = 'password' if 'ansible_ssh_pass' in previous_config else 'key'

            # Show current values if updating
            if existing_config:
                self.print_colored("\nCurrent configuration:", 'yellow')
                self.print_colored(f"Host: {existing_ip}")
                self.print_colored(f"User: {existing_user}")
                self.print_colored(f"Auth: {'Password' if existing_auth_type == 'password' else 'SSH Key'}")
                self.print_colored("\nPress Enter to keep current values, or enter new values:", 'cyan')

            # Get host (IP address or URL)
            host_prompt = f"Enter host (IP address or URL)"
            if existing_ip:
                host_prompt += f" (current: {existing_ip})"
            host_address = self.get_input(host_prompt, existing_ip, required=True)
            host['ansible_host'] = host_address

            # Get username (default: root for new nodes, previous value for subsequent)
            default_user = existing_user if existing_user else 'root'
            user_prompt = "Enter SSH username"
            if existing_config:
                user_prompt += f" (current: {default_user})"
            username = self.get_input(user_prompt, default_user)
            if not username.strip():
                self.print_colored("Username cannot be empty", 'red')
                continue
            host['ansible_user'] = username

            # Authentication method selection (with retry loop)
            auth_configured = False
            while not auth_configured:
                # Authentication method
                self.print_colored("\nAuthentication method:")
                self.print_colored("  1) Password")
                self.print_colored("  2) SSH Key")

                # Set default based on existing config
                default_auth = "1" if existing_auth_type == 'password' else "2"
                auth_prompt = "Select authentication (1/2)"
                if existing_auth_type:
                    auth_prompt += f" (current: {existing_auth_type})"

                while True:
                    auth_choice = self.get_input(auth_prompt, default_auth)
                    if auth_choice in ['1', '2']:
                        break
                    self.print_colored("Invalid choice. Please enter 1 or 2", 'red')

                if auth_choice == '1':
                    # Password authentication
                    host.pop('ansible_ssh_private_key_file', None)
                    if existing_config and 'ansible_ssh_pass' in existing_config:
                        self.print_colored("\nPassword authentication - Press Enter to keep existing passwords", 'cyan')
                        ssh_pass = self.get_secure_input("Enter SSH password (Enter to keep current)")
                        if not ssh_pass.strip():
                            # Keep existing password
                            host['ansible_ssh_pass'] = existing_config['ansible_ssh_pass']
                            host['ansible_become_password'] = existing_config.get('ansible_become_password', existing_config['ansible_ssh_pass'])
                        else:
                            # New password provided
                            host['ansible_ssh_pass'] = ssh_pass
                            self.print_colored("\nFor sudo password:", 'yellow')
                            self.print_colored("  - Enter a different password if sudo requires it", 'yellow')
                            self.print_colored("  - Press Enter to use the same SSH password", 'yellow')
                            sudo_pass = self.get_secure_input("Enter sudo password")
                            host['ansible_become_password'] = sudo_pass.strip() or ssh_pass
                    else:
                        # New password authentication
                        ssh_pass = self.get_secure_input("Enter SSH password")
                        if not ssh_pass.strip():
                            self.print_colored("SSH password cannot be empty!", 'red')
                            continue  # This will go back to authentication method selection
                        host['ansible_ssh_pass'] = ssh_pass

                        self.print_colored("\nFor sudo password:", 'yellow')
                        self.print_colored("  - Enter a different password if sudo requires it", 'yellow')
                        self.print_colored("  - Press Enter to use the same SSH password", 'yellow')
                        sudo_pass = self.get_secure_input("Enter sudo password")
                        host['ansible_become_password'] = sudo_pass.strip() or ssh_pass
                    
                    auth_configured = True
                else:
                    # Key authentication
                    if existing_config:
                        existing_key = existing_config.get('ansible_ssh_private_key_file', '~/.ssh/id_rsa')
                    elif previous_config and 'ansible_ssh_private_key_file' in previous_config:
                        existing_key = previous_config['ansible_ssh_private_key_file']
                    else:
                        existing_key = '~/.ssh/id_rsa'

                    key_auth_success = False
                    while not key_auth_success:
                        key_prompt = "Enter path to SSH private key"
                        if existing_config and 'ansible_ssh_private_key_file' in existing_config:
                            key_prompt += f" (current: {existing_key})"

                        key_path = self.get_input(key_prompt, existing_key)
                        expanded_path = os.path.expanduser(key_path)
                        
                        # Validate SSH key file
                        validation_result = self._validate_ssh_key_file(expanded_path)
                        if validation_result['valid']:
                            host['ansible_ssh_private_key_file'] = key_path
                            host.pop('ansible_ssh_pass', None)
                            
                            # Prompt for sudo password
                            if existing_config and 'ansible_become_password' in existing_config:
                                self.print_colored("\nFor sudo password:", 'yellow')
                                self.print_colored("  - Enter a new sudo password to change it", 'yellow')
                                self.print_colored("  - Press Enter to keep existing password", 'yellow')
                                sudo_pass = self.get_secure_input("Enter sudo password")
                                if sudo_pass.strip():
                                    host['ansible_become_password'] = sudo_pass.strip()
                                else:
                                    host['ansible_become_password'] = existing_config['ansible_become_password']
                            else:
                                # New configuration
                                self.print_colored("\nFor sudo password:", 'yellow')
                                self.print_colored("  - Enter a sudo password if required", 'yellow')
                                self.print_colored("  - Press Enter if sudo doesn't require a password", 'yellow')
                                sudo_pass = self.get_secure_input("Enter sudo password")
                                if sudo_pass.strip():
                                    host['ansible_become_password'] = sudo_pass.strip()
                            
                            key_auth_success = True
                            auth_configured = True
                            break
                        
                        # Show specific error message
                        self.print_colored(f"SSH key validation failed: {validation_result['error']}", 'red')
                        self.print_colored(f"Path checked: {expanded_path}", 'yellow')
                        
                        if existing_config and 'ansible_ssh_private_key_file' in existing_config:
                            retry_choice = self.get_input("Choose an option:\n  1) Try another SSH key path\n  2) Switch to password authentication\n  3) Keep existing key\nSelect option (1/2/3)", "1")
                        else:
                            retry_choice = self.get_input("Choose an option:\n  1) Try another SSH key path\n  2) Switch to password authentication\nSelect option (1/2)", "1")

                        if retry_choice == '1':
                            continue  # Try another path
                        elif retry_choice == '2':
                            self.print_colored("Switching to password authentication...", 'cyan')
                            break  # Exit SSH key loop, will go back to auth method selection
                        elif retry_choice == '3' and existing_config and 'ansible_ssh_private_key_file' in existing_config:
                            # Keep existing key configuration
                            host['ansible_ssh_private_key_file'] = existing_config['ansible_ssh_private_key_file']
                            # Also keep existing sudo password if present
                            if 'ansible_become_password' in existing_config:
                                host['ansible_become_password'] = existing_config['ansible_become_password']
                            self.print_colored("Keeping existing SSH key configuration", 'yellow')
                            key_auth_success = True
                            auth_configured = True
                            break
                        else:
                            self.print_colored("Invalid choice.", 'red')
                    
                    # If user chose to switch to password auth, continue the auth loop
                    if not key_auth_success and not auth_configured:
                        continue

            # Preserve or set SSH common args
            host['ansible_ssh_common_args'] = existing_config.get('ansible_ssh_common_args', '-o StrictHostKeyChecking=no') if existing_config else '-o StrictHostKeyChecking=no'

            # Initialize or preserve node status fields
            if existing_config:
                # Preserve existing status unless it's being updated
                host['node_status'] = existing_config.get('node_status', 'unknown')
                host['last_status_update'] = existing_config.get('last_status_update', datetime.now().isoformat())
            else:
                # New node - set initial status
                timestamp = datetime.now().isoformat()
                host['node_status'] = 'never_deployed'
                host['last_status_update'] = timestamp

            existing_managed_keys = existing_config.get('r1setup_managed_public_keys', []) if existing_config else previous_config.get('r1setup_managed_public_keys', []) if previous_config else []
            existing_key_path = existing_config.get('ansible_ssh_private_key_file') if existing_config else None
            existing_verified_fingerprint = existing_config.get('r1setup_ssh_last_verified_fingerprint') if existing_config else None
            if 'ansible_ssh_private_key_file' in host:
                same_verified_key = (
                    existing_config
                    and existing_key_path == host['ansible_ssh_private_key_file']
                    and existing_config.get('r1setup_ssh_auth_mode') == SSH_AUTH_MODE_KEY_VERIFIED
                )
                host['r1setup_ssh_auth_mode'] = (
                    SSH_AUTH_MODE_KEY_VERIFIED if same_verified_key else SSH_AUTH_MODE_KEY_INSTALLED_UNVERIFIED
                )
                host['r1setup_ssh_primary_key_path'] = host['ansible_ssh_private_key_file']
                host['r1setup_ssh_requires_revalidation'] = not same_verified_key
            else:
                host['r1setup_ssh_auth_mode'] = SSH_AUTH_MODE_PASSWORD_ONLY
                host['r1setup_ssh_primary_key_path'] = None
                host['r1setup_ssh_requires_revalidation'] = False
            host['r1setup_ssh_last_verification_status'] = (
                existing_config.get('r1setup_ssh_last_verification_status', 'not_checked')
                if existing_config else 'not_checked'
            )
            host['r1setup_ssh_key_auth_verified_at'] = (
                existing_config.get('r1setup_ssh_key_auth_verified_at')
                if existing_config and host['r1setup_ssh_auth_mode'] == SSH_AUTH_MODE_KEY_VERIFIED else None
            )
            host['r1setup_ssh_primary_key_fingerprint'] = (
                existing_config.get('r1setup_ssh_primary_key_fingerprint')
                if existing_config and 'ansible_ssh_private_key_file' in host else None
            )
            host['r1setup_ssh_last_verified_fingerprint'] = (
                existing_verified_fingerprint
                if existing_config and host['r1setup_ssh_auth_mode'] == SSH_AUTH_MODE_KEY_VERIFIED else None
            )
            host['r1setup_managed_public_keys'] = existing_managed_keys
            host[SERVICE_FILE_VERSION_FIELD] = (
                self.get_host_service_file_version(existing_config) if existing_config else DEFAULT_SERVICE_FILE_VERSION
            )

            # Show summary and confirm
            self.print_colored("\nConfiguration Summary:", 'yellow')
            self.print_colored(f"Host: {host['ansible_host']}")
            self.print_colored(f"User: {host['ansible_user']}")
            self.print_colored(f"Auth: {'Password' if auth_choice == '1' else 'SSH Key'}")

            if self.get_input("\nConfirm this configuration? (y/n)", "y").lower() == 'y':
                return host

            self.print_colored("Let's reconfigure this node...", 'yellow')

    def _add_node(self) -> None:
        """Add a new node to existing configuration"""
        self.print_section("Add New Node")
        hosts = self.inventory['all']['children']['gpu_nodes']['hosts']

        while True:
            name = self._get_valid_hostname("Enter name for the new node", "")
            if name in hosts:
                self.print_colored(f"You already have a node named '{name}'! Please choose a different name.", 'red')
                continue
            break

        # Use last existing node's config as previous_config for defaults
        last_node_config = None
        if hosts:
            last_node_name = list(hosts.keys())[-1]
            last_node_config = hosts[last_node_name]
        hosts[name] = self._configure_single_node(previous_config=last_node_config)
        self._save_configuration()
        self.print_colored(f"Node '{name}' added successfully!", 'green')
        self.print_colored("\n💡 Next: Deploy your updated configuration from Main Menu → Deployment Menu", 'cyan')

    def _update_node(self) -> None:
        """Update an existing node"""
        hosts = self.inventory['all']['children']['gpu_nodes']['hosts']
        if not hosts:
            self.print_colored("No nodes configured!", 'red')
            return

        self.print_section("Select Node to Update")
        node_list = list(hosts.keys())
        for i, name in enumerate(node_list, 1):
            ip = hosts[name].get('ansible_host', 'Unknown')
            self.print_colored(f"  {i}) {name} ({ip})")

        while True:
            try:
                choice = int(self.get_input("Select node number", "1")) - 1
                if 0 <= choice < len(node_list):
                    original_name = node_list[choice]
                    break
                self.print_colored("Invalid selection", 'red')
            except ValueError:
                self.print_colored("Please enter a number", 'red')

        self.print_colored(f"Updating node: {original_name}", 'yellow')

        # Ask if user wants to rename the node
        rename_node = self.get_input(f"\nDo you want to rename this node? Current name: '{original_name}' (y/n)", "n").lower() == 'y'

        new_name = original_name
        if rename_node:
            while True:
                new_name = self._get_valid_hostname(f"Enter new name for node (current: {original_name})", original_name)
                if new_name == original_name:
                    self.print_colored("New name is the same as current name.", 'yellow')
                    break
                if new_name in hosts:
                    self.print_colored(f"Node name '{new_name}' already exists! Please choose a different name.", 'red')
                    continue
                break

        # Update node configuration
        existing_config = hosts[original_name].copy()
        updated_config = self._configure_single_node(existing_config)

        # Handle name change
        name_changed = new_name != original_name
        if name_changed:
            # Remove old entry and add new one
            del hosts[original_name]
            hosts[new_name] = updated_config
            # Set status to pending_restart for renamed nodes
            self._update_node_status(new_name, 'pending_restart')
            self.print_colored(f"Node renamed from '{original_name}' to '{new_name}'", 'green')
        else:
            # Update existing entry
            hosts[original_name] = updated_config

        self._save_configuration()

        if name_changed:
            self.print_colored(f"Node '{original_name}' updated and renamed to '{new_name}' successfully!", 'green')
            self.print_colored(f"Status: ", 'cyan', end='')
            self._display_node_status(new_name, compact=True)
            print()  # New line after status
            self.print_colored("\n💡 Recommendation:", 'cyan', bold=True)
            self.print_colored("Since you changed the node name, the node status is now 'Pending Restart'.", 'yellow')
            self.print_colored("Use Operations Menu (Main Menu \u2192 3) \u2192 Restart Service to update the status.", 'yellow')
            self.print_colored(f"When prompted, select ONLY the renamed node '{new_name}' to avoid", 'yellow')
            self.print_colored("disturbing other running nodes. This will ensure the renamed node", 'yellow')
            self.print_colored("starts with its updated configuration.", 'white')
        else:
            self.print_colored(f"Node '{original_name}' updated successfully!", 'green')

    def _delete_node(self) -> None:
        """Delete a node"""
        hosts = self.inventory['all']['children']['gpu_nodes']['hosts']
        if not hosts:
            self.print_colored("No nodes configured!", 'red')
            return

        self.print_section("Select Node to Delete")
        node_list = list(hosts.keys())
        for i, name in enumerate(node_list, 1):
            ip = hosts[name].get('ansible_host', 'Unknown')
            self.print_colored(f"  {i}) {name} ({ip})")

        while True:
            try:
                choice = int(self.get_input("Select node number", "1")) - 1
                if 0 <= choice < len(node_list):
                    name = node_list[choice]
                    break
                self.print_colored("Invalid selection", 'red')
            except ValueError:
                self.print_colored("Please enter a number", 'red')

        ip = hosts[name].get('ansible_host', 'Unknown')
        user = hosts[name].get('ansible_user', 'Unknown')
        if self.get_input(f"Delete node '{name}' ({user}@{ip})? (y/n)", "n").lower() == 'y':
            del hosts[name]
            self._save_configuration()
            self.print_colored(f"Node '{name}' deleted successfully!", 'green')

    def _create_new_configuration(self) -> None:
        """Create completely new configuration"""
        if self.get_input("This will overwrite your current configuration. Continue? (y/n)", "n").lower() == 'y':
            # Backup existing config
            if self.config_file.exists():
                backup_dir = self.config_dir / 'hosts-history'
                backup_dir.mkdir(exist_ok=True)
                timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
                backup_file = backup_dir / f'hosts-{timestamp}.yml'
                self.config_file.rename(backup_file)
                self.print_colored(f"Configuration backed up to: {backup_file}", 'green')

            # Reset inventory
            self.inventory = {
                'all': {
                    'vars': {},
                    'children': {
                        'gpu_nodes': {
                            'hosts': {}
                        }
                    }
                }
            }
            self._create_initial_configuration()

    def view_configuration(self) -> None:
        """View current configuration"""
        self.print_header("Current Configuration")

        # Show active configuration info
        active_config_name = self.active_config.get('config_name')
        if active_config_name:
            self.print_section("Active Configuration")
            self.print_colored(f"Configuration Name: {active_config_name}", 'green')
            self.print_colored(f"Environment: {self.active_config.get('environment', 'Unknown')}", 'green')
            self.print_colored(f"Nodes Count: {self.active_config.get('nodes_count', 0)}", 'green')
            created_at = self.active_config.get('created_at')
            if created_at:
                created_str = _parse_iso_datetime(created_at) or str(created_at)
                self.print_colored(f"Created: {created_str}", 'green')
        else:
            self.print_colored("No active configuration", 'red')

        # Show network environment
        env = self.get_mnl_app_env()
        self.print_colored(f"\nCurrent Network Environment: {env if env else 'Not set'}",
                           'green' if env else 'red')
        self.print_colored(f"Current Service Template Version: {self.get_mnl_service_version()}", 'green')

        # Load and show hosts
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)

        if not hosts:
            self.print_colored("\nNo nodes configured!", 'red')
        else:
            self.print_section(f"Configured Nodes ({len(hosts)})")
            for name, config in hosts.items():
                # Get status information
                status_info = self._get_node_status_info(name)
                status = status_info['status']
                status_emoji, status_color, status_desc = self._get_status_display_info(status)
                
                self.print_colored(f"\nNode: {name} ", 'yellow', end='')
                self.print_colored(f"[{status_emoji} {status_desc}]", status_color)
                self.print_colored(f"  service_file_version: {self.get_host_service_file_version(config)}")
                
                for key, value in config.items():
                    # Skip displaying status fields in the config details
                    if key in ['node_status', 'last_status_update', SERVICE_FILE_VERSION_FIELD]:
                        continue
                    if any(k in key.lower() for k in ["password", "key"]):
                        value = "********"
                    self.print_colored(f"  {key}: {value}")

        self.wait_for_enter()

    def test_connectivity(self) -> None:
        """Test connectivity to configured nodes"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("Testing Node Connectivity")

        # Load configuration to show nodes being tested
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        env = self.get_mnl_app_env()

        # Show pre-test information
        self.print_colored(f"🔧 Connectivity Test Details:", 'cyan', bold=True)
        self.print_colored(f"   • Network: {env if env else 'Not set'}", 'green' if env else 'red')
        self.print_colored(f"   • Nodes to test: {len(hosts)}", 'white')

        self.print_colored(f"\n🖥️  Testing connectivity to:", 'cyan', bold=True)
        for name, config in hosts.items():
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            auth_type = "Password" if 'ansible_ssh_pass' in config else "SSH Key"
            self.print_colored(f"   • {name}: {user}@{ip} ({auth_type})", 'white')

        playbook_path = self.config_dir / 'playbooks/test_connection.yml'
        if not playbook_path.exists():
            self.print_colored(f"❌ Test playbook not found: {playbook_path}", 'red')
            self.wait_for_enter()
            return

        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.config_file} {playbook_path}")

        self.print_colored(f"\n🔍 Running connectivity test...", 'yellow')
        success, output = self.run_command(cmd, show_output=False, timeout=self.connection_timeout)

        # Parse the results
        connectivity_results = self._parse_connectivity_output(output)
        
        # Display formatted results
        self._display_connectivity_results(connectivity_results, hosts)

        self.wait_for_enter()

    def _display_copy_friendly_addresses(self, host_names: List[str]) -> None:
        """Display copy-friendly node addresses after successful deployment"""
        self.print_colored(f"\n🎉 Deployment Complete! Getting node addresses...", 'green', bold=True)
        
        # Get node addresses for the deployed hosts
        playbook_path = self.config_dir / 'playbooks/get_node_info.yml'
        if not playbook_path.exists():
            self.print_colored(f"Node info playbook not found: {playbook_path}", 'red')
            return

        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.config_file} {playbook_path}")

        success, output = self.run_command(cmd, show_output=False, timeout=self.connection_timeout)

        if success:
            node_results = self._parse_node_info_output(output)

            # Filter results to only show the successfully deployed nodes
            successful_deployed_nodes = []
            for host_name in host_names:
                if host_name in node_results and node_results[host_name]['status'] == 'success':
                    successful_deployed_nodes.append((host_name, node_results[host_name]))
            
            if successful_deployed_nodes:
                
                self.print_colored(f"\n📋 Your Node Addresses (Ready to Copy!):", 'green', bold=True)
                self.print_colored(f"\n Copy addresses below  and and link them to your licenses in ratio1.ai dashboard.", 'blue')
                
                self.print_colored("=" * 55, 'cyan')
                
                for i, (node_name, result) in enumerate(successful_deployed_nodes, 1):
                    eth_address = result['data'].get('eth_address', 'N/A')
                    self.print_colored(f"{i}. {node_name}", 'yellow')
                    print(f"   {eth_address}")
                    print()
                
                self.print_colored("\n💡 Tip: Double-click to select an address, then Ctrl+Shift+C (Command+Shift+C on Mac) to copy", 'cyan')
                self.print_colored("\U0001f4a1 These addresses will also be available via menu option 5", 'cyan')
            else:
                self.print_colored(f"\n\u26a0\ufe0f  Node addresses not ready yet. Use menu option 5 to check again later.", 'yellow')
        else:
            self.print_colored(f"\n\u26a0\ufe0f  Could not retrieve node addresses at this time. Use menu option 5 to check later.", 'yellow')

    def _parse_node_info_output(self, output: str) -> Dict[str, Dict[str, Any]]:
        """Parse node info output to extract both successful and failed nodes"""
        node_results = {}
        import re
        
        # First, detect unreachable nodes
        unreachable_pattern = r'fatal: \[([^\]]+)\]: UNREACHABLE!'
        unreachable_matches = re.findall(unreachable_pattern, output)
        
        for node_name in unreachable_matches:
            node_results[node_name] = {
                'status': 'unreachable',
                'data': None
            }
            self.print_debug(f"Found unreachable node: {node_name}")
        
        # Also check for other unreachable patterns
        unreachable_pattern2 = r'unreachable: \[([^\]]+)\]'
        unreachable_matches2 = re.findall(unreachable_pattern2, output)
        
        for node_name in unreachable_matches2:
            if node_name not in node_results:
                node_results[node_name] = {
                    'status': 'unreachable',
                    'data': None
                }
                self.print_debug(f"Found unreachable node (pattern2): {node_name}")
        
        # Now parse successful nodes (existing logic from _get_node_info_data)
        result_blocks = re.split(r'(?=ok: \[[^\]]+\] => \{)', output)
        
        for block in result_blocks:
            if not block.strip():
                continue
                
            # Extract node name from the block
            node_match = re.search(r'ok: \[([^\]]+)\] => \{', block)
            if not node_match:
                continue
                
            node_name = node_match.group(1)
            self.print_debug(f"Processing successful node: {node_name}")
            
            # Check if this block contains node_info.stdout_lines
            if '"node_info.stdout_lines":' not in block:
                self.print_debug(f"No node_info.stdout_lines found for {node_name}")
                continue
            
            try:
                # Extract the JSON lines from stdout_lines array
                stdout_start = block.find('"node_info.stdout_lines":')
                if stdout_start == -1:
                    continue
                
                # Find the opening bracket for the array
                array_start = block.find('[', stdout_start)
                if array_start == -1:
                    continue
                
                # Count brackets to find the matching closing bracket
                bracket_count = 0
                array_end = array_start
                for i in range(array_start, len(block)):
                    if block[i] == '[':
                        bracket_count += 1
                    elif block[i] == ']':
                        bracket_count -= 1
                        if bracket_count == 0:
                            array_end = i
                            break
                
                if bracket_count != 0:
                    continue
                
                # Extract the array content
                array_content = block[array_start+1:array_end]
                
                # Extract all quoted strings from the array content using regex
                # Pattern to match quoted strings, handling escaped quotes
                quoted_pattern = r'"([^"\\]*(?:\\.[^"\\]*)*)"'
                quoted_matches = re.findall(quoted_pattern, array_content)
                
                # Reconstruct the JSON string
                json_str = ''
                for line in quoted_matches:
                    # Unescape the quotes and add to JSON string
                    unescaped_line = line.replace('\\"', '"')
                    json_str += unescaped_line + '\n'
                
                # Parse the JSON
                if json_str.strip():
                    node_data = json.loads(json_str.strip())
                    node_results[node_name] = {
                        'status': 'success',
                        'data': node_data
                    }
                    self.print_debug(f"Successfully parsed JSON for {node_name}")
                    
            except (json.JSONDecodeError, Exception) as e:
                self.print_debug(f"Failed to parse JSON for {node_name}: {e}")
                # If parsing fails, mark as error but don't overwrite unreachable status
                if node_name not in node_results:
                    node_results[node_name] = {
                        'status': 'error',
                        'data': None
                    }
        
        return node_results


    def _parse_node_info_line_by_line(self, output: str) -> Dict[str, Dict[str, Any]]:
        """Fallback method to parse node info line by line"""
        node_info = {}
        lines = output.split('\n')
        
        i = 0
        while i < len(lines):
            line = lines[i].strip()
            
            # Look for node output blocks
            if line.startswith('ok: [') and '] => {' in line:
                # Extract node name
                start = line.find('[') + 1
                end = line.find(']')
                if start > 0 and end > start:
                    node_name = line[start:end]
                    self.print_debug(f"Line-by-line parsing for node: {node_name}")
                    
                    # Find the JSON block for this node
                    json_lines = []
                    i += 1
                    
                    # Look for the start of node_info.stdout_lines
                    while i < len(lines) and 'node_info.stdout_lines' not in lines[i]:
                        i += 1
                    
                    if i < len(lines):
                        self.print_debug(f"Found stdout_lines at line {i}")
                        i += 1  # Skip the stdout_lines line
                        
                        # Collect JSON lines until we hit the end
                        json_started = False
                        while i < len(lines):
                            current_line = lines[i].strip()
                            
                            # Stop if we hit the end of the array or next node
                            if current_line == ']' or current_line == '}' or current_line.startswith('ok: ['):
                                break
                            
                            # Skip empty lines and array markers
                            if not current_line or current_line == '[':
                                i += 1
                                continue
                            
                            # Clean up the JSON line - handle various formats
                            if current_line.startswith('"') and (current_line.endswith('",') or current_line.endswith('"')):
                                # Remove quotes and trailing comma
                                clean_line = current_line[1:]
                                if clean_line.endswith('",'):
                                    clean_line = clean_line[:-2]
                                elif clean_line.endswith('"'):
                                    clean_line = clean_line[:-1]
                                
                                # Unescape quotes
                                clean_line = clean_line.replace('\\"', '"')
                                json_lines.append(clean_line)
                                json_started = True
                            
                            i += 1
                        
                        # Try to parse the collected JSON
                        if json_lines:
                            try:
                                json_str = '\n'.join(json_lines)
                                self.print_debug(f"Line-by-line JSON for {node_name}: {json_str[:200]}...")
                                node_data = json.loads(json_str)
                                node_info[node_name] = node_data
                                self.print_debug(f"Line-by-line parsing successful for {node_name}")
                            except json.JSONDecodeError as e:
                                self.print_debug(f"Line-by-line JSON parsing failed for {node_name}: {e}")
                        else:
                            self.print_debug(f"No JSON lines collected for {node_name}")
            else:
                i += 1
        
        self.print_debug(f"Line-by-line parsing result: {len(node_info)} nodes found")
        return node_info

    def node_addresses_and_export(self) -> None:
        """Get and display node addresses, with optional CSV export"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("Node Addresses & Export")

        playbook_path = self.config_dir / 'playbooks/get_node_info.yml'
        if not playbook_path.exists():
            self.print_colored(f"Node info playbook not found: {playbook_path}", 'red')
            self.wait_for_enter()
            return

        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.config_file} {playbook_path}")

        self.print_colored("Retrieving node addresses...", 'yellow')

        success, output = self.run_command(cmd, show_output=False, timeout=self.connection_timeout * 2)

        # Check for timeout specifically
        if not success and "timed out" in output.lower():
            self.print_colored(f"Node address retrieval timed out after {self.connection_timeout * 2} seconds", 'red')
            self.print_colored("Some nodes may be offline or not responding", 'yellow')
            self.wait_for_enter()
            return

        # Parse the output to extract both successful and failed nodes
        # Note: We parse even if success=False because some nodes might have succeeded
        node_results = self._parse_node_info_output(output)

        if not node_results:
            self.print_colored("No node information could be parsed from the playbook output.", 'yellow')
            self.print_colored("This might be because the nodes are not running or not accessible.", 'yellow')
            self.wait_for_enter()
            return

        # Display the addresses in a formatted table
        self.print_colored(f"\nNode Addresses Summary:", 'green')
        self.print_colored(f"{'Node Name':<20} {'ETH Address':<48} {'Status':<15}", 'cyan')
        self.print_colored("-" * 83, 'cyan')

        success_count = 0
        unreachable_count = 0

        for node_name, result in node_results.items():
            if result['status'] == 'success':
                address = result['data'].get('address', 'N/A')
                eth_address = result['data'].get('eth_address', 'N/A')
                self.print_colored(f"{node_name:<20} {eth_address:<48} {'SUCCESS':<15}", 'green')
                success_count += 1
            else:
                self.print_colored(f"{node_name:<20} {'N/A':<48} {'UNREACHABLE':<15}", 'red')
                unreachable_count += 1

        # Show summary
        if success_count > 0 and unreachable_count > 0:
            self.print_colored(f"\n\u2705 {success_count} node(s) retrieved successfully, \u274c {unreachable_count} node(s) unreachable", 'yellow')
        elif success_count > 0:
            self.print_colored(f"\n\u2705 All {success_count} node(s) retrieved successfully", 'green')
        else:
            self.print_colored(f"\n\u274c All {unreachable_count} node(s) are unreachable", 'red')

        # Offer CSV export
        print()
        export_choice = self.get_input("Export to CSV? (y/N)", "N")
        if export_choice.lower() == 'y':
            self._write_addresses_csv(node_results)

        self.wait_for_enter()

    def _write_addresses_csv(self, node_results: dict) -> None:
        """Write pre-fetched node results to a CSV file"""
        success_count = sum(1 for result in node_results.values() if result['status'] == 'success')
        unreachable_count = sum(1 for result in node_results.values() if result['status'] == 'unreachable')

        self.print_colored(f"Found information for {len(node_results)} node(s) (\u2705 {success_count} successful, \u274c {unreachable_count} unreachable)", 'green')

        default_dir = os.getcwd()
        self.print_colored(f"Default export directory: {default_dir}", 'cyan')
        export_dir = self.get_input("Export directory (Enter to use default)", default_dir)
        export_dir = os.path.expanduser(export_dir.strip())
        if not os.path.isdir(export_dir):
            self.print_colored(f"Directory does not exist: {export_dir}", 'red')
            return

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        csv_file = os.path.join(export_dir, f"node_addresses_{timestamp}.csv")

        try:
            with open(csv_file, 'w') as f:
                f.write("Node_Name,Status,ETH_Address,SSH_Host,SSH_User,Address\n")

                # Load configuration to get SSH details
                self.load_configuration()
                hosts = _get_gpu_hosts(self.inventory)

                for node_name, result in node_results.items():
                    # Get SSH info from configuration
                    ssh_host = 'N/A'
                    ssh_user = 'N/A'
                    if node_name in hosts:
                        ssh_host = hosts[node_name].get('ansible_host', 'N/A')
                        ssh_user = hosts[node_name].get('ansible_user', 'N/A')

                    if result['status'] == 'success':
                        info = result['data']
                        alias = info.get('alias', node_name)
                        address = info.get('address', 'N/A')
                        eth_address = info.get('eth_address', 'N/A')
                        f.write(f'"{node_name}","SUCCESS","{eth_address}","{ssh_host}","{ssh_user}","{address}"\n')
                    else:
                        f.write(f'"{node_name}","UNREACHABLE","N/A","{ssh_host}","{ssh_user}","N/A"\n')

            self.print_colored(f"Addresses exported to: {csv_file}", 'green')
            self.print_colored(f"CSV contains: Node Name, Status, ETH Address, SSH Host, SSH User, Address", 'cyan')
            if success_count > 0 and unreachable_count > 0:
                self.print_colored(f"Note: {success_count} nodes have valid addresses, {unreachable_count} nodes are marked as unreachable", 'yellow')
        except Exception as e:
            self.print_colored(f"Error exporting to CSV: {e}", 'red')

    def change_network_environment(self) -> None:
        """Change the network environment"""
        self.print_header("Change Network Environment")

        current_env = self.get_mnl_app_env()
        if current_env:
            self.print_colored(f"Current environment: {current_env}", 'yellow')

        env = self._select_network_environment()
        self.set_mnl_app_env(env)
        self.print_colored(f"Network environment changed to: {env}", 'green')

        self.wait_for_enter()

    def _parse_connectivity_output(self, output: str) -> Dict[str, Dict[str, Any]]:
        """Parse ansible connectivity test output to extract connection results"""
        node_results = {}
        
        try:
            lines = output.split('\n')
            self.print_debug(f"Parsing connectivity output with {len(lines)} lines")
            
            # Look for PLAY RECAP section which contains the summary
            recap_started = False
            for line in lines:
                line = line.strip()
                
                if 'PLAY RECAP' in line:
                    recap_started = True
                    continue
                
                if recap_started and line:
                    # Parse lines like: "node-name : ok=5 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0"
                    if ':' in line and 'ok=' in line:
                        parts = line.split(':', 1)
                        if len(parts) == 2:
                            node_name = parts[0].strip()
                            stats = parts[1].strip()
                            
                            # Parse the statistics
                            stats_dict = {}
                            for stat_pair in stats.split():
                                if '=' in stat_pair:
                                    key, value = stat_pair.split('=', 1)
                                    try:
                                        stats_dict[key] = int(value)
                                    except ValueError:
                                        stats_dict[key] = value
                            
                            # Determine connection status
                            if stats_dict.get('unreachable', 0) > 0:
                                status = 'unreachable'
                                message = 'Connection failed - node is unreachable'
                            elif stats_dict.get('failed', 0) > 0:
                                status = 'failed'
                                message = 'Connection failed - authentication or other error'
                            elif stats_dict.get('ok', 0) > 0:
                                status = 'connected'
                                message = 'Connection successful'
                            else:
                                status = 'unknown'
                                message = 'Unknown connection status'
                            
                            node_results[node_name] = {
                                'status': status,
                                'message': message,
                                'stats': stats_dict
                            }
                            
                            self.print_debug(f"Parsed {node_name}: {status} - {message}")
                            
        except Exception as e:
            self.print_debug(f"Error parsing connectivity output: {e}")
            
        self.print_debug(f"Final connectivity results: {node_results}")
        return node_results

    def _display_connectivity_results(self, connectivity_results: Dict[str, Dict[str, Any]], hosts: Dict[str, Dict[str, Any]]) -> None:
        """Display formatted connectivity test results"""
        
        if not connectivity_results:
            self.print_colored("\n❌ No connectivity results could be parsed from the test output.", 'red')
            self.print_colored("   This might indicate a configuration or network issue.", 'yellow')
            return
        
        # Count results by status
        connected_count = sum(1 for result in connectivity_results.values() if result['status'] == 'connected')
        unreachable_count = sum(1 for result in connectivity_results.values() if result['status'] == 'unreachable')
        failed_count = sum(1 for result in connectivity_results.values() if result['status'] == 'failed')
        total_count = len(connectivity_results)
        
        # Display overall summary
        self.print_colored(f"\n📊 Connectivity Test Results", 'cyan', bold=True)
        self.print_colored(f"   • Total nodes tested: {total_count}", 'white')
        self.print_colored(f"   • Connected: {connected_count}", 'green' if connected_count > 0 else 'white')
        self.print_colored(f"   • Unreachable: {unreachable_count}", 'red' if unreachable_count > 0 else 'white')
        self.print_colored(f"   • Failed: {failed_count}", 'red' if failed_count > 0 else 'white')
        
        # Display detailed results for each node
        self.print_colored(f"\n🔍 Detailed Node Results:", 'cyan', bold=True)
        
        for node_name, result in connectivity_results.items():
            status = result['status']
            message = result['message']
            stats = result.get('stats', {})
            
            # Get node configuration details
            node_config = hosts.get(node_name, {})
            ip = node_config.get('ansible_host', 'Unknown')
            user = node_config.get('ansible_user', 'Unknown')
            
            # Choose appropriate emoji and color
            if status == 'connected':
                emoji = '✅'
                color = 'green'
            elif status == 'unreachable':
                emoji = '🔴'
                color = 'red'
            elif status == 'failed':
                emoji = '❌'
                color = 'red'
            else:
                emoji = '❓'
                color = 'yellow'
            
            # Display node result
            self.print_colored(f"\n   {emoji} {node_name} ({user}@{ip})", color, bold=True)
            self.print_colored(f"      Status: {message}", color)
            
            # Show statistics if available
            if stats:
                ok_count = stats.get('ok', 0)
                changed_count = stats.get('changed', 0)
                unreachable_count = stats.get('unreachable', 0)
                failed_count = stats.get('failed', 0)
                
                self.print_colored(f"      Tasks: {ok_count} successful, {changed_count} changed, {unreachable_count} unreachable, {failed_count} failed", 'white')
        
        # Overall result and recommendations
        if connected_count == total_count:
            self.print_colored(f"\n✅ All {total_count} node(s) are reachable and ready for deployment!", 'green', bold=True)
        elif connected_count > 0:
            self.print_colored(f"\n⚠️  {connected_count} of {total_count} node(s) are reachable", 'yellow', bold=True)
            self.print_colored("   Some nodes have connectivity issues that need to be resolved.", 'yellow')
        else:
            self.print_colored(f"\n❌ All {total_count} node(s) are unreachable", 'red', bold=True)
            self.print_colored("   Please check your network configuration and node settings.", 'red')
        
        # Show troubleshooting tips if there are issues
        if unreachable_count > 0 or failed_count > 0:
            self.print_colored(f"\n💡 Troubleshooting Tips:", 'cyan', bold=True)
            self.print_colored("   • Verify network connectivity (ping the IP addresses)", 'white')
            self.print_colored("   • Check SSH credentials and authentication method", 'white')
            self.print_colored("   • Ensure SSH service is running on target nodes", 'white')
            self.print_colored("   • Verify firewall settings allow SSH connections", 'white')
            self.print_colored("   • Use option 1 → 3 to view and verify your configuration", 'white')
            self.print_colored("   \u2022 Use option 7 \u2192 1 to test SSH connection manually", 'white')

    def ssh_into_node_machine(self) -> None:
        """SSH into a selected node's machine"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("SSH Into Node's Machine")

        # Load configuration
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        env = self.get_mnl_app_env()

        # Show SSH connection details
        self.print_colored(f"🔧 SSH Connection Details:", 'cyan', bold=True)
        self.print_colored(f"   • Network: {env if env else 'Not set'}", 'green' if env else 'red')
        self.print_colored(f"   • Available Nodes: {len(hosts)}", 'white')

        self.print_colored(f"\n🖥️  Available Machines:", 'cyan', bold=True)
        for name, config in hosts.items():
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            auth_type = "Password" if 'ansible_ssh_pass' in config else "SSH Key"
            status_info = self._get_node_status_info(name)
            status = status_info['status']
            last_update = status_info['last_update']
            emoji, color, description = self._get_status_display_info(status)
            last_update_str = self._format_timestamp_ago(last_update)
            self.print_colored(f"   • {name}: {user}@{ip} ", 'white', end='')
            self.print_colored(f"[{emoji} {description}]", color, end='')
            self.print_colored(f" ({auth_type}) (Last updated: {last_update_str})", 'white')

        self.print_colored(f"\n📋 This will:", 'yellow', bold=True)
        self.print_colored("   • Connect you directly to the selected node via SSH", 'yellow')
        self.print_colored("   • Use the same connection details as configured for deployment", 'yellow')
        self.print_colored("   • Return you to r1setup when you exit the SSH session", 'yellow')
        self.print_colored("   • Type 'exit' in the SSH session to return here", 'yellow')

        # Node selection for SSH - only allow single selection
        print(f"\n🔍 Select a node's machine to SSH into:")
        host_list = list(hosts.keys())
        
        for i, host_name in enumerate(host_list, 1):
            config = hosts[host_name]
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            status_info = self._get_node_status_info(host_name)
            status = status_info['status']
            last_update = status_info['last_update']
            emoji, color, description = self._get_status_display_info(status)
            last_update_str = self._format_timestamp_ago(last_update)
            
            self.print_colored(f"  {i}) {host_name}", 'cyan')
            self.print_colored(f"     └─ {user}@{ip} [{emoji} {description}] (Last updated: {last_update_str})", 'white')
        
        self.print_colored(f"  0) Cancel")

        while True:
            try:
                choice = input(f"\nSelect node (1-{len(host_list)}, 0 to cancel): ").strip()
                
                if choice == '0':
                    self.print_colored("SSH connection cancelled.", 'yellow')
                    return
                
                choice_num = int(choice)
                if 1 <= choice_num <= len(host_list):
                    selected_host = host_list[choice_num - 1]
                    break
                else:
                    self.print_colored(f"Please enter a number between 1 and {len(host_list)}, or 0 to cancel.", 'red')
            except ValueError:
                self.print_colored("Please enter a valid number.", 'red')

        # Get connection details for selected host
        config = hosts[selected_host]
        ip = config.get('ansible_host')
        user = config.get('ansible_user')
        ssh_port = config.get('ansible_port', 22)
        
        if not ip or not user:
            self.print_colored(f"❌ Missing connection details for {selected_host}. Please reconfigure the node.", 'red')
            self.wait_for_enter()
            return

        # Build SSH command
        ssh_cmd = ['ssh']
        
        # Add port if not default
        if ssh_port != 22:
            ssh_cmd.extend(['-p', str(ssh_port)])
        
        # Handle SSH key vs password authentication
        if 'ansible_ssh_pass' in config:
            # Password authentication - warn user
            self.print_colored(f"\n⚠️  Password Authentication Required:", 'yellow', bold=True)
            self.print_colored(f"   • This node uses password authentication", 'yellow')
            self.print_colored(f"   • You'll be prompted for the password when connecting", 'yellow')
            password = config.get('ansible_ssh_pass')
            # We can't easily pass password to SSH, so just inform user
            self.print_colored(f"   • Use the same password configured for this node", 'white')
        else:
            # SSH key authentication
            if 'ansible_ssh_private_key_file' in config:
                key_file = config['ansible_ssh_private_key_file']
                # Expand user path if needed
                if key_file.startswith('~'):
                    key_file = os.path.expanduser(key_file)
                ssh_cmd.extend(['-i', key_file])
                self.print_colored(f"\n🔑 Using SSH key: {key_file}", 'green')
            else:
                self.print_colored(f"\n🔑 Using default SSH key authentication", 'green')
        
        # Add any additional SSH options for better connectivity
        ssh_cmd.extend([
            '-o', 'StrictHostKeyChecking=no',  # Don't prompt for host key verification
            '-o', 'UserKnownHostsFile=/dev/null',  # Don't save host keys
            '-o', f'ConnectTimeout={self.ssh_connect_timeout}',
        ])
        
        # Add user@host
        ssh_cmd.append(f"{user}@{ip}")

        self.print_colored(f"\n🚀 Connecting to {selected_host} ({user}@{ip})...", 'cyan', bold=True)
        self.print_colored("   Type 'exit' to return to r1setup", 'white')
        self.wait_for_enter()

        try:
            # Execute SSH command
            self.print_colored(f"Executing: {' '.join(ssh_cmd[:4])} ... {ssh_cmd[-1]}", 'cyan')
            
            # Use subprocess.run to execute SSH interactively
            result = subprocess.run(ssh_cmd)
            
            # When SSH exits, we return here
            self.print_colored(f"\n✅ SSH session to {selected_host} ended.", 'green')
            self.print_colored("Returning to r1setup...", 'cyan')
            
        except KeyboardInterrupt:
            self.print_colored(f"\n🛑 SSH connection interrupted.", 'yellow')
        except Exception as e:
            self.print_colored(f"\n❌ SSH connection failed: {e}", 'red')
            self.print_colored("Common issues:", 'yellow')
            self.print_colored("   • Network connectivity problems", 'white')
            self.print_colored("   • Incorrect SSH credentials", 'white')
            self.print_colored("   • Firewall blocking SSH port", 'white')
            self.print_colored("   • Node is not reachable", 'white')
        
        self.wait_for_enter()

    def start_edge_node_service(self) -> None:
        """Start Edge Node on all configured nodes"""
        self._manage_service("service_start.yml", "Start Edge Node", "🚀 Starting Edge Node")

    def stop_edge_node_service(self) -> None:
        """Stop Edge Node on all configured nodes"""
        self._manage_service("service_stop.yml", "Stop Edge Node", "🛑 Stopping Edge Node")

    def restart_edge_node_service(self) -> None:
        """Restart Edge Node on all configured nodes"""
        self._manage_service("service_restart.yml", "Restart Edge Node", "🔄 Restarting Edge Node")


    def _auto_update_check(self):
        return self.version_manager._auto_update_check()

    def ensure_active_configuration(self) -> bool:
        """Ensure there's an active configuration before proceeding to main menu"""
        # Check if we have a valid active configuration
        if self.check_hosts_config():
            return True

        self.print_header("Configuration Required")
        self.print_colored("⚠️  No active configuration detected!", 'red', bold=True)

        # Check if we have saved configurations
        configs = self._list_available_configs()

        if configs:
            # If there's only one configuration, automatically select it
            if len(configs) == 1:
                config_name, metadata = configs[0]
                display_name = config_name.replace('.yml', '')

                # Extract custom name for display
                custom_name = display_name
                if '_' in display_name:
                    parts = display_name.split('_')
                    if len(parts) >= 2:
                        # Find where the timestamp starts (8 digits)
                        for idx, part in enumerate(parts):
                            if len(part) == 8 and part.isdigit():
                                custom_name = '_'.join(parts[:idx])
                                break

                env = metadata.get('environment', 'unknown')
                nodes = metadata.get('nodes_count', 0)

                self.print_colored(f"\n📁 Found 1 saved configuration: {custom_name} ({env}, {nodes} node(s))", 'cyan')
                self.print_colored("Automatically activating the only available configuration...", 'yellow')

                if self._load_config_by_name(display_name):
                    self.print_colored(f"✅ Successfully activated configuration: {custom_name}", 'green')
                    self.print_colored("Proceeding to main menu...", 'green')
                    self.wait_for_enter()
                    return True
                else:
                    self.print_colored(f"❌ Failed to activate configuration: {custom_name}", 'red')
                    self.print_colored("The configuration file may be corrupted. Please create a new one.", 'red')
                    self._create_new_configuration_with_management()
                    return True

            # Multiple configurations - show selection menu
            self.print_colored(f"\n📁 Found {len(configs)} saved configuration(s):", 'cyan')
            self.print_colored("It looks like you have existing configurations but none are currently active.", 'yellow')
            self.print_colored("This can happen after reinstalling or updating r1setup.", 'yellow')

            # Show available configurations
            for i, (config_name, metadata) in enumerate(configs, 1):
                display_name = config_name.replace('.yml', '')
                env = metadata.get('environment', 'unknown')
                nodes = metadata.get('nodes_count', 0)
                created_at = metadata.get('created_at')
                last_deployed_date = metadata.get('last_deployed_date')
                deployment_status = metadata.get('deployment_status', 'never_deployed')

                # Extract custom name from the config name
                custom_name = display_name
                if '_' in display_name:
                    parts = display_name.split('_')
                    if len(parts) >= 2:
                        # Find where the timestamp starts (8 digits)
                        for idx, part in enumerate(parts):
                            if len(part) == 8 and part.isdigit():
                                custom_name = '_'.join(parts[:idx])
                                break

                # Format creation date
                created_str = _parse_iso_datetime(created_at) or "Unknown"

                # Format deployment status
                deployment_str = ""
                if deployment_status == 'deployed' and last_deployed_date:
                    deployed_str = _parse_iso_datetime(last_deployed_date)
                    if deployed_str:
                        deployment_str = f" | 🚀 Last deployed: {deployed_str}"
                    else:
                        deployment_str = " | 🚀 Deployed"
                elif deployment_status == 'deleted':
                    deployment_str = " | 🗑️ Deleted"
                else:
                    deployment_str = " | 📋 Never deployed"

                self.print_colored(f"  {i}. {custom_name}", 'cyan', bold=True)
                self.print_colored(f"     {env} | {nodes} node(s) | Created: {created_str}{deployment_str}", 'white')

            while True:
                self.print_colored("\n🔧 What would you like to do?", 'cyan', bold=True)
                self.print_colored("  1) Select an existing configuration to activate")
                self.print_colored("  2) Create a new configuration")
                self.print_colored("  3) Import configuration from .r1config file")
                print()
                self.print_colored("  0) Exit")

                choice = self.get_input("\nSelect option (0-3)", "1")

                if choice == '0':
                    self.print_colored("Exiting r1setup.", 'yellow')
                    return False
                elif choice == '1':
                    # Let user select from existing configurations
                    while True:
                        try:
                            selection = int(self.get_input(f"Select configuration number (1-{len(configs)})", "1")) - 1
                            if 0 <= selection < len(configs):
                                selected_config = configs[selection][0].replace('.yml', '')
                                break
                            self.print_colored("Invalid selection", 'red')
                        except ValueError:
                            self.print_colored("Please enter a number", 'red')

                    if self._load_config_by_name(selected_config):
                        self.print_colored(f"✅ Successfully activated configuration: {selected_config}", 'green')
                        self.print_colored("You can now access the main menu.", 'green')
                        self.wait_for_enter()
                        return True
                    else:
                        self.print_colored(f"❌ Failed to activate configuration: {selected_config}", 'red')
                        self.print_colored("Please try another configuration or create a new one.", 'red')
                        self.wait_for_enter()
                        continue
                elif choice == '2':
                    # Create new configuration
                    self._create_new_configuration_with_management()
                    return True
                elif choice == '3':
                    # Import configuration
                    self._import_configuration()
                    # Check if import was successful by seeing if we now have an active config
                    if self.check_hosts_config():
                        self.print_colored("You can now access the main menu.", 'green')
                        self.wait_for_enter()
                        return True
                    else:
                        self.print_colored("Import was cancelled or failed. Please try again.", 'yellow')
                        self.wait_for_enter()
                        continue
                else:
                    self.print_colored("Invalid option. Please enter 0, 1, 2, or 3.", 'red')
        else:
            # No saved configurations exist
            self.print_colored("\n📝 No configurations found!", 'yellow')
            self.print_colored("You need to create your first configuration to use r1setup.", 'white')
            self.print_colored("A configuration contains your GPU node connection details and network settings.", 'white')

            while True:
                self.print_colored("\n🔧 What would you like to do?", 'cyan', bold=True)
                self.print_colored("  1) Create your first configuration")
                self.print_colored("  2) Import configuration from .r1config file")
                print()
                self.print_colored("  0) Exit")

                choice = self.get_input("\nSelect option (0-2)", "1")

                if choice == '0':
                    self.print_colored("Exiting r1setup.", 'yellow')
                    return False
                elif choice == '1':
                    self._create_new_configuration_with_management()
                    return True
                elif choice == '2':
                    # Import configuration for first-time users
                    self._import_configuration()
                    # Check if import was successful by seeing if we now have an active config
                    if self.check_hosts_config():
                        self.print_colored("Welcome to r1setup! Your configuration has been imported successfully.", 'green')
                        self.print_colored("You can now access the main menu.", 'green')
                        self.wait_for_enter()
                        return True
                    else:
                        self.print_colored("Import was cancelled or failed. Please try again.", 'yellow')
                        self.wait_for_enter()
                        continue
                else:
                    self.print_colored("Invalid option. Please enter 0, 1, or 2.", 'red')

    def run(self) -> None:
        """Main program loop"""
        # Handle command line arguments
        global DEBUG
        if len(sys.argv) > 1:
            if sys.argv[1] == '--version':
                print(f"r1setup version {CLI_VERSION}")
                sys.exit(0)
            elif sys.argv[1] == '--debug':
                DEBUG = True
                self.print_colored("Debug mode enabled", 'yellow')

        # Check prerequisites
        if not self.check_ansible_installation():
            self.print_colored("Please ensure Ansible and the required collection are installed.", 'red')
            sys.exit(1)

        # Auto-update check - this runs first before everything else
        self._auto_update_check()

        # Add SSH metadata for legacy configurations without changing active auth.
        self.migrate_legacy_ssh_metadata()

        # Ensure we have an active configuration before proceeding
        if not self.ensure_active_configuration():
            sys.exit(0)

        # One-time initial node status refresh on startup
        if self.check_hosts_config() and self.settings_manager.should_refresh_status():
            self.load_configuration()
            print("  Refreshing node statuses...", end='\r')
            node_status_data = self._get_real_time_node_status()
            for node_name, status_data in node_status_data.items():
                self._update_node_status(node_name, status_data['status'])
            self.settings_manager.mark_status_refreshed()
            print("                              ", end='\r')

        while True:
            try:
                self.show_main_menu()
                choice = self.get_input("Select option (0-7)", "0")

                if choice == '0':
                    self.print_colored("Thank you for using Ratio1 Multi-Node Launcher Setup!", 'green')
                    break
                elif choice == '1':
                    self.configuration_menu()
                elif choice == '2':
                    self.deployment_menu()
                elif choice == '3':
                    self.operations_menu()
                elif choice == '4':
                    self.combined_node_status_and_info()
                elif choice == '5':
                    self.node_addresses_and_export()
                elif choice == '6':
                    self.settings_menu()
                elif choice == '7':
                    self.advanced_menu()
                else:
                    self.print_colored("Invalid option. Valid choices are 0-7.", 'red')
                    self.wait_for_enter()

            except KeyboardInterrupt:
                self.print_colored("\n\nOperation cancelled by user.", 'yellow')
                break
            except Exception as e:
                self.print_colored(f"An error occurred: {e}", 'red')
                self.wait_for_enter()

    def switch_environment(self) -> None:
        """Switch network environment (wrapper for change_network_environment)"""
        self.change_network_environment()

    def get_logs(self) -> None:
        """Stream logs from selected nodes"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("Get Node Logs")
        
        # Load configuration
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        
        if not hosts:
            self.print_colored("No nodes configured.", 'yellow')
            self.wait_for_enter()
            return
        
        # Show available nodes
        self.print_section(f"Available Nodes ({len(hosts)})")
        host_list = list(hosts.keys())
        
        for i, host_name in enumerate(host_list, 1):
            host_config = hosts[host_name]
            ip = host_config.get('ansible_host', 'Unknown')
            user = host_config.get('ansible_user', 'Unknown')
            
            # Get status information
            status_info = self._get_node_status_info(host_name)
            status = status_info['status']
            emoji, color, description = self._get_status_display_info(status)
            
            self.print_colored(f"  {i}. {host_name} ({user}@{ip}) ", 'white', end='')
            self.print_colored(f"[{emoji} {description}]", color)
        
        print()
        self.print_colored("  0) Return to main menu")
        print()
        
        while True:
            choice = self.get_input("Select a node to view logs (0 to return)", "0")
            
            if choice == '0':
                return
            
            try:
                node_index = int(choice) - 1
                if 0 <= node_index < len(host_list):
                    selected_node = host_list[node_index]
                    self._stream_node_logs(selected_node)
                    break
                else:
                    self.print_colored("Invalid selection. Please try again.", 'red')
            except ValueError:
                self.print_colored("Invalid input. Please enter a number.", 'red')

    def _stream_node_logs(self, node_name: str) -> None:
        """Stream logs from a specific node"""
        self.print_header(f"Streaming Logs - {node_name}")
        
        # Get node connection details first to validate before asking user to proceed
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        node_config = hosts.get(node_name, {})
        
        host = node_config.get('ansible_host', '')
        user = node_config.get('ansible_user', '')
        
        if not host or not user:
            self.print_colored("Error: Node configuration incomplete.", 'red')
            self.wait_for_enter()
            return
        
        self.print_colored(f"📡 Ready to stream logs from: {user}@{host}", 'cyan')
        self.print_colored("🔍 This will run 'get_logs -f' on the target machine", 'white')
        self.print_colored("⚠️  Use Ctrl+C to stop streaming and return to menu", 'yellow', bold=True)
        print()
        
        # Ask user to confirm before starting
        self.wait_for_enter("Press Enter to start streaming logs...")
        print()
        
        try:
            # Construct SSH command to follow logs using the deployed get_logs script
            ssh_cmd = f"ssh {user}@{host} 'get_logs -f'"
            
            # Handle SSH key vs password authentication
            if 'ansible_ssh_pass' in node_config:
                # Use sshpass for password authentication
                ssh_cmd = f"sshpass -p '{node_config['ansible_ssh_pass']}' {ssh_cmd}"
            
            self.print_colored(f"Connecting to {user}@{host}...", 'yellow')
            print("=" * 80)
            
            # Execute the command
            process = subprocess.Popen(ssh_cmd, shell=True, stdout=subprocess.PIPE, 
                                     stderr=subprocess.STDOUT, text=True, 
                                     universal_newlines=True)
            
            # Stream the output
            try:
                for line in iter(process.stdout.readline, ''):
                    if line:
                        print(line.rstrip())
                    else:
                        break
            except KeyboardInterrupt:
                print("\n" + "=" * 80)
                self.print_colored("\n🛑 Log streaming stopped by user.", 'yellow')
                process.terminate()
                try:
                    process.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    process.kill()
            
        except Exception as e:
            self.print_colored(f"Error streaming logs: {e}", 'red')
        
        self.wait_for_enter()

    def write_logs_to_file(self) -> None:
        """Save node logs to a local file"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header("Write Logs to File")
        
        # Load configuration
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        
        if not hosts:
            self.print_colored("No nodes configured.", 'yellow')
            self.wait_for_enter()
            return
        
        # Show available nodes
        self.print_section(f"Available Nodes ({len(hosts)})")
        host_list = list(hosts.keys())
        
        for i, host_name in enumerate(host_list, 1):
            host_config = hosts[host_name]
            ip = host_config.get('ansible_host', 'Unknown')
            user = host_config.get('ansible_user', 'Unknown')
            
            # Get status information
            status_info = self._get_node_status_info(host_name)
            status = status_info['status']
            emoji, color, description = self._get_status_display_info(status)
            
            self.print_colored(f"  {i}. {host_name} ({user}@{ip}) ", 'white', end='')
            self.print_colored(f"[{emoji} {description}]", color)
        
        print()
        self.print_colored("  0) Return to main menu")
        print()
        
        while True:
            choice = self.get_input("Select a node to save logs from (0 to return)", "0")
            
            if choice == '0':
                return
            
            try:
                node_index = int(choice) - 1
                if 0 <= node_index < len(host_list):
                    selected_node = host_list[node_index]
                    self._save_node_logs_to_file(selected_node)
                    break
                else:
                    self.print_colored("Invalid selection. Please try again.", 'red')
            except ValueError:
                self.print_colored("Invalid input. Please enter a number.", 'red')

    def _save_node_logs_to_file(self, node_name: str) -> None:
        """Save logs from a specific node to a local file"""
        self.print_header(f"Save Logs to File - {node_name}")
        
        # Get log lines count
        while True:
            try:
                lines_input = self.get_input("How many recent log lines to save (default: 1000)", "1000")
                lines_count = int(lines_input)
                if lines_count <= 0:
                    self.print_colored("Please enter a positive number.", 'red')
                    continue
                break
            except ValueError:
                self.print_colored("Invalid input. Please enter a number.", 'red')
        
        # Generate default filename
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        default_filename = f"{node_name}_logs_{timestamp}.txt"
        
        # Get output filename
        filename = self.get_input(f"Output filename (default: {default_filename})", default_filename)
        
        # Ensure we have a valid filename
        if not filename.strip():
            filename = default_filename
        
        # Add .txt extension if not present
        if not filename.endswith('.txt'):
            filename += '.txt'
        
        try:
            # Get node connection details
            self.load_configuration()
            hosts = _get_gpu_hosts(self.inventory)
            node_config = hosts.get(node_name, {})
            
            host = node_config.get('ansible_host', '')
            user = node_config.get('ansible_user', '')
            
            if not host or not user:
                self.print_colored("Error: Node configuration incomplete.", 'red')
                self.wait_for_enter()
                return
            
            # Construct SSH command to get logs using the deployed get_logs script
            ssh_cmd = f"ssh {user}@{host} 'get_logs -n {lines_count}'"
            
            # Handle SSH key vs password authentication
            if 'ansible_ssh_pass' in node_config:
                # Use sshpass for password authentication
                ssh_cmd = f"sshpass -p '{node_config['ansible_ssh_pass']}' {ssh_cmd}"
            
            self.print_colored(f"Connecting to {user}@{host} and retrieving {lines_count} log lines...", 'yellow')
            
            # Execute the command
            result = subprocess.run(ssh_cmd, shell=True, capture_output=True, text=True, timeout=self.connection_timeout)

            if result.returncode == 0:
                # Save logs to file
                with open(filename, 'w') as f:
                    f.write(f"# Edge Node Logs from {node_name} ({user}@{host})\n")
                    f.write(f"# Retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                    f.write(f"# Last {lines_count} log lines\n")
                    f.write("# " + "="*60 + "\n\n")
                    f.write(result.stdout)
                
                self.print_colored(f"✅ Logs saved to: {filename}", 'green')
                self.print_colored(f"📝 File size: {os.path.getsize(filename)} bytes", 'cyan')
                self.print_colored(f"📅 Lines saved: {lines_count}", 'cyan')
                
            else:
                self.print_colored(f"❌ Error retrieving logs: {result.stderr}", 'red')
                
        except Exception as e:
            self.print_colored(f"Error saving logs: {e}", 'red')
        
        self.wait_for_enter()

    def select_hosts(self, hosts: Dict[str, Dict[str, Any]], operation_name: str,
                     preselect_mode: str = 'all') -> List[str]:
        """Unified host selection UI (interactive with termios, fallback without).

        preselect_mode: 'all' (all selected) | 'undeployed' (never_deployed/deleted selected) | 'none'
        Returns: list of selected host names, or [] if cancelled.
        """
        if not hosts:
            return []

        # Compute initial selection based on preselect_mode
        if preselect_mode == 'undeployed':
            initial_selection = set()
            for host_name in hosts:
                status_info = self._get_node_status_info(host_name)
                if status_info['status'] in ['never_deployed', 'deleted']:
                    initial_selection.add(host_name)
        elif preselect_mode == 'none':
            initial_selection = set()
        else:  # 'all'
            initial_selection = set(hosts.keys())

        try:
            import tty
            import termios
            return self._interactive_select_hosts(hosts, operation_name, initial_selection)
        except ImportError:
            return self._fallback_select_hosts(hosts, operation_name, initial_selection)

    def _render_host_menu(self, hosts, host_list, selected_hosts, current_index, operation_name,
                          initial_selection=None, interactive=True):
        """Render the host selection menu (shared by interactive and fallback modes)."""
        # Clear screen and move cursor to top
        print("\033[2J\033[H", end="")

        # Header
        self.print_header(f"Select Hosts for {operation_name.title()}")

        # Instructions
        if interactive:
            self.print_colored("🎮 Navigation Controls:", 'cyan', bold=True)
            self.print_colored("   ↑/↓ Arrow keys    - Navigate up/down", 'white')
            self.print_colored("   Space bar        - Toggle selection", 'white')
            self.print_colored("   Enter           - Confirm selection", 'white')
            self.print_colored("   q/Esc           - Cancel operation", 'white')
        else:
            self.print_colored("📋 Instructions:", 'cyan', bold=True)
            self.print_colored("   • Enter numbers to toggle selection (e.g., 1, 2, 3)", 'white')
            self.print_colored("   • Use 'a' to select all hosts", 'white')
            self.print_colored("   • Use 'n' to deselect all hosts", 'white')
            self.print_colored("   • Use 'c' to cancel operation", 'white')
            self.print_colored("   • Press Enter when ready to proceed", 'white')
        print()

        # Show preselection info (only for 'undeployed' mode)
        if initial_selection is not None and initial_selection != set(hosts.keys()):
            preselected = initial_selection
            if preselected:
                self.print_colored("💡 Pre-selected nodes that were never deployed or deleted:", 'cyan', bold=True)
                for host_name in sorted(preselected):
                    status_info = self._get_node_status_info(host_name)
                    status_emoji, _, status_desc = self._get_status_display_info(status_info['status'])
                    self.print_colored(f"   • {host_name} [{status_emoji} {status_desc}]", 'green')
                print()

        # Status
        all_selected = len(selected_hosts) == len(hosts)
        if len(selected_hosts) == 0:
            self.print_colored("⚠️  No hosts selected!", 'red', bold=True)
        elif all_selected:
            self.print_colored(f"✅ All {len(hosts)} hosts selected", 'green', bold=True)
        else:
            self.print_colored(f"📊 Selected: {len(selected_hosts)}/{len(hosts)} hosts", 'cyan', bold=True)
            if not interactive:
                selected_names = ', '.join(sorted(selected_hosts))
                self.print_colored(f"   Selected hosts: {selected_names}", 'cyan')
        print()

        # Menu items
        for i, item in enumerate(host_list):
            is_current = interactive and (i == current_index)

            if item == "All hosts":
                all_sel = len(selected_hosts) == len(hosts)
                marker = "✓" if all_sel else " "
                prefix = "→ " if is_current else "  "
                idx_str = f"{prefix}" if interactive else f"  0) "
                color = 'yellow' if is_current else ('green' if all_sel else 'white')
                style = 'bold' if is_current or all_sel else False
                self.print_colored(f"{idx_str}[{marker}] {item} ({len(hosts)} total)", color, bold=style)
            else:
                host_name = item
                is_selected = host_name in selected_hosts
                is_preselected = initial_selection is not None and host_name in initial_selection and initial_selection != set(hosts.keys())
                marker = "✓" if is_selected else " "

                if interactive:
                    prefix = "→ " if is_current else "  "
                    color = 'yellow' if is_current else ('green' if is_selected else 'white')
                    style = 'bold' if is_current else False
                    preselect_indicator = " (pre-selected)" if is_preselected else ""
                    self.print_colored(f"{prefix}[{marker}] {host_name}{preselect_indicator}", color, bold=style)
                else:
                    idx = host_list.index(item)  # 0 is "All hosts", so real index starts at 1
                    color = 'green' if is_selected else 'white'
                    preselect_indicator = " (pre-selected)" if is_preselected else ""
                    self.print_colored(f"  {idx}) [{marker}] {host_name}{preselect_indicator}", color, bold=is_selected)

        print()
        self.print_colored("─" * 60, 'blue')

    def _interactive_select_hosts(self, hosts, operation_name, initial_selection):
        """Interactive host selection with keyboard navigation (arrow keys + space)."""
        import tty
        import termios

        host_list = ["All hosts"] + list(hosts.keys())
        selected_hosts = initial_selection.copy()
        current_index = 0

        def get_key():
            fd = sys.stdin.fileno()
            old_settings = termios.tcgetattr(fd)
            try:
                tty.setraw(sys.stdin.fileno())
                key = sys.stdin.read(1)
                if key == '\x1b':
                    key += sys.stdin.read(2)
                return key
            finally:
                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

        while True:
            self._render_host_menu(hosts, host_list, selected_hosts, current_index,
                                   operation_name, initial_selection, interactive=True)

            try:
                key = get_key()

                if key == '\x1b[A':  # Up arrow
                    current_index = (current_index - 1) % len(host_list)
                elif key == '\x1b[B':  # Down arrow
                    current_index = (current_index + 1) % len(host_list)
                elif key == ' ':  # Space bar - toggle selection
                    current_item = host_list[current_index]
                    if current_item == "All hosts":
                        if len(selected_hosts) == len(hosts):
                            selected_hosts = set()
                        else:
                            selected_hosts = set(hosts.keys())
                    else:
                        if current_item in selected_hosts:
                            selected_hosts.remove(current_item)
                        else:
                            selected_hosts.add(current_item)
                elif key == '\r' or key == '\n':  # Enter - confirm
                    if len(selected_hosts) == 0:
                        print("\033[2J\033[H", end="")
                        self.print_colored("❌ Cannot proceed without selecting any hosts!", 'red', bold=True)
                        self.print_colored("Press any key to continue...", 'yellow')
                        get_key()
                        continue
                    break
                elif key == 'q' or key == '\x1b':  # q or Esc - cancel
                    return []

            except KeyboardInterrupt:
                return []

        return list(selected_hosts)

    def _fallback_select_hosts(self, hosts, operation_name, initial_selection):
        """Fallback host selection for systems without termios."""
        host_list = ["All hosts"] + list(hosts.keys())
        selected_hosts = initial_selection.copy()

        while True:
            self._render_host_menu(hosts, host_list, selected_hosts, -1,
                                   operation_name, initial_selection, interactive=False)

            choice = self.get_input("Enter choice (number/a/n/c/Enter to proceed)", "").strip().lower()

            if choice == "":
                if len(selected_hosts) == 0:
                    self.print_colored("❌ Cannot proceed without selecting any hosts!", 'red')
                    self.wait_for_enter("Press Enter to continue selection...")
                    continue
                break
            elif choice == "c":
                return []
            elif choice == "a":
                selected_hosts = set(hosts.keys())
                self.print_colored("✅ All hosts selected", 'green')
            elif choice == "n":
                selected_hosts = set()
                self.print_colored("⚠️  All hosts deselected", 'yellow')
            else:
                try:
                    choices = []
                    for part in choice.replace(',', ' ').split():
                        try:
                            choices.append(int(part))
                        except ValueError:
                            continue

                    if not choices:
                        choices = [int(choice)]

                    real_host_list = list(hosts.keys())
                    for choice_num in choices:
                        if choice_num == 0:
                            all_selected = len(selected_hosts) == len(hosts)
                            if all_selected:
                                selected_hosts = set()
                                self.print_colored("⚠️  All hosts deselected", 'yellow')
                            else:
                                selected_hosts = set(hosts.keys())
                                self.print_colored("✅ All hosts selected", 'green')
                        elif 1 <= choice_num <= len(real_host_list):
                            host_name = real_host_list[choice_num - 1]
                            if host_name in selected_hosts:
                                selected_hosts.remove(host_name)
                                self.print_colored(f"➖ Deselected: {host_name}", 'yellow')
                            else:
                                selected_hosts.add(host_name)
                                self.print_colored(f"➕ Selected: {host_name}", 'green')
                        else:
                            self.print_colored(f"❌ Invalid choice: {choice_num} (valid range: 0-{len(real_host_list)})", 'red')

                except ValueError:
                    self.print_colored("❌ Invalid input. Please enter numbers, 'a', 'n', 'c', or press Enter.", 'red')

            import time
            time.sleep(0.8)

        return list(selected_hosts)

    def _format_timestamp_ago(self, timestamp: str) -> str:
        """Helper method to format timestamp as 'X time ago' string"""
        if not timestamp:
            return "Never"
            
        timestamp_dt = _parse_iso_to_datetime(timestamp)
        if not timestamp_dt:
            self.print_debug(f"Error parsing timestamp '{timestamp}'")
            return "Unknown"

        # Calculate time difference
        now = datetime.now(timestamp_dt.tzinfo) if timestamp_dt.tzinfo else datetime.now()
        time_diff = now - timestamp_dt

        if time_diff.days > 0:
            return f"{time_diff.days} day(s) ago"
        elif time_diff.seconds > 3600:
            hours = time_diff.seconds // 3600
            return f"{hours} hour(s) ago"
        elif time_diff.seconds > 60:
            minutes = time_diff.seconds // 60
            return f"{minutes} minute(s) ago"
        else:
            return "Just now"

    def combined_node_status_and_info(self) -> None:
        """Display beautiful live container status overview - checks if Edge Node containers are running"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        # Load configuration
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        env = self.get_mnl_app_env()

        # Clear screen and show loading
        print("\033[2J\033[H", end="")
        self.print_header("Container Status")
        self.print_colored(f"🔍 Checking container status (max {self.connection_timeout}s timeout)...", 'cyan')
        
        # Use the same deployment status workflow - get real-time status
        node_status_data = self._get_real_time_node_status()

        # Update persistent status information for each node
        for node_name, status_data in node_status_data.items():
            self._update_node_status(node_name, status_data['status'])
        self.settings_manager.mark_status_refreshed()

        # Clear and display beautiful status
        print("\033[2J\033[H", end="")
        self.print_header("Container Status")
        
        # Show deployment status overview (like deployment menu does)
        self._load_active_config()
        deployment_status = self.active_config.get('deployment_status', 'never_deployed')
        last_deployed_date = self.active_config.get('last_deployed_date')
        last_deployed_network = self.active_config.get('last_deployed_network')
        last_deployment_type = self.active_config.get('last_deployment_type')
        last_deleted_date = self.active_config.get('last_deleted_date')
        
        # Overall deployment status
        if deployment_status == 'deployed' and last_deployed_date:
            deployed_str = _parse_iso_datetime(last_deployed_date)
            if deployed_str:
                deployment_text = f"🚀 Last deployed: {deployed_str}"
                if last_deployed_network:
                    deployment_text += f" ({last_deployed_network})"
                self.print_colored(f"Deployment: {deployment_text}", 'green')
            else:
                self.print_colored("Deployment: ✓ Deployed", 'green')
        elif deployment_status == 'deleted' and last_deleted_date:
            deleted_str = _parse_iso_datetime(last_deleted_date)
            if deleted_str:
                deployment_text = f"🗑️ Last deleted: {deleted_str}"
                self.print_colored(f"Deployment: {deployment_text}", 'red')
            else:
                self.print_colored("Deployment: ✓ Deleted", 'red')
        else:
            self.print_colored("Deployment: ✗ Never deployed", 'yellow')
        print()
        
        # Network info
        env_color = 'green' if env else 'red'
        env_text = env if env else 'Not Set'
        self.print_colored(f"🌐 {env_text} │ 🐳 {len(hosts)} containers", env_color, bold=True)
        print()

        # Count status
        running = sum(1 for data in node_status_data.values() if data['status'] == 'running')
        stopped = sum(1 for data in node_status_data.values() if data['status'] == 'stopped')
        unreachable = sum(1 for data in node_status_data.values() if data['status'] == 'unreachable')
        not_deployed = sum(1 for data in node_status_data.values() if data['status'] == 'not_deployed')
        unknown = sum(1 for data in node_status_data.values() if data['status'] == 'unknown')

        # Status overview
        self.print_colored("┌─ Container Status " + "─" * 48 + "┐", 'cyan')
        status_line = "│"
        if running > 0:
            status_line += f" 🟢 {running} Running"
        if stopped > 0:
            status_line += f" 🔴 {stopped} Stopped"
        if not_deployed > 0:
            status_line += f" 📦 {not_deployed} Not Deployed"
        if unreachable > 0:
            status_line += f" 🔌 {unreachable} Unreachable"
        if unknown > 0:
            status_line += f" ❓ {unknown} Unknown"
        
        # Pad the status line
        padding = 68 - len(status_line)
        status_line += " " * padding + "│"
        
        if running > 0:
            self.print_colored(status_line, 'green')
        elif stopped > 0:
            self.print_colored(status_line, 'red')
        else:
            self.print_colored(status_line, 'yellow')
        
        self.print_colored("└" + "─" * 68 + "┘", 'cyan')
        print()

        # Container list
        self.print_colored("┌─ Containers " + "─" * 55 + "┐", 'cyan')
        
        for i, (name, config) in enumerate(hosts.items()):
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            status_data = node_status_data[name]
            status = status_data['status']
            
            # Status display
            if status == 'running':
                status_icon = "🟢"
                status_text = "RUNNING"
                status_color = 'green'
            elif status == 'stopped':
                status_icon = "🔴"
                status_text = "STOPPED"
                status_color = 'red'
            elif status == 'not_deployed':
                status_icon = "📦"
                status_text = "NOT DEPLOYED"
                status_color = 'yellow'
            elif status == 'unreachable':
                status_icon = "🔌"
                status_text = "UNREACHABLE"
                status_color = 'red'
            else:
                status_icon = "❓"
                status_text = "UNKNOWN"
                status_color = 'yellow'
            
            # Node name line
            self.print_colored("│", 'cyan', end='')
            self.print_colored(f" {status_icon} {name}", status_color, bold=True, end='')
            self.print_colored(f" [{status_text}]", status_color, end='')
            
            # Calculate padding for node name line
            line_text = f" {status_icon} {name} [{status_text}]"
            padding = 67 - len(line_text)
            self.print_colored(" " * padding + "│", 'cyan')
            
            # Connection info
            self.print_colored("│", 'cyan', end='')
            self.print_colored(f"   📡 {user}@{ip}", 'white', end='')
            
            display_text = f"   📡 {user}@{ip}"
            padding = 67 - len(display_text)
            self.print_colored(" " * padding + "│", 'cyan')
            
            # Add separator between nodes (except last)
            if i < len(hosts) - 1:
                self.print_colored("├" + "─" * 68 + "┤", 'cyan')
        
        self.print_colored("└" + "─" * 68 + "┘", 'cyan')
        print()

        # Quick actions
        self.print_colored("\U0001f4a1 Quick Actions: Main Menu \u2192 3 (Operations Menu)", 'white')

        self.wait_for_enter()

    def _manage_service(self, playbook_name: str, title: str, action_text: str) -> None:
        """Common method to manage Edge Nodes"""
        if not self.check_hosts_config():
            self.print_colored("No nodes configured! Please configure nodes first.", 'red')
            self.wait_for_enter()
            return

        self.print_header(title)

        # Load configuration to show service management details
        self.load_configuration()
        hosts = _get_gpu_hosts(self.inventory)
        env = self.get_mnl_app_env()

        # Show service management details
        self.print_colored(f"🔧 Service Management Details:", 'cyan', bold=True)
        self.print_colored(f"   • Action: {action_text}", 'white')
        self.print_colored(f"   • Network: {env if env else 'Not set'}", 'green' if env else 'red')
        self.print_colored(f"   • Available Nodes: {len(hosts)}", 'white')

        self.print_colored(f"\n🖥️  Available Machines:", 'cyan', bold=True)
        for name, config in hosts.items():
            ip = config.get('ansible_host', 'Unknown')
            user = config.get('ansible_user', 'Unknown')
            status_info = self._get_node_status_info(name)
            status = status_info['status']
            emoji, color, description = self._get_status_display_info(status)
            self.print_colored(f"   • {name}: {user}@{ip} ", 'white', end='')
            self.print_colored(f"[{emoji} {description}]", color, end='')

        # Service-specific descriptions
        if "start" in playbook_name:
            self.print_colored(f"\n📋 This will:", 'yellow', bold=True)
            self.print_colored("   • Start the Edge Node systemd service on selected nodes", 'yellow')
            self.print_colored("   • Enable the service to start automatically on boot", 'yellow')
            self.print_colored("   • Verify service startup status", 'yellow')
        elif "stop" in playbook_name:
            self.print_colored(f"\n📋 This will:", 'yellow', bold=True)
            self.print_colored("   • Stop the Edge Node systemd service on selected nodes", 'yellow')
            self.print_colored("   • Keep the service enabled for future startups", 'yellow')
            self.print_colored("   • Verify service shutdown status", 'yellow')
        elif "restart" in playbook_name:
            self.print_colored(f"\n📋 This will:", 'yellow', bold=True)
            self.print_colored("   • Stop the current Edge Node", 'yellow')
            self.print_colored("   • Start the Edge Node with fresh state", 'yellow')
            self.print_colored("   • Verify service restart status", 'yellow')
        elif "status" in playbook_name:
            self.print_colored(f"\n📋 This will:", 'yellow', bold=True)
            self.print_colored("   • Check the current status of Edge Node", 'yellow')
            self.print_colored("   • Show service logs and runtime information", 'yellow')
            self.print_colored("   • Display resource usage if available", 'yellow')

        if not env:
            self.print_colored("\n⚠️  WARNING: Network environment is not set!", 'red', bold=True)
            self.print_colored("   Service operations will proceed, but network environment should be configured.", 'red')

        # Interactive host selection
        operation_name = action_text.lower()
        selected_hosts = self.select_hosts(hosts, operation_name, preselect_mode='all')
        
        if not selected_hosts:
            self.print_colored("No hosts selected. Operation cancelled.", 'yellow')
            return

        # Show final confirmation with selected hosts
        self.print_colored(f"\n📋 Selected hosts for {operation_name}:", 'cyan', bold=True)
        for host_name in selected_hosts:
            host_config = hosts[host_name]
            ip = host_config.get('ansible_host', 'Unknown')
            user = host_config.get('ansible_user', 'Unknown')
            self.print_colored(f"   ✓ {host_name}: {user}@{ip}", 'green')

        # Confirmation for potentially disruptive operations
        if "stop" in playbook_name or "restart" in playbook_name:
            if self.get_input(f"\n⚠️  Continue with {operation_name} on {len(selected_hosts)} selected node(s)? (y/n)", "y").lower() != 'y':
                self.print_colored("Service operation cancelled.", 'yellow')
                return
        else:
            if self.get_input(f"\n🔧 Continue with {operation_name} on {len(selected_hosts)} selected node(s)? (y/n)", "y").lower() != 'y':
                self.print_colored("Service operation cancelled.", 'yellow')
                return

        playbook_path = self.config_dir / f'playbooks/{playbook_name}'
        if not playbook_path.exists():
            self.print_colored(f"Service management playbook not found: {playbook_path}", 'red')
            self.wait_for_enter()
            return

        # Build Ansible command with host limitation
        cmd = (f"ANSIBLE_CONFIG={os.environ['ANSIBLE_CONFIG']} "
               f"ANSIBLE_COLLECTIONS_PATH={os.environ['ANSIBLE_COLLECTIONS_PATH']} "
               f"ANSIBLE_HOME={os.environ['ANSIBLE_HOME']} "
               f"ansible-playbook -i {self.config_file} {playbook_path}")

        # Add --limit parameter to target only selected hosts
        if len(selected_hosts) < len(hosts):
            limit_hosts = ','.join(selected_hosts)
            cmd += f" --limit '{limit_hosts}'"

        # Update node statuses to reflect the operation being performed
        if "start" in playbook_name or "restart" in playbook_name:
            for host_name in selected_hosts:
                self._update_node_status(host_name, 'deploying')
        elif "stop" in playbook_name:
            for host_name in selected_hosts:
                self._update_node_status(host_name, 'deploying')

        self.print_colored(f"\n{action_text} on {len(selected_hosts)} node(s)...", 'cyan')
        success, _ = self.run_command(cmd, show_output=True)

        if success:
            self.print_colored(f"\n✅ {title} completed successfully!", 'green')
            
            # Update node statuses based on successful operation
            if "start" in playbook_name:
                for host_name in selected_hosts:
                    self._update_node_status(host_name, 'running')
                self.print_colored(f"Edge Nodes have been started on {len(selected_hosts)} node(s).", 'green')
            elif "stop" in playbook_name:
                for host_name in selected_hosts:
                    self._update_node_status(host_name, 'stopped')
                self.print_colored(f"Edge Nodes have been stopped on {len(selected_hosts)} node(s).", 'green')
            elif "restart" in playbook_name:
                for host_name in selected_hosts:
                    self._update_node_status(host_name, 'running')
                self.print_colored(f"Edge Nodes have been restarted on {len(selected_hosts)} node(s).", 'green')
            elif "status" in playbook_name:
                # Status check doesn't change the actual status, just reports it
                self.print_colored(f"Service status information retrieved for {len(selected_hosts)} node(s).", 'green')
                
            # Show updated statuses
            if "start" in playbook_name or "stop" in playbook_name or "restart" in playbook_name:
                self.print_colored(f"\n📊 Updated Node Statuses:", 'cyan', bold=True)
                for host_name in selected_hosts:
                    self.print_colored(f"   • {host_name}: ", 'white', end='')
                    self._display_node_status(host_name, compact=True)
                    print()  # New line after each status
        else:
            self.print_colored(f"\n❌ {title} encountered issues. Please check the output above.", 'red')
            
            # Update node statuses to reflect potential error state
            if "start" in playbook_name or "restart" in playbook_name or "stop" in playbook_name:
                for host_name in selected_hosts:
                    self._update_node_status(host_name, 'error')
                self.print_colored(f"\n📊 Node statuses updated to Error due to operation failure.", 'yellow')
            
            # Additional error guidance based on operation
            if "start" in playbook_name:
                self.print_colored("Common issues: Service not deployed, network connectivity, or configuration errors.", 'yellow')
            elif "stop" in playbook_name or "restart" in playbook_name:
                self.print_colored("This might be normal if the service was not running on some nodes.", 'yellow')

        self.wait_for_enter()


if __name__ == "__main__":
    r1setup = R1Setup()
    r1setup.run()
