#!/usr/bin/python3
"""
Borg Backup Script with YAML configuration support
"""

import argparse
import fcntl
import logging
import os
import pwd
import socket
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Any

try:
    import yaml
except ImportError:
    print("ERROR: PyYAML module is required. Install with: pip3 install PyYAML", file=sys.stderr)
    sys.exit(1)


HOOKS_DIR = Path('/etc/borg/hooks.d')


class BorgBackup:
    """Main Borg Backup class"""

    # Hardcoded exclude presets
    EXCLUDE_PRESETS = {
        'system_dirs': [
            '/proc/*',
            '/sys/*',
            '/dev/*',
            '/run/*',
            '/var/run/*',
            '/.fsck',
            '/lost+found',
        ],
        'temp_dirs': [
            '/tmp/*',
            '/var/tmp/*',
            '*.tmp',
            '*.temp',
        ],
        'borg_cache': [
            '/root/.cache/borg/*',
            '/root/.config/borg/*',
        ],
        'log_dirs': [
            '/var/log/journal/*',
            '/var/log/sudo-io/*',
            '/var/log/lastlog',
            '/var/log/syslog',
        ],
        'cache_dirs': [
            '/var/cache/*',
            '.cache/*',
            '*.cache',
        ],
        'session_dirs': [
            '/var/lib/php/sessions/*',
            '/var/lib/php*/sessions/*',
        ],
        'containers': [
            '/var/lib/docker/*',
            '/var/lib/containerd/*',
            '/var/lib/lxc/*',
            '/var/lib/lxd/*',
        ],
        'databases': [
            '/var/lib/mysql/*',
            '/var/lib/mysql-files/*',
            '/var/lib/mysql-keyring/*',
            '/var/lib/postgresql/*',
            '/var/lib/mongodb/*',
            '/var/lib/redis/*',
            '/var/lib/elasticsearch/*',
            '/var/lib/influxdb/*',
        ],
        'pkg_managed': [
            '/usr/bin/*',
            '/usr/sbin/*',
            '/usr/lib/*',
            '/usr/lib64/*',
            '/usr/libexec/*',
            '/usr/share/*',
            '/usr/src/*',
            '/usr/include/*',
            '/var/cache/apt/archives/*',
            '/var/lib/dpkg/info/*',
            '/lib/*',
            '/lib64/*',
            '/sbin/*',
            '/bin/*',
            '/boot/*',
        ],
        'dev_caches': [
            'node_modules',
            '__pycache__',
            '*.pyc',
            '.pytest_cache',
            '.tox',
            'venv',
            '.venv',
            'vendor/bundle',
        ],
    }

    # Default state for each preset (True = active unless explicitly disabled)
    EXCLUDE_PRESET_DEFAULTS = {
        'system_dirs': True,
        'temp_dirs': True,
        'borg_cache': True,
        'log_dirs': True,
        'cache_dirs': False,
        'session_dirs': False,
        'containers': False,
        'databases': False,
        'pkg_managed': True,
        'dev_caches': False,
    }

    def __init__(self, config_path: Optional[str] = None):
        self.config, self._config_file = self._load_config(config_path)
        self._setup_logging()
        self.logger = logging.getLogger(__name__)
        self.logger.info(f"Loaded configuration from: {self._config_file}")

    def _load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
        """Load configuration from YAML file"""
        config_locations = []

        if config_path:
            config_locations.append(Path(config_path))
        else:
            config_locations.extend([
                Path('/etc/borg/borg.yml'),
                Path.cwd() / 'borg.yml'
            ])

        for config_file in config_locations:
            if config_file.exists():
                try:
                    with open(config_file, 'r') as f:
                        config = yaml.safe_load(f)
                    return config, config_file
                except Exception as e:
                    print(f"ERROR: Failed to load config from {config_file}: {e}", file=sys.stderr)
                    sys.exit(1)

        print(f"ERROR: No configuration file found in: {', '.join(str(p) for p in config_locations)}",
              file=sys.stderr)
        sys.exit(1)

    def _setup_logging(self):
        """Setup logging configuration"""
        log_config = self.config.get('logging', {})
        log_level = getattr(logging, log_config.get('level', 'INFO').upper())
        log_file = log_config.get('file')

        handlers = []

        # Console handler (brief format - journalctl adds its own timestamp/unit)
        console_handler = logging.StreamHandler()
        console_handler.setLevel(log_level)
        console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))
        handlers.append(console_handler)

        # File handler if specified (full format with timestamp for standalone log files)
        if log_file:
            try:
                file_handler = logging.FileHandler(log_file)
                file_handler.setLevel(log_level)
                file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
                handlers.append(file_handler)
            except Exception as e:
                print(f"WARNING: Could not create log file {log_file}: {e}", file=sys.stderr)

        logging.basicConfig(
            level=log_level,
            handlers=handlers
        )

    def _check_keepalive_master(self) -> bool:
        """Check if this node is the keepalive master"""
        keepalive_config = self.config.get('keepalive')

        # If keepalive section doesn't exist or is not enabled, proceed with backup
        if not keepalive_config or not keepalive_config.get('enabled', False):
            return True

        state_file = keepalive_config.get('state_file')
        if not state_file:
            self.logger.error("Keepalive enabled but no state_file configured")
            return False

        try:
            with open(state_file, 'r') as f:
                content = f.read()

            is_master = 'MASTER' in content
            if is_master:
                self.logger.info(f"This node is MASTER (state file: {state_file})")
            else:
                self.logger.info(f"This node is not MASTER (state file: {state_file})")

            return is_master
        except FileNotFoundError:
            self.logger.error(f"Keepalive state file not found: {state_file}")
            return False
        except Exception as e:
            self.logger.error(f"Error checking keepalive status: {e}")
            return False

    def _acquire_global_lock(self) -> Optional[int]:
        """Acquire a global process lock to prevent concurrent runs.

        Returns the file descriptor if the lock was acquired, or None if locking is disabled.
        Exits with code 1 on timeout.
        """
        lock_config = self.config.get('lock', {})
        if not lock_config.get('enabled', True):
            return None

        lock_file = lock_config.get('file', '/run/lock/borg-backup.lock')
        timeout = lock_config.get('timeout', 900)

        self.logger.info(f"Acquiring global lock: {lock_file} (timeout: {timeout}s)")

        try:
            fd = os.open(lock_file, os.O_CREAT | os.O_WRONLY, 0o644)
        except OSError as e:
            self.logger.error(f"Cannot open lock file {lock_file}: {e}")
            sys.exit(1)

        deadline = time.monotonic() + timeout
        while True:
            try:
                fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
                # Write PID into the lock file
                os.ftruncate(fd, 0)
                os.write(fd, str(os.getpid()).encode())
                self.logger.info(f"Global lock acquired (pid={os.getpid()})")
                return fd
            except BlockingIOError:
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    self.logger.error(
                        f"Could not acquire global lock within {timeout}s – another instance is running"
                    )
                    os.close(fd)
                    sys.exit(1)
                self.logger.debug(f"Waiting for global lock... ({remaining:.0f}s remaining)")
                time.sleep(1)

    def _release_global_lock(self, fd: Optional[int]) -> None:
        """Release the global process lock."""
        if fd is None:
            return
        try:
            fcntl.flock(fd, fcntl.LOCK_UN)
            os.close(fd)
            self.logger.info("Global lock released")
        except OSError as e:
            self.logger.warning(f"Error releasing global lock: {e}")

    def _get_env_vars(self, backup_config: Dict[str, Any]) -> Dict[str, str]:
        """Prepare environment variables for borg"""
        env = os.environ.copy()

        # Get global borg environment from config (optional)
        global_borg_env = self.config.get('borg', {}).get('environment', {})
        env.update(global_borg_env)

        # Get backup-specific environment variables (these override global settings)
        backup_env = backup_config.get('environment', {})
        env.update(backup_env)

        # Set default BORG_EXIT_CODES if not specified anywhere
        if 'BORG_EXIT_CODES' not in env:
            env['BORG_EXIT_CODES'] = 'modern'

        return env

    def _validate_hook_executable(self, script_name: str) -> Optional[Path]:
        """Validate that a hook script is safe to execute"""
        # Reject path separators and traversal attempts
        if '/' in script_name or '..' in script_name:
            self.logger.error(f"Hook script name contains path separator or '..': {script_name}")
            return None

        hook_path = HOOKS_DIR / script_name
        resolved_path = hook_path.resolve()
        resolved_hooks_dir = HOOKS_DIR.resolve()

        # Ensure resolved path is within HOOKS_DIR (symlink traversal protection)
        try:
            resolved_path.relative_to(resolved_hooks_dir)
        except ValueError:
            self.logger.error(f"Hook script resolves outside of {HOOKS_DIR}: {script_name} -> {resolved_path}")
            return None

        if not resolved_path.exists():
            self.logger.error(f"Hook script does not exist: {resolved_path}")
            return None

        if not os.access(resolved_path, os.X_OK):
            self.logger.error(f"Hook script is not executable: {resolved_path}")
            return None

        file_stat = resolved_path.stat()

        if file_stat.st_uid != 0:
            self.logger.error(f"Hook script is not owned by root (uid={file_stat.st_uid}): {resolved_path}")
            return None

        if file_stat.st_mode & 0o002:
            self.logger.error(f"Hook script is world-writable: {resolved_path}")
            return None

        return resolved_path

    def _run_hook_command(self, hook_config: dict, phase: str, env: Dict[str, str],
                          tag: str, backup_status: str) -> bool:
        """Run a command-based hook. Returns True on success."""
        command = hook_config.get('command', hook_config) if isinstance(hook_config, dict) else hook_config
        if isinstance(command, str):
            command = [command]

        on_failure = hook_config.get('on_failure', 'abort' if phase == 'pre' else 'continue') \
            if isinstance(hook_config, dict) else ('abort' if phase == 'pre' else 'continue')

        # Validate the executable (first element)
        script_path = self._validate_hook_executable(command[0])
        if script_path is None:
            if on_failure == 'abort':
                self.logger.error(f"[{phase}] Hook validation failed for '{command[0]}', aborting")
                return False
            self.logger.warning(f"[{phase}] Hook validation failed for '{command[0]}', continuing")
            return True

        cmd = [str(script_path)] + command[1:]
        label = command[0]

        if isinstance(hook_config, dict):
            timeout = hook_config.get('timeout', 300) or None  # 0 = no timeout
            run_as = hook_config.get('run_as')
        else:
            timeout = 300
            run_as = None

        # Resolve run_as user
        uid = None
        gid = None
        if run_as:
            try:
                pw = pwd.getpwnam(run_as)
                uid = pw.pw_uid
                gid = pw.pw_gid
            except KeyError:
                self.logger.error(f"[{phase}] User '{run_as}' not found for hook '{label}'")
                return on_failure != 'abort'

        # Prepare hook environment
        hook_env = env.copy()
        hook_env['BORG_HOOK_PHASE'] = phase
        hook_env['BORG_BACKUP_TAG'] = tag
        hook_env['BORG_BACKUP_STATUS'] = backup_status

        self.logger.info(f"[{phase}] Running hook: {' '.join(cmd)}"
                         f"{f' (as {run_as})' if run_as else ''}")

        start = time.monotonic()
        try:
            kwargs = {'env': hook_env, 'timeout': timeout}
            if uid is not None:
                kwargs['user'] = uid
                kwargs['group'] = gid
            result = subprocess.run(cmd, **kwargs)
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))

            if result.returncode != 0:
                self.logger.error(f"[{phase}] Hook '{label}' failed with exit code {result.returncode} ({duration})")
                return on_failure != 'abort'

            self.logger.info(f"[{phase}] Hook '{label}' completed successfully ({duration})")
            return True

        except subprocess.TimeoutExpired:
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))
            self.logger.error(f"[{phase}] Hook '{label}' timed out after {timeout}s ({duration})")
            return on_failure != 'abort'
        except Exception as e:
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))
            self.logger.error(f"[{phase}] Hook '{label}' failed with error: {e} ({duration})")
            return on_failure != 'abort'

    def _run_hook_service(self, hook_config: dict, phase: str, tag: str) -> bool:
        """Run a systemd service hook. Returns True on success."""
        service = hook_config['service']
        on_failure = hook_config.get('on_failure', 'abort' if phase == 'pre' else 'continue')
        timeout = hook_config.get('timeout', 300) or None  # 0 = no timeout

        self.logger.info(f"[{phase}] Starting service: {service}")

        start = time.monotonic()
        try:
            result = subprocess.run(
                ['systemctl', 'start', service],
                timeout=timeout
            )
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))

            if result.returncode != 0:
                self.logger.error(f"[{phase}] Service '{service}' failed (rc={result.returncode}) ({duration})")
                return on_failure != 'abort'

            self.logger.info(f"[{phase}] Service '{service}' completed successfully ({duration})")
            return True

        except subprocess.TimeoutExpired:
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))
            self.logger.error(f"[{phase}] Service '{service}' timed out after {timeout}s ({duration})")
            self.logger.info(f"[{phase}] Stopping timed-out service: {service}")
            subprocess.run(['systemctl', 'stop', service], timeout=30)
            return on_failure != 'abort'
        except Exception as e:
            duration = time.strftime('%H:%M:%S', time.gmtime(time.monotonic() - start))
            self.logger.error(f"[{phase}] Service '{service}' failed with error: {e} ({duration})")
            return on_failure != 'abort'

    def _run_hooks(self, hooks: List, phase: str, env: Dict[str, str], tag: str,
                   backup_status: str = 'pending') -> bool:
        """Run hook scripts sequentially. Returns True if all hooks succeeded or were non-fatal."""
        if not hooks:
            return True

        # Sort hooks: those with 'order' first (ascending), then those without
        ordered = [(i, h) for i, h in enumerate(hooks)
                   if isinstance(h, dict) and 'order' in h]
        unordered = [(i, h) for i, h in enumerate(hooks)
                     if not (isinstance(h, dict) and 'order' in h)]
        ordered.sort(key=lambda x: x[1]['order'])
        sorted_hooks = [h for _, h in ordered] + [h for _, h in unordered]

        for hook_config in sorted_hooks:
            if isinstance(hook_config, dict) and 'service' in hook_config:
                success = self._run_hook_service(hook_config, phase, tag)
            else:
                success = self._run_hook_command(hook_config, phase, env, tag, backup_status)

            if not success:
                return False

        return True

    def _get_exclude_patterns(self, backup_config: Dict[str, Any]) -> List[str]:
        """Get all exclude patterns including presets and custom excludes"""
        excludes = []

        # Get exclude presets configuration
        exclude_presets = backup_config.get('exclude_presets', {})

        # Preset defaults only apply for root backups (path = /)
        paths = backup_config.get('paths', [])
        if isinstance(paths, str):
            paths = [paths]
        is_root_backup = '/' in paths

        # Add preset excludes if enabled (using per-preset defaults)
        for preset_name, patterns in self.EXCLUDE_PRESETS.items():
            default = self.EXCLUDE_PRESET_DEFAULTS.get(preset_name, False) if is_root_backup else False
            if exclude_presets.get(preset_name, default):
                excludes.extend(patterns)
                self.logger.debug(f"Added exclude preset '{preset_name}': {len(patterns)} patterns")

        # Add custom excludes
        custom_excludes = backup_config.get('excludes', [])
        excludes.extend(custom_excludes)

        return excludes

    def _build_borg_create_command(self, backup_config: Dict[str, Any], archive_name: str) -> List[str]:
        """Build borg create command from configuration"""
        cmd = ['borg', 'create']

        # Options
        options = backup_config.get('options', {})

        if options.get('verbose', True):
            cmd.append('--verbose')

        # Filter (default: AME - Added, Modified, Error)
        filter_value = options.get('filter', 'AME')
        if filter_value:  # Allow disabling with filter: null or filter: ''
            cmd.extend(['--filter', filter_value])

        if options.get('list', True):
            cmd.append('--list')

        if options.get('stats', True):
            cmd.append('--stats')

        if options.get('show_rc', True):
            cmd.append('--show-rc')

        # Set compression (default: zstd,9)
        compression = options.get('compression', 'zstd,9')
        cmd.extend(['--compression', compression])

        if options.get('one_file_system', True):
            cmd.append('--one-file-system')

        if options.get('exclude_caches', True):
            cmd.append('--exclude-caches')

        # Excludes (presets + custom)
        all_excludes = self._get_exclude_patterns(backup_config)
        for exclude in all_excludes:
            cmd.extend(['--exclude', exclude])

        # Exclude if present (default: .exclude_from_backup)
        exclude_if_present = options.get('exclude_if_present', '.exclude_from_backup')
        if exclude_if_present:  # Allow disabling with exclude_if_present: null or ''
            cmd.extend(['--exclude-if-present', exclude_if_present])

        # Archive name
        cmd.append(f'::{archive_name}')

        # Paths to backup
        paths = backup_config.get('paths', [])
        if isinstance(paths, str):
            paths = [paths]
        cmd.extend(paths)

        return cmd

    def _build_borg_prune_command(self, prune_config: Dict[str, Any], archive_template: str) -> List[str]:
        """Build borg prune command from configuration"""
        cmd = ['borg', 'prune']

        if prune_config.get('list', True):
            cmd.append('--list')

        if prune_config.get('show_rc', True):
            cmd.append('--show-rc')

        # Determine glob_archives pattern
        if 'glob_archives' in prune_config:
            # Use explicitly configured glob_archives
            glob_pattern = prune_config['glob_archives']
        else:
            # Derive glob_archives from archive_template
            # Replace time-based placeholders with wildcard
            glob_pattern = archive_template
            time_placeholders = ['{now}', '{utcnow}', '{fqdn}', '{reverse-fqdn}',
                               '{user}', '{pid}', '{borgversion}']
            for placeholder in time_placeholders:
                glob_pattern = glob_pattern.replace(placeholder, '*')

        # Replace {hostname} with actual hostname
        glob_pattern = glob_pattern.replace('{hostname}', os.uname().nodename)
        cmd.extend(['--glob-archives', glob_pattern])

        # Retention policy
        retention = prune_config.get('retention', {})
        if 'keep_within' in retention:
            cmd.extend(['--keep-within', str(retention['keep_within'])])
        if 'keep_hourly' in retention:
            cmd.extend(['--keep-hourly', str(retention['keep_hourly'])])
        if 'keep_daily' in retention:
            cmd.extend(['--keep-daily', str(retention['keep_daily'])])
        if 'keep_weekly' in retention:
            cmd.extend(['--keep-weekly', str(retention['keep_weekly'])])
        if 'keep_monthly' in retention:
            cmd.extend(['--keep-monthly', str(retention['keep_monthly'])])
        if 'keep_yearly' in retention:
            cmd.extend(['--keep-yearly', str(retention['keep_yearly'])])

        return cmd

    def _run_command(self, cmd: List[str], env: Dict[str, str], allowed_exit_codes: List[int]) -> int:
        """Run a command and return exit code"""
        self.logger.info(f"Running command: {' '.join(cmd)}")

        try:
            result = subprocess.run(cmd, env=env)
            exit_code = result.returncode

            if exit_code in allowed_exit_codes:
                self.logger.info(f"Command completed with exit code {exit_code} (accepted)")
                return 0
            else:
                self.logger.error(f"Command failed with exit code {exit_code}")
                return exit_code
        except Exception as e:
            self.logger.error(f"Error running command: {e}")
            return 1

    def init_repo(self, backup_tag: str) -> int:
        """Initialize a new borg repository for the given backup tag"""
        backups_config = self.config.get('backups', {})

        if backup_tag not in backups_config:
            self.logger.error(f"Backup tag '{backup_tag}' not found in configuration")
            return 1

        backup_config = backups_config[backup_tag]
        env = self._get_env_vars(backup_config)

        cmd = ['borg', 'init', '--encryption', 'repokey-blake2']
        self.logger.info(f"Initializing repository for tag '{backup_tag}'")

        exit_code = self._run_command(cmd, env, [0])
        if exit_code != 0:
            return exit_code

        return self.export_key(backup_tag)

    def export_key(self, backup_tag: str) -> int:
        """Export the repository key and print a Vaultwarden entry hint"""
        backups_config = self.config.get('backups', {})

        if backup_tag not in backups_config:
            self.logger.error(f"Backup tag '{backup_tag}' not found in configuration")
            return 1

        backup_config = backups_config[backup_tag]
        env = self._get_env_vars(backup_config)
        hostname = socket.getfqdn()
        borg_repo = env.get('BORG_REPO', '')
        borg_passphrase = env.get('BORG_PASSPHRASE', '')

        try:
            result = subprocess.run(
                ['borg', 'key', 'export', '::'],
                env=env,
                capture_output=True,
                text=True
            )
            key_export = result.stdout.strip() if result.returncode == 0 else '(key export failed)'
        except Exception as e:
            key_export = f'(key export failed: {e})'

        separator = '-' * 60
        print(f"\n{separator}")
        print(f"  Vaultwarden Entry: {hostname} - borg {backup_tag}")
        print(separator)
        print(f"  Passwort: {borg_passphrase}")
        print(f"  Notiz:    {borg_repo}")
        print(f"  Key (Versteckt):")
        print(f"{key_export}")
        print(separator)

        return 0

    def _resolve_prune_config(self, backup_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
        """Resolve prune configuration with retention fallback hierarchy.

        Returns the resolved prune config, or None if pruning is disabled.
        """
        prune_config = backup_config.get('prune')

        if prune_config is not None and not prune_config.get('enabled', True):
            return None

        # Use prune_config if it exists, otherwise create default
        if prune_config is None:
            prune_config = {}

        # Get retention policy with fallback hierarchy:
        # 1. Backup-specific retention (highest priority)
        # 2. Global default_retention
        # 3. Hard-coded default: keep_daily: 31
        if 'retention' not in prune_config:
            global_default_retention = self.config.get('default_retention')
            if global_default_retention:
                prune_config['retention'] = global_default_retention
            else:
                prune_config['retention'] = {'keep_daily': 31}

        return prune_config

    def _execute_prune(self, tag: str, backup_config: Dict[str, Any], env: Dict[str, str]) -> int:
        """Execute borg prune for a single backup tag. Returns exit code (0 = success)."""
        prune_config = self._resolve_prune_config(backup_config)
        if prune_config is None:
            self.logger.info(f"Pruning is disabled for '{tag}', skipping")
            return 0

        archive_template = backup_config.get('archive_name', '{hostname}-{now}')
        prune_cmd = self._build_borg_prune_command(prune_config, archive_template)
        allowed_prune_exits = backup_config.get('allowed_exit_codes', {}).get('prune', [0])

        prune_exit = self._run_command(prune_cmd, env, allowed_prune_exits)
        if prune_exit != 0:
            self.logger.error(f"Prune for backup '{tag}' failed")
        return prune_exit

    def run_prune(self, backup_tag: Optional[str] = None) -> int:
        """Run prune only for specified tag or all backups"""
        backups_config = self.config.get('backups', {})

        if backup_tag:
            if backup_tag not in backups_config:
                self.logger.error(f"Backup tag '{backup_tag}' not found in configuration")
                return 1
            backups_to_run = {backup_tag: backups_config[backup_tag]}
        else:
            backups_to_run = backups_config

        overall_exit_code = 0

        for tag, backup_config in backups_to_run.items():
            self.logger.info(f"Processing prune: {tag}")

            # Skip only_on_demand backups when no explicit tag was specified
            if backup_tag is None and backup_config.get('only_on_demand', False):
                self.logger.info(
                    f"Skipping prune '{tag}' - only_on_demand=true (use --backup {tag} to run explicitly)"
                )
                continue

            env = self._get_env_vars(backup_config)
            prune_exit = self._execute_prune(tag, backup_config, env)
            if prune_exit != 0:
                overall_exit_code = prune_exit

        return overall_exit_code

    def _log_summary(self, total: int, successful: List[str], failed: List[str],
                     skipped: List[str], hook_failed: List[str]):
        """Log a one-line summary of backup results."""
        ok = len(successful)
        parts = [f"{ok}/{total} backups successful"]

        if failed:
            parts.append(f"{len(failed)} failed ({', '.join(failed)})")
        if hook_failed:
            parts.append(f"{len(hook_failed)} pre-hook failed ({', '.join(hook_failed)})")
        if skipped:
            parts.append(f"{len(skipped)} skipped ({', '.join(skipped)})")

        self.logger.info(f"Summary: {', '.join(parts)}")

    def run_backup(self, backup_tag: Optional[str] = None) -> int:
        """Run backup for specified tag or all backups"""
        # Get backups to run
        backups_config = self.config.get('backups', {})

        if backup_tag:
            if backup_tag not in backups_config:
                self.logger.error(f"Backup tag '{backup_tag}' not found in configuration")
                return 1
            backups_to_run = {backup_tag: backups_config[backup_tag]}
        else:
            backups_to_run = backups_config

        overall_exit_code = 0
        successful = []
        failed = []
        skipped = []
        hook_failed = []

        for tag, backup_config in backups_to_run.items():
            self.logger.info(f"Processing backup: {tag}")

            # Check keepalive status per tag (can be overridden per tag)
            check_keepalive = backup_config.get('check_keepalive', True)
            if check_keepalive and not self._check_keepalive_master():
                self.logger.info(f"Skipping backup '{tag}' - this node is not the master")
                skipped.append(tag)
                continue

            # Skip only_on_demand backups when no explicit tag was specified
            if backup_tag is None and backup_config.get('only_on_demand', False):
                self.logger.info(
                    f"Skipping backup '{tag}' - only_on_demand=true (use --backup {tag} to run explicitly)"
                )
                skipped.append(tag)
                continue

            # Get environment variables for this specific backup
            env = self._get_env_vars(backup_config)

            # Get hook configuration
            hooks_config = backup_config.get('hooks', {})
            pre_hooks = hooks_config.get('pre', [])
            post_hooks = hooks_config.get('post', [])

            # Run pre-hooks
            if pre_hooks:
                if not self._run_hooks(pre_hooks, 'pre', env, tag, backup_status='pending'):
                    self.logger.error(f"Pre-hook failed for backup '{tag}', skipping backup")
                    overall_exit_code = 1
                    hook_failed.append(tag)
                    continue

            # Generate archive name (default: {hostname}-{now})
            archive_template = backup_config.get('archive_name', '{hostname}-{now}')
            archive_name = archive_template.replace('{hostname}', os.uname().nodename)
            # borg will replace {now} automatically

            # Run borg create
            # Default allowed exit codes: 0 (success), 100 (warning - files changed during backup)
            create_cmd = self._build_borg_create_command(backup_config, archive_name)
            allowed_backup_exits = backup_config.get('allowed_exit_codes', {}).get('backup', [0, 100])

            backup_exit = self._run_command(create_cmd, env, allowed_backup_exits)

            # Run borg prune if configured (default: enabled) and backup succeeded
            prune_exit = 0
            if backup_exit == 0:
                prune_exit = self._execute_prune(tag, backup_config, env)

            # Determine backup status for post-hooks
            if backup_exit != 0:
                self.logger.error(f"Backup '{tag}' failed")
                overall_exit_code = backup_exit
                backup_status = 'failed'
                failed.append(tag)
            elif prune_exit != 0:
                overall_exit_code = prune_exit
                backup_status = 'failed'
                failed.append(tag)
            else:
                backup_status = 'success'
                successful.append(tag)

            # Post-hooks always run (even on failure, e.g. to restart stopped services)
            if post_hooks:
                self._run_hooks(post_hooks, 'post', env, tag, backup_status=backup_status)

        total = len(backups_to_run)
        self._log_summary(total, successful, failed, skipped, hook_failed)

        return overall_exit_code


def main():
    """Main entry point"""
    parser = argparse.ArgumentParser(
        description='Borg Backup Script with YAML configuration',
        formatter_class=argparse.RawDescriptionHelpFormatter
    )

    parser.add_argument(
        '--config',
        '-c',
        help='Path to configuration file (default: /etc/borg/borg.yml or ./borg.yml)',
        default=None
    )

    parser.add_argument(
        '--backup',
        '-b',
        help='Backup tag to run (default: all configured backups)',
        default=None
    )

    parser.add_argument(
        '--init',
        action='store_true',
        help='Initialize a new borg repository (requires --backup <tag>)'
    )

    parser.add_argument(
        '--export-key',
        action='store_true',
        help='Export repository key and print Vaultwarden entry (requires --backup <tag>)'
    )

    parser.add_argument(
        '--prune',
        '-p',
        nargs='?',
        const=True,
        default=None,
        metavar='TAG',
        help='Run only prune (no backup). Optionally specify a tag, otherwise all tags are pruned.'
    )

    parser.add_argument(
        '--dry-run',
        action='store_true',
        help='Dry run mode (not implemented in borg commands, but logged)'
    )

    args = parser.parse_args()

    if args.init and not args.backup:
        parser.error("--init requires --backup <tag>")

    if args.export_key and not args.backup:
        parser.error("--export-key requires --backup <tag>")

    try:
        backup = BorgBackup(config_path=args.config)

        if args.init:
            exit_code = backup.init_repo(backup_tag=args.backup)
            sys.exit(exit_code)

        if args.export_key:
            exit_code = backup.export_key(backup_tag=args.backup)
            sys.exit(exit_code)

        if args.dry_run:
            backup.logger.info("DRY RUN MODE - Commands will be logged but not modified")

        lock_fd = backup._acquire_global_lock()
        try:
            if args.prune is not None:
                prune_tag = args.prune if args.prune is not True else None
                exit_code = backup.run_prune(backup_tag=prune_tag)
                sys.exit(exit_code)

            exit_code = backup.run_backup(backup_tag=args.backup)
            sys.exit(exit_code)
        finally:
            backup._release_global_lock(lock_fd)

    except KeyboardInterrupt:
        print("\nBackup interrupted by user", file=sys.stderr)
        sys.exit(130)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()
