use crate::hosts::{Host, HostManager};
use crate::output;
use crate::selector::select_item;
use crate::user_config::UserConfig;
use chrono::Utc;
use clap::Subcommand;
use eyre::{Context, Result};
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::Instant;
use tabled::Tabled;

const SSH_MUX_OPTIONS: &[(&str, &str)] = &[
    ("ControlMaster", "auto"),
    ("ControlPath", "/tmp/ssh-%r@%h:%p"),
    ("ControlPersist", "60s"),
];

struct SshSession<'a> {
    host: &'a Host,
    ssh_key: &'a Path,
}

impl<'a> SshSession<'a> {
    fn new(host: &'a Host, ssh_key: &'a Path) -> Self {
        Self { host, ssh_key }
    }

    fn mux_args() -> Vec<OsString> {
        SSH_MUX_OPTIONS
            .iter()
            .flat_map(|(k, v)| [OsString::from("-o"), format!("{}={}", k, v).into()])
            .collect()
    }

    fn ssh_args(&self) -> Vec<OsString> {
        let mut args = Self::mux_args();
        args.extend([
            "-i".into(),
            self.ssh_key.into(),
            "-p".into(),
            self.host.port.to_string().into(),
            format!("{}@{}", self.host.user, self.host.address).into(),
        ]);
        args
    }

    fn run(&self, command: &str) -> Result<Output> {
        Command::new("ssh")
            .args(self.ssh_args())
            .arg(command)
            .output()
            .wrap_err("Failed to execute SSH command")
    }

    fn run_raw(&self, args: &[&str]) -> Result<Output> {
        let mut cmd = Command::new("ssh");
        cmd.args(self.ssh_args());
        for arg in args {
            cmd.arg(arg);
        }
        cmd.output().wrap_err("Failed to execute SSH command")
    }

    fn rsync_e_arg(&self) -> String {
        let mux = SSH_MUX_OPTIONS
            .iter()
            .map(|(k, v)| format!("-o {}={}", k, v))
            .collect::<Vec<_>>()
            .join(" ");
        let key = shell_escape::escape(self.ssh_key.display().to_string().into());
        format!("ssh {} -i {} -p {}", mux, key, self.host.port)
    }

    fn scp_args(&self) -> Vec<OsString> {
        let mut args = Self::mux_args();
        args.extend([
            "-i".into(),
            self.ssh_key.into(),
            "-P".into(),
            self.host.port.to_string().into(),
        ]);
        args
    }

    fn scp_to(&self, local: &Path, remote: &str) -> Result<()> {
        let output = Command::new("scp")
            .args(self.scp_args())
            .arg(local)
            .arg(format!(
                "{}@{}:{}",
                self.host.user, self.host.address, remote
            ))
            .output()
            .wrap_err("Failed to upload file via scp")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let stderr = stderr.trim();
            if stderr.is_empty() {
                eyre::bail!("scp to {}:{} failed", self.host.address, remote);
            } else {
                eyre::bail!("scp to {}:{} failed: {}", self.host.address, remote, stderr);
            }
        }
        Ok(())
    }

    fn scp_from(&self, remote: &str, local: &Path) -> Result<()> {
        let output = Command::new("scp")
            .args(self.scp_args())
            .arg(format!(
                "{}@{}:{}",
                self.host.user, self.host.address, remote
            ))
            .arg(local)
            .output()
            .wrap_err("Failed to download file via scp")?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let stderr = stderr.trim();
            if stderr.is_empty() {
                eyre::bail!("scp from {}:{} failed", self.host.address, remote);
            } else {
                eyre::bail!(
                    "scp from {}:{} failed: {}",
                    self.host.address,
                    remote,
                    stderr
                );
            }
        }
        Ok(())
    }

    fn systemctl(&self, action: &str, service: &str) -> Result<()> {
        let status = Command::new("ssh")
            .args(self.ssh_args())
            .arg("sudo")
            .arg("systemctl")
            .arg(action)
            .arg(service)
            .status()
            .wrap_err_with(|| format!("Failed to {} service {}", action, service))?;
        if !status.success() {
            eyre::bail!("systemctl {} {} failed", action, service);
        }
        Ok(())
    }
}

const RSYNC_EXCLUDES: &[&str] = &[
    ".git",
    ".git/",
    "venv",
    "venv/",
    "node_modules",
    "node_modules/",
    "__pycache__",
    "__pycache__/",
    "*.pyc",
    "*.pyo",
    ".cache",
    ".cache/",
    ".Radicale.cache",
    ".Radicale.cache/",
    "*.tmp",
    "*.log",
    ".DS_Store",
];

#[derive(Subcommand)]
pub enum BackupCommands {
    #[command(alias = "c", about = "Create backup of application data")]
    Create {
        #[arg(short = 'H', long, help = "Target host")]
        host: Option<String>,
        #[arg(
            short,
            long,
            value_delimiter = ',',
            help = "Apps to backup (baikal,bichon,freshrss,headscale,navidrome,calibre,webdav,yourls,paperless). Default: all"
        )]
        apps: Option<Vec<String>>,
        #[arg(short, long, help = "Backup destination directory")]
        dest: Option<PathBuf>,
        #[arg(
            short = 'k',
            long,
            help = "SSH private key (default: ~/.ssh/identities/{user}_{host})"
        )]
        ssh_key: Option<PathBuf>,
        #[arg(long, help = "Include music files in Navidrome backup (large, slow)")]
        include_music: bool,
        #[arg(short = 'n', long, help = "Dry run (show what would be backed up)")]
        dry_run: bool,
        #[arg(short, long, help = "Show detailed progress and paths")]
        verbose: bool,
    },
    #[command(alias = "ls", about = "List available backups")]
    List {
        #[arg(short = 'H', long, help = "Filter by host")]
        host: Option<String>,
        #[arg(short, long, help = "Filter by app")]
        app: Option<String>,
        #[arg(
            short,
            long,
            value_enum,
            default_value = "table",
            help = "Output format"
        )]
        format: OutputFormat,
    },
    #[command(alias = "r", about = "Restore from backup")]
    Restore {
        #[arg(help = "Backup timestamp (YYYY-MM-DD_HH-MM-SS) or 'latest'")]
        backup_id: String,
        #[arg(short = 'H', long, help = "Target host")]
        host: Option<String>,
        #[arg(
            short = 'F',
            long,
            help = "Source host (for cross-host restore/migration)"
        )]
        from_host: Option<String>,
        #[arg(
            short,
            long,
            value_delimiter = ',',
            help = "Apps to restore (baikal,bichon,freshrss,headscale,navidrome,calibre,webdav,yourls,paperless). Default: all"
        )]
        apps: Option<Vec<String>>,
        #[arg(
            short = 'k',
            long,
            help = "SSH private key (default: ~/.ssh/identities/{user}_{host})"
        )]
        ssh_key: Option<PathBuf>,
        #[arg(short = 'n', long, help = "Dry run (show what would be restored)")]
        dry_run: bool,
        #[arg(short = 'y', long, help = "Skip confirmation prompt")]
        yes: bool,
        #[arg(
            long,
            help = "UNSAFE: Skip Ansible playbook run (services will fail without correct permissions)"
        )]
        skip_playbook_unsafe: bool,
    },
    #[command(alias = "eo", about = "Export FreshRSS feeds to OPML file")]
    ExportOpml {
        #[arg(short = 'H', long, help = "Target host")]
        host: Option<String>,
        #[arg(short, long, help = "Output OPML file path")]
        output: PathBuf,
        #[arg(
            short = 'k',
            long,
            help = "SSH private key (default: ~/.ssh/identities/{user}_{host})"
        )]
        ssh_key: Option<PathBuf>,
        #[arg(long, default_value = "admin", help = "FreshRSS username")]
        user: String,
    },
    #[command(alias = "p", about = "Push backups to offsite restic repository")]
    Push {
        #[arg(short = 'H', long, help = "Filter backups by host")]
        host: Option<String>,
        #[arg(short, long, help = "Specific backup timestamp (default: latest)")]
        backup_id: Option<String>,
    },
    #[command(about = "Prune old snapshots from offsite restic repository")]
    Prune {
        #[arg(short = 'n', long, help = "Show what would be pruned without removing")]
        dry_run: bool,
    },
    #[command(alias = "io", about = "Import OPML file to FreshRSS")]
    ImportOpml {
        #[arg(short = 'H', long, help = "Target host")]
        host: Option<String>,
        #[arg(short, long, help = "OPML file to import")]
        input: PathBuf,
        #[arg(
            short = 'k',
            long,
            help = "SSH private key (default: ~/.ssh/identities/{user}_{host})"
        )]
        ssh_key: Option<PathBuf>,
        #[arg(long, default_value = "admin", help = "FreshRSS username")]
        user: String,
    },
}

#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum OutputFormat {
    Table,
    Json,
    Yaml,
}

#[derive(Debug)]
pub struct DbBackupConfig {
    pub db_name: &'static str,
    pub remote_dump_path: &'static str,
}

pub struct AppBackupConfig {
    pub name: &'static str,
    pub systemd_services: Vec<&'static str>,
    pub paths: Vec<&'static str>,
    pub owner: Option<(&'static str, &'static str)>,
    pub db: Option<DbBackupConfig>,
}

pub struct RestoreOptions {
    pub backup_id: String,
    pub host_arg: Option<String>,
    pub from_host_arg: Option<String>,
    pub apps: Option<Vec<String>>,
    pub ssh_key: Option<PathBuf>,
    pub dry_run: bool,
    pub yes: bool,
    pub skip_playbook_unsafe: bool,
}

impl AppBackupConfig {
    pub fn all() -> Vec<Self> {
        vec![
            Self::baikal(),
            Self::bichon(),
            Self::freshrss(),
            Self::headscale(),
            Self::navidrome(false),
            Self::calibre(),
            Self::webdav(),
            Self::yourls(),
            Self::paperless(),
        ]
    }

    pub fn by_name(name: &str, include_music: bool) -> Option<Self> {
        match name {
            "baikal" => Some(Self::baikal()),
            "bichon" => Some(Self::bichon()),
            "freshrss" => Some(Self::freshrss()),
            "headscale" => Some(Self::headscale()),
            "navidrome" => Some(Self::navidrome(include_music)),
            "calibre" => Some(Self::calibre()),
            "vdirsyncer" => Some(Self::vdirsyncer()),
            "webdav" => Some(Self::webdav()),
            "yourls" => Some(Self::yourls()),
            "paperless" => Some(Self::paperless()),
            _ => None,
        }
    }

    fn baikal() -> Self {
        Self {
            name: "baikal",
            systemd_services: vec![],
            paths: vec!["/opt/baikal/Specific"],
            owner: Some(("baikal", "baikal")),
            db: None,
        }
    }

    fn bichon() -> Self {
        Self {
            name: "bichon",
            systemd_services: vec!["bichon"],
            paths: vec!["/opt/bichon/data"],
            owner: Some(("bichon", "bichon")),
            db: None,
        }
    }

    fn headscale() -> Self {
        Self {
            name: "headscale",
            systemd_services: vec!["headscale"],
            paths: vec!["/var/lib/headscale"],
            owner: Some(("headscale", "headscale")),
            db: None,
        }
    }

    fn freshrss() -> Self {
        Self {
            name: "freshrss",
            systemd_services: vec!["freshrss"],
            paths: vec!["/var/lib/freshrss", "/opt/freshrss/data"],
            owner: Some(("freshrss", "freshrss")),
            db: None,
        }
    }

    fn navidrome(include_music: bool) -> Self {
        let mut paths = vec!["/var/lib/navidrome", "/etc/navidrome"];

        if include_music {
            paths.push("/srv/music");
        }

        Self {
            name: "navidrome",
            systemd_services: vec!["navidrome"],
            paths,
            owner: Some(("navidrome", "navidrome")),
            db: None,
        }
    }

    fn calibre() -> Self {
        Self {
            name: "calibre",
            systemd_services: vec!["calibre"],
            paths: vec!["/srv/calibre", "/opt/calibre", "/home/calibre"],
            owner: Some(("calibre", "calibre")),
            db: None,
        }
    }

    fn vdirsyncer() -> Self {
        Self {
            name: "vdirsyncer",
            systemd_services: vec!["vdirsyncer.timer", "vdirsyncer.service"],
            paths: vec!["/var/lib/vdirsyncer"],
            owner: Some(("vdirsyncer", "vdirsyncer")),
            db: None,
        }
    }

    fn webdav() -> Self {
        Self {
            name: "webdav",
            systemd_services: vec![],
            paths: vec!["/var/www/webdav-files"],
            owner: None,
            db: None,
        }
    }

    fn yourls() -> Self {
        Self {
            name: "yourls",
            systemd_services: vec![],
            paths: vec!["/var/www/yourls"],
            owner: Some(("www-data", "www-data")),
            db: None,
        }
    }

    fn paperless() -> Self {
        Self {
            name: "paperless",
            systemd_services: vec![
                "paperless-webserver",
                "paperless-consumer",
                "paperless-task-queue",
                "paperless-scheduler",
            ],
            paths: vec!["/opt/paperless/data", "/opt/paperless/media"],
            owner: Some(("paperless", "paperless")),
            db: Some(DbBackupConfig {
                db_name: "paperless",
                remote_dump_path: "/tmp/paperless_db.dump",
            }),
        }
    }
}

pub fn run_backup_create(
    host_arg: Option<String>,
    apps: Option<Vec<String>>,
    dest: Option<PathBuf>,
    ssh_key: Option<PathBuf>,
    include_music: bool,
    dry_run: bool,
    verbose: bool,
) -> Result<()> {
    let host = get_host_or_select(host_arg)?;
    let backup_dest = dest.unwrap_or_else(default_backup_dir);

    let ssh_key_path = resolve_ssh_key_path(&host, ssh_key)?;

    if verbose {
        eprintln!("Using SSH key: {}", ssh_key_path.display());
        eprintln!("Backing up to: {}", backup_dest.join(&host.name).display());
    } else {
        let short_dest = backup_dest
            .to_string_lossy()
            .replace(&std::env::var("HOME").unwrap_or_default(), "~");
        eprintln!("Backing up {} → {}", host.name, short_dest);
    }

    let app_configs = match apps {
        Some(app_names) => app_names
            .iter()
            .filter_map(|name| AppBackupConfig::by_name(name, include_music))
            .collect(),
        None => AppBackupConfig::all(),
    };

    if app_configs.is_empty() {
        eyre::bail!("No valid apps specified for backup");
    }

    if verbose {
        let app_names: Vec<&str> = app_configs.iter().map(|c| c.name).collect();
        eprintln!("Apps: {}\n", app_names.join(", "));
    }

    if dry_run {
        eprintln!("\n✓ Dry run completed (no changes made)");
        return Ok(());
    }
    let start_time = Instant::now();
    let timestamp = Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string();

    let mut results = Vec::new();
    for config in app_configs {
        match backup_app(
            &host,
            &config,
            &backup_dest,
            &ssh_key_path,
            &timestamp,
            verbose,
        ) {
            Ok(size) => results.push((config.name, true, Some(size), None)),
            Err(e) => {
                eprintln!("✗ {} backup failed: {}", config.name, e);
                results.push((config.name, false, None, Some(e.to_string())));
            }
        }
    }

    let elapsed = start_time.elapsed().as_secs();
    let total_size: u64 = results.iter().filter_map(|(_, _, size, _)| *size).sum();
    let successful = results.iter().filter(|(_, ok, _, _)| *ok).count();
    let failed = results.iter().filter(|(_, ok, _, _)| !*ok).count();

    eprintln!();

    if verbose {
        #[derive(Tabled)]
        struct BackupResult {
            #[tabled(rename = "App")]
            app: String,
            #[tabled(rename = "Status")]
            status: String,
            #[tabled(rename = "Size")]
            size: String,
        }

        let table_data: Vec<BackupResult> = results
            .iter()
            .map(|(app, ok, size, err)| BackupResult {
                app: app.to_string(),
                status: if *ok {
                    "✓".to_string()
                } else {
                    format!("✗ {}", err.as_ref().unwrap())
                },
                size: size.map(output::format_size).unwrap_or_default(),
            })
            .collect();

        output::print_table(&table_data);
        eprintln!();
    }

    if failed == 0 {
        eprintln!(
            "Backed up {} app{} ({}) in {}",
            successful,
            if successful == 1 { "" } else { "s" },
            output::format_size(total_size),
            output::format_duration(elapsed)
        );
    } else {
        eprintln!(
            "Backup completed with errors ({} of {} apps failed)",
            failed,
            successful + failed
        );
    }

    if verbose {
        eprintln!(
            "Location: {}/{}/",
            backup_dest.join(&host.name).display(),
            timestamp
        );
    }

    if failed > 0 {
        eyre::bail!("{} backup(s) failed", failed);
    }

    Ok(())
}

pub fn run_backup_list(
    host_filter: Option<String>,
    app_filter: Option<String>,
    format: OutputFormat,
) -> Result<()> {
    let backup_root = default_backup_dir();

    if !backup_root.exists() {
        output::info("No backups found. Backup directory does not exist:");
        eprintln!("  {}", backup_root.display());
        return Ok(());
    }

    let backups = discover_backups(&backup_root, host_filter.as_deref(), app_filter.as_deref())?;

    if backups.is_empty() {
        output::info("No backups found");
        return Ok(());
    }

    match format {
        OutputFormat::Table => print_backups_table(&backups),
        OutputFormat::Json => print_backups_json(&backups)?,
        OutputFormat::Yaml => print_backups_yaml(&backups)?,
    }

    Ok(())
}

#[derive(Debug)]
struct BackupEntry {
    host: String,
    app: String,
    timestamp: String,
    path: PathBuf,
    size_bytes: u64,
}

#[derive(Tabled)]
struct BackupDisplay {
    #[tabled(rename = "HOST")]
    host: String,
    #[tabled(rename = "APP")]
    app: String,
    #[tabled(rename = "TIMESTAMP")]
    timestamp: String,
    #[tabled(rename = "SIZE")]
    size: String,
}

impl From<&BackupEntry> for BackupDisplay {
    fn from(entry: &BackupEntry) -> Self {
        Self {
            host: entry.host.clone(),
            app: entry.app.clone(),
            timestamp: entry.timestamp.clone(),
            size: output::format_size(entry.size_bytes),
        }
    }
}

fn discover_backups(
    backup_root: &Path,
    host_filter: Option<&str>,
    app_filter: Option<&str>,
) -> Result<Vec<BackupEntry>> {
    let mut backups = Vec::new();

    if !backup_root.is_dir() {
        return Ok(backups);
    }

    for host_entry in fs::read_dir(backup_root)
        .wrap_err_with(|| format!("Failed to read backup directory: {}", backup_root.display()))?
    {
        let host_entry = host_entry?;
        if !host_entry.file_type()?.is_dir() {
            continue;
        }

        let host_name = host_entry.file_name().to_string_lossy().to_string();

        if let Some(filter) = host_filter
            && host_name != filter
        {
            continue;
        }

        for timestamp_entry in fs::read_dir(host_entry.path())? {
            let timestamp_entry = timestamp_entry?;
            let timestamp_path = timestamp_entry.path();

            if timestamp_path.is_symlink() {
                continue;
            }

            if !timestamp_path.is_dir() {
                continue;
            }

            let timestamp = timestamp_entry.file_name().to_string_lossy().to_string();

            if !timestamp.contains('_') || !timestamp.starts_with("20") {
                continue;
            }

            for app_entry in fs::read_dir(timestamp_path)? {
                let app_entry = app_entry?;
                let app_path = app_entry.path();

                if !app_path.is_dir() {
                    continue;
                }

                let app_name = app_entry.file_name().to_string_lossy().to_string();

                if let Some(filter) = app_filter
                    && app_name != filter
                {
                    continue;
                }

                let size_bytes = calculate_dir_size(&app_path)?;

                backups.push(BackupEntry {
                    host: host_name.clone(),
                    app: app_name.clone(),
                    timestamp: timestamp.clone(),
                    path: app_path,
                    size_bytes,
                });
            }
        }
    }

    backups.sort_by(|a, b| {
        a.host
            .cmp(&b.host)
            .then_with(|| b.timestamp.cmp(&a.timestamp))
            .then_with(|| a.app.cmp(&b.app))
    });

    Ok(backups)
}

fn calculate_dir_size(path: &Path) -> Result<u64> {
    let mut total = 0u64;

    if path.is_file() {
        return Ok(path.metadata()?.len());
    }

    if path.is_dir() {
        for entry in fs::read_dir(path)? {
            let entry = entry?;
            let metadata = entry.metadata()?;

            if metadata.is_file() {
                total += metadata.len();
            } else if metadata.is_dir() {
                total += calculate_dir_size(&entry.path())?;
            }
        }
    }

    Ok(total)
}

fn print_backups_table(backups: &[BackupEntry]) {
    let display_backups: Vec<BackupDisplay> = backups.iter().map(BackupDisplay::from).collect();
    output::print_table(&display_backups);
    println!("\nTotal: {} backup(s)", backups.len());
}

fn print_backups_json(backups: &[BackupEntry]) -> Result<()> {
    let json = serde_json::to_string_pretty(
        &backups
            .iter()
            .map(|b| {
                serde_json::json!({
                    "host": b.host,
                    "app": b.app,
                    "timestamp": b.timestamp,
                    "path": b.path,
                    "size_bytes": b.size_bytes,
                })
            })
            .collect::<Vec<_>>(),
    )?;

    println!("{}", json);
    Ok(())
}

fn print_backups_yaml(backups: &[BackupEntry]) -> Result<()> {
    let yaml = serde_yaml::to_string(
        &backups
            .iter()
            .map(|b| {
                serde_yaml::to_value(serde_json::json!({
                    "host": b.host,
                    "app": b.app,
                    "timestamp": b.timestamp,
                    "path": b.path,
                    "size_bytes": b.size_bytes,
                }))
                .unwrap()
            })
            .collect::<Vec<_>>(),
    )?;

    println!("{}", yaml);
    Ok(())
}

fn format_size(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if bytes >= GB {
        format!("{:.2} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.2} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.2} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} B", bytes)
    }
}

pub fn run_backup_restore(opts: RestoreOptions) -> Result<()> {
    let host = get_host_or_select(opts.host_arg)?;
    let backup_root = default_backup_dir();

    let (source_host_name, is_cross_host) = match opts.from_host_arg {
        Some(ref from_host) => (from_host.clone(), from_host != &host.name),
        None => (host.name.clone(), false),
    };

    let host_backup_dir = backup_root.join(&source_host_name);

    let ssh_key_path = resolve_ssh_key_path(&host, opts.ssh_key)?;
    eprintln!("Using SSH key: {}", ssh_key_path.display());

    if !host_backup_dir.exists() {
        eyre::bail!("No backups found for host: {}", source_host_name);
    }

    let app_names = opts.apps.unwrap_or_else(|| {
        vec![
            "baikal".to_string(),
            "freshrss".to_string(),
            "navidrome".to_string(),
            "calibre".to_string(),
            "webdav".to_string(),
            "yourls".to_string(),
        ]
    });

    let mut restore_plan = Vec::new();

    for app_name in &app_names {
        let backup_path = if opts.backup_id == "latest" {
            let mut timestamps: Vec<_> = fs::read_dir(&host_backup_dir)?
                .filter_map(Result::ok)
                .filter(|e| e.path().is_dir())
                .filter(|e| !e.path().is_symlink())
                .filter(|e| {
                    let name = e.file_name().to_string_lossy().to_string();
                    name.contains('_') && name.starts_with("20")
                })
                .collect();

            timestamps.sort_by_key(|b| std::cmp::Reverse(b.file_name()));

            let latest_timestamp = timestamps.first();

            if let Some(timestamp_entry) = latest_timestamp {
                let app_path = timestamp_entry.path().join(app_name);
                if !app_path.exists() {
                    eprintln!(
                        "⚠ No backup found for {} in latest backup, skipping",
                        app_name
                    );
                    continue;
                }
                app_path
            } else {
                eprintln!("⚠ No backups found for {}, skipping", app_name);
                continue;
            }
        } else {
            let backup_path = host_backup_dir.join(&opts.backup_id).join(app_name);
            if !backup_path.exists() {
                eprintln!(
                    "⚠ Backup {} not found for {}, skipping",
                    opts.backup_id, app_name
                );
                continue;
            }
            backup_path
        };

        restore_plan.push((app_name.clone(), backup_path));
    }

    if restore_plan.is_empty() {
        eyre::bail!("No backups to restore");
    }

    let total_backup_size: u64 = restore_plan
        .iter()
        .map(|(_, path)| calculate_dir_size(path).unwrap_or(0))
        .sum();

    if is_cross_host {
        validate_cross_host_restore(&host, &ssh_key_path, &app_names, total_backup_size)?;
    }

    eprintln!("\n=== Restore Plan ===");
    if is_cross_host {
        eprintln!("Source: {} (backup: {})", source_host_name, opts.backup_id);
        eprintln!("Target: {} ({}:{})", host.name, host.address, host.port);
        eprintln!("\n⚠  CROSS-HOST RESTORE WARNING");
        eprintln!(
            "   This will restore data from '{}' to '{}'",
            source_host_name, host.name
        );
        eprintln!("   Existing data on '{}' will be OVERWRITTEN", host.name);
    } else {
        eprintln!("Host: {}", host.name);
        eprintln!("Backup ID: {}", opts.backup_id);
    }
    eprintln!("\nApps to restore:");
    for (app, path) in &restore_plan {
        eprintln!("  - {:<12} from {}", app, path.display());
    }

    if opts.dry_run {
        eprintln!("\n✓ Dry run completed (no changes made)");
        return Ok(());
    }

    if is_cross_host && !opts.yes {
        eprintln!("\n⚠  DANGER: Cross-host restore requires explicit confirmation");
        eprintln!("   Type the target host name '{}' to confirm:", host.name);

        let confirmation: String = dialoguer::Input::new()
            .with_prompt("Target host name")
            .interact_text()?;

        if confirmation.trim() != host.name {
            eprintln!("✗ Confirmation failed. Restore cancelled");
            return Ok(());
        }
    } else if !opts.yes {
        eprintln!("\n⚠ WARNING: This will overwrite existing data on the remote host!");
        if !dialoguer::Confirm::new()
            .with_prompt("Continue with restore?")
            .default(false)
            .interact()?
        {
            eprintln!("Restore cancelled");
            return Ok(());
        }
    }

    if is_cross_host && opts.yes {
        eprintln!("\n⚠  Cross-host restore with --yes flag");
        eprintln!("   Waiting 3 seconds (press Ctrl+C to cancel)...");
        std::thread::sleep(std::time::Duration::from_secs(3));
    }

    if is_cross_host && !opts.dry_run {
        eprintln!("\n--- Creating Emergency Backup ---");
        eprintln!(
            "  Backing up current state of '{}' before cross-host restore",
            host.name
        );

        let emergency_timestamp = Utc::now().format("%Y-%m-%d_%H-%M-%S").to_string();
        let emergency_backup_name = format!("pre-migration-{}", emergency_timestamp);

        match run_backup_create(
            Some(host.name.clone()),
            Some(app_names.clone()),
            Some(backup_root.clone()),
            Some(ssh_key_path.clone()),
            false,
            false,
            false,
        ) {
            Ok(_) => {
                eprintln!("  ✓ Emergency backup created: {}", emergency_backup_name);
                eprintln!(
                    "    Location: {}/{}/{}/",
                    backup_root.display(),
                    host.name,
                    emergency_timestamp
                );
            }
            Err(e) => {
                eprintln!("  ⚠ Failed to create emergency backup: {}", e);
                eprintln!("    Continue without emergency backup? This is DANGEROUS!");

                if !dialoguer::Confirm::new()
                    .with_prompt("Continue without emergency backup?")
                    .default(false)
                    .interact()?
                {
                    eprintln!("Restore cancelled");
                    return Ok(());
                }
            }
        }
    }

    let phase_label = if opts.skip_playbook_unsafe || opts.dry_run {
        ""
    } else {
        "[1/2] "
    };
    eprintln!("\n{}Starting restore...", phase_label);

    for (app_name, backup_path) in restore_plan {
        restore_app(&host, &app_name, &backup_path, &ssh_key_path)?;
    }

    eprintln!("\n✓ All restores completed successfully");

    if !opts.skip_playbook_unsafe && !opts.dry_run {
        eprintln!("\n[2/2] Running Ansible playbooks to fix permissions...");

        let assets = crate::ansible_assets::AnsibleAssets::prepare()?;
        let apps_playbook = assets.playbooks_dir().join("apps.yml");

        if !apps_playbook.exists() {
            eprintln!("⚠ Ansible playbook not found: {}", apps_playbook.display());
            eprintln!("  Services may fail due to incorrect file ownership!");
            eprintln!("  Run manually: cd ansible && ansible-playbook playbooks/apps.yml");
        } else {
            let tags: Vec<String> = app_names.iter().map(|s| s.to_string()).collect();

            let inventory_host = crate::services::ansible_runner::InventoryHost {
                name: host.name.clone(),
                address: host.address.clone(),
                port: host.port,
                user: host.user.clone(),
            };

            match crate::services::ansible_runner::run_playbook(
                &apps_playbook,
                &inventory_host,
                false,
                Some(&tags),
                None,
                None,
                false,
                false,
            ) {
                Ok(result) if result.success => {
                    eprintln!("✓ Ansible playbooks completed successfully");
                    eprintln!("  File permissions have been corrected");
                }
                Ok(result) => {
                    eprintln!(
                        "⚠ Ansible playbook failed (exit code: {})",
                        result.exit_code
                    );
                    eprintln!("  Services may fail due to incorrect file ownership!");
                    eprintln!(
                        "  Fix manually: cd ansible && ansible-playbook playbooks/apps.yml --tags {}",
                        tags.join(",")
                    );
                }
                Err(e) => {
                    eprintln!("⚠ Failed to run Ansible playbook: {}", e);
                    eprintln!("  Services may fail due to incorrect file ownership!");
                    eprintln!(
                        "  Fix manually: cd ansible && ansible-playbook playbooks/apps.yml --tags {}",
                        tags.join(",")
                    );
                }
            }
        }
    } else if opts.skip_playbook_unsafe && !opts.dry_run {
        eprintln!("\n⚠️  WARNING: Skipped Ansible playbooks (--skip-playbook-unsafe)");
        eprintln!("⚠️  Services WILL fail until you run:");
        eprintln!(
            "     cd ansible && ansible-playbook playbooks/apps.yml --tags {}",
            app_names.join(",")
        );
    }

    if is_cross_host {
        eprintln!("\n=== Post-Restore Actions Required ===");
        eprintln!("  Cross-host restore completed. Manual verification needed:\n");
        let all_services: Vec<&str> = app_names
            .iter()
            .filter_map(|name| AppBackupConfig::by_name(name, false))
            .flat_map(|cfg| cfg.systemd_services)
            .collect();
        if !all_services.is_empty() {
            eprintln!("  1. Verify services are running:");
            eprintln!(
                "     ssh {}@{} 'systemctl status {}'",
                host.user,
                host.address,
                all_services.join(" ")
            );
        }
        eprintln!("\n  2. Check service logs for errors:");
        for app_name in &app_names {
            if let Some(cfg) = AppBackupConfig::by_name(app_name, false) {
                for service in &cfg.systemd_services {
                    eprintln!(
                        "     ssh {}@{} 'journalctl -u {} --since \"5 minutes ago\" | grep -i error'",
                        host.user, host.address, service
                    );
                }
            }
        }
        eprintln!("\n  3. Update DNS records if hostnames changed");
        eprintln!("\n  4. Verify SSL certificates are valid for new domain\n");

        eprintln!("  ⚠  App-specific notes:");
        for app_name in &app_names {
            match app_name.as_str() {
                "navidrome" => {
                    eprintln!("     - Navidrome: May need to rescan music library");
                    eprintln!("       Fix: Trigger rescan from web UI or restart service");
                }
                "freshrss" => {
                    eprintln!(
                        "     - FreshRSS: Database paths should be fine, but verify feeds update"
                    );
                }
                _ => {}
            }
        }
    }

    Ok(())
}

fn restore_app(host: &Host, app_name: &str, backup_path: &Path, ssh_key: &Path) -> Result<()> {
    eprintln!("\n--- Restoring {} ---", app_name);

    let config = AppBackupConfig::by_name(app_name, false)
        .ok_or_else(|| eyre::eyre!("Unknown app: {}", app_name))?;

    let mut stopped_services: Vec<&str> = Vec::new();
    for service in &config.systemd_services {
        eprintln!("  Stopping service: {}", service);
        if let Err(e) = remote_systemctl(host, ssh_key, "stop", service) {
            for previously_stopped in &stopped_services {
                let _ = remote_systemctl(host, ssh_key, "start", previously_stopped);
            }
            return Err(e).wrap_err_with(|| format!("Failed to stop service {}", service));
        }
        stopped_services.push(service);
    }

    let restore_result = (|| -> Result<()> {
        for remote_path in &config.paths {
            eprintln!("  Restoring to: {}", remote_path);
            rsync_to_remote(host, ssh_key, backup_path, remote_path)?;
        }

        if let Some((user, group)) = config.owner {
            eprintln!("  Setting ownership to {}:{}", user, group);
            for remote_path in &config.paths {
                set_remote_ownership(host, ssh_key, remote_path, user, group)?;
            }
        }

        if let Some(db) = &config.db {
            let local_dump = backup_path.join("db.dump");
            if local_dump.exists() {
                eprintln!("  Restoring database: {}", db.db_name);
                scp_to_remote(host, ssh_key, &local_dump, db.remote_dump_path)?;
                remote_ssh_command(host, ssh_key, &format!("chmod 644 {}", db.remote_dump_path))?;
                let pg_result = remote_pg_restore(host, ssh_key, db);
                remote_pg_dump_cleanup(host, ssh_key, db);
                pg_result?;

                if config.name == "paperless" {
                    eprintln!("  Running database migrations");
                    let migrate_cmd = "sudo -u paperless /opt/paperless/src/venv/bin/python3 manage.py migrate --no-input";
                    let migrate_result = remote_ssh_command(
                        host,
                        ssh_key,
                        &format!(
                            "cd /opt/paperless/src && PAPERLESS_CONFIGURATION_PATH=/opt/paperless/paperless.conf {}",
                            migrate_cmd
                        ),
                    );
                    if let Err(e) = migrate_result {
                        output::warn(&format!("Migration warning: {}", e));
                    }
                }
            } else {
                output::warn(&format!(
                    "No database dump found at {}, skipping db restore",
                    local_dump.display()
                ));
            }
        }

        Ok(())
    })();

    let mut start_failures: Vec<String> = Vec::new();
    for service in &config.systemd_services {
        eprintln!("  Starting service: {}", service);
        if let Err(e) = remote_systemctl(host, ssh_key, "start", service) {
            start_failures.push(format!("{}: {}", service, e));
        }
    }

    match restore_result {
        Ok(()) => {
            if !start_failures.is_empty() {
                eyre::bail!(
                    "Restore of {} succeeded but failed to restart services:\n  {}",
                    app_name,
                    start_failures.join("\n  ")
                );
            }
        }
        Err(e) => {
            if !start_failures.is_empty() {
                eyre::bail!(
                    "Restore of {} failed: {}\nAdditionally, failed to restart services:\n  {}",
                    app_name,
                    e,
                    start_failures.join("\n  ")
                );
            }
            return Err(e);
        }
    }

    eprintln!("✓ {} restore completed", app_name);
    Ok(())
}

fn rsync_to_remote(
    host: &Host,
    ssh_key: &Path,
    local_path: &Path,
    remote_path: &str,
) -> Result<()> {
    let local_source = local_path.join(remote_path.trim_start_matches('/'));

    if !local_source.exists() {
        eprintln!("    (skipping {} - not in backup)", remote_path);
        return Ok(());
    }

    let parent_dir = std::path::Path::new(remote_path)
        .parent()
        .ok_or_else(|| eyre::eyre!("Invalid remote path: {}", remote_path))?;

    let session = SshSession::new(host, ssh_key);
    let _ = Command::new("ssh")
        .args(session.ssh_args())
        .arg("sudo")
        .arg("mkdir")
        .arg("-p")
        .arg(parent_dir)
        .status();

    let mut cmd = Command::new("rsync");
    cmd.arg("-az")
        .arg("--delete")
        .arg("--rsync-path=sudo rsync")
        .stdout(Stdio::null())
        .stderr(Stdio::null());

    for pattern in RSYNC_EXCLUDES {
        cmd.arg(format!("--exclude={}", pattern));
    }

    cmd.arg("-e")
        .arg(session.rsync_e_arg())
        .arg(format!("{}/", local_source.display()))
        .arg(format!("{}@{}:{}", host.user, host.address, remote_path));

    let status = cmd.status().wrap_err("Failed to execute rsync")?;

    if !status.success() {
        eyre::bail!("rsync failed for {}", remote_path);
    }

    Ok(())
}

pub fn run_export_opml(
    host_arg: Option<String>,
    output: PathBuf,
    ssh_key: Option<PathBuf>,
    user: String,
) -> Result<()> {
    let host = get_host_or_select(host_arg)?;
    let ssh_key_path = resolve_ssh_key_path(&host, ssh_key)?;
    eprintln!("Using SSH key: {}", ssh_key_path.display());

    eprintln!("Exporting OPML from FreshRSS");
    eprintln!("  Host: {}", host.name);
    eprintln!("  User: {}", user);
    eprintln!("  Output: {}", output.display());

    let remote_cmd = format!(
        "cd /opt/freshrss && sudo -u freshrss ./cli/export-opml-for-user.php --user {}",
        user
    );

    let session = SshSession::new(&host, &ssh_key_path);
    let opml_output = session.run(&remote_cmd)?;

    if !opml_output.status.success() {
        let stderr = String::from_utf8_lossy(&opml_output.stderr);
        eyre::bail!("OPML export failed: {}", stderr);
    }

    fs::write(&output, &opml_output.stdout)
        .wrap_err_with(|| format!("Failed to write OPML to {}", output.display()))?;

    eprintln!("✓ OPML exported successfully");
    eprintln!("  Saved to: {}", output.display());

    Ok(())
}

pub fn run_import_opml(
    host_arg: Option<String>,
    input: PathBuf,
    ssh_key: Option<PathBuf>,
    user: String,
) -> Result<()> {
    let host = get_host_or_select(host_arg)?;
    let ssh_key_path = resolve_ssh_key_path(&host, ssh_key)?;
    eprintln!("Using SSH key: {}", ssh_key_path.display());

    if !input.exists() {
        eyre::bail!("OPML file not found: {}", input.display());
    }

    eprintln!("Importing OPML to FreshRSS");
    eprintln!("  Host: {}", host.name);
    eprintln!("  User: {}", user);
    eprintln!("  Input: {}", input.display());

    let remote_opml_path = format!("/tmp/freshrss_import_{}.opml", user);

    eprintln!("  Uploading OPML file...");
    let session = SshSession::new(&host, &ssh_key_path);
    session
        .scp_to(&input, &remote_opml_path)
        .wrap_err("Failed to upload OPML file")?;

    eprintln!("  Importing feeds...");
    let import_cmd = format!(
        "cd /opt/freshrss && sudo -u freshrss ./cli/import-for-user.php --user {} --filename {} && rm {}",
        user, remote_opml_path, remote_opml_path
    );

    let import_output = session
        .run(&import_cmd)
        .wrap_err("Failed to execute import command")?;

    if !import_output.status.success() {
        let stderr = String::from_utf8_lossy(&import_output.stderr);
        eyre::bail!("OPML import failed: {}", stderr);
    }

    let stdout = String::from_utf8_lossy(&import_output.stdout);
    eprintln!("{}", stdout);

    eprintln!("✓ OPML imported successfully");

    Ok(())
}

fn load_restic_config() -> Result<(String, String)> {
    let config = UserConfig::load()?;
    let missing = config.validate_required(&["restic_repository", "restic_password"]);
    if !missing.is_empty() {
        eyre::bail!(
            "Missing restic config: {}. Set with `auberge config set <key> <value>`",
            missing.join(", ")
        );
    }
    Ok((
        config.get("restic_repository").unwrap(),
        config.get("restic_password").unwrap(),
    ))
}

pub fn run_backup_push(host_filter: Option<String>, backup_id: Option<String>) -> Result<()> {
    let (restic_repo, restic_password) = load_restic_config()?;

    let backup_root = default_backup_dir();
    if !backup_root.exists() {
        eyre::bail!("No backups found. Run `auberge backup create` first.");
    }

    let backup_dir =
        resolve_backup_dir(&backup_root, host_filter.as_deref(), backup_id.as_deref())?;

    output::info(&format!("Pushing {} to restic", backup_dir.display()));

    let spinner = output::spinner("Checking restic repository");
    let snapshots_check = Command::new("restic")
        .arg("snapshots")
        .arg("--json")
        .env("RESTIC_REPOSITORY", &restic_repo)
        .env("RESTIC_PASSWORD", &restic_password)
        .output();

    let needs_init = match snapshots_check {
        Ok(output) if output.status.success() => false,
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            if stderr.contains("Is there a repository at the following location")
                || stderr.contains("unable to open config file")
            {
                true
            } else {
                eyre::bail!("restic snapshots failed: {}", stderr.trim());
            }
        }
        Err(_) => eyre::bail!("restic not found. Install restic: https://restic.net"),
    };

    if needs_init {
        spinner.set_message("Initializing restic repository".to_string());
        let init_output = Command::new("restic")
            .arg("init")
            .env("RESTIC_REPOSITORY", &restic_repo)
            .env("RESTIC_PASSWORD", &restic_password)
            .output()
            .wrap_err("Failed to initialize restic repository")?;

        if !init_output.status.success() {
            let stderr = String::from_utf8_lossy(&init_output.stderr);
            eyre::bail!("Failed to initialize restic repository: {}", stderr.trim());
        }
    }

    spinner.set_message(format!("Backing up {}", backup_dir.display()));
    let backup_output = Command::new("restic")
        .arg("backup")
        .arg(&backup_dir)
        .env("RESTIC_REPOSITORY", &restic_repo)
        .env("RESTIC_PASSWORD", &restic_password)
        .output()
        .wrap_err("Failed to run restic backup")?;

    spinner.finish_and_clear();

    if !backup_output.status.success() {
        let stderr = String::from_utf8_lossy(&backup_output.stderr);
        eyre::bail!("restic backup failed: {}", stderr.trim());
    }

    let stdout = String::from_utf8_lossy(&backup_output.stdout);
    let snapshot_id = stdout
        .lines()
        .find(|line| line.contains("snapshot") && line.contains("saved"))
        .unwrap_or("backup completed");

    output::success(&format!("Push complete: {}", snapshot_id.trim()));

    Ok(())
}

pub fn run_backup_prune(dry_run: bool) -> Result<()> {
    let (restic_repo, restic_password) = load_restic_config()?;

    let spinner = output::spinner("Pruning restic snapshots");

    let mut cmd = Command::new("restic");
    cmd.arg("forget")
        .arg("--keep-daily")
        .arg("7")
        .arg("--keep-weekly")
        .arg("4")
        .arg("--keep-monthly")
        .arg("12")
        .arg("--prune")
        .env("RESTIC_REPOSITORY", &restic_repo)
        .env("RESTIC_PASSWORD", &restic_password);

    if dry_run {
        cmd.arg("--dry-run");
    }

    let output = cmd.output().wrap_err("Failed to run restic forget")?;

    spinner.finish_and_clear();

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        eyre::bail!("restic prune failed: {}", stderr.trim());
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    if !stdout.is_empty() {
        eprintln!("{}", stdout.trim());
    }

    if dry_run {
        output::info("Dry run completed (no changes made)");
    } else {
        output::success("Prune complete");
    }

    Ok(())
}

fn resolve_backup_dir(
    backup_root: &Path,
    host_filter: Option<&str>,
    backup_id: Option<&str>,
) -> Result<PathBuf> {
    let host_dir = match host_filter {
        Some(host) => {
            let dir = backup_root.join(host);
            if !dir.exists() {
                eyre::bail!("No backups found for host: {}", host);
            }
            dir
        }
        None => {
            let mut hosts: Vec<_> = fs::read_dir(backup_root)?
                .filter_map(Result::ok)
                .filter(|e| e.path().is_dir())
                .collect();
            if hosts.is_empty() {
                eyre::bail!("No backups found");
            }
            if hosts.len() == 1 {
                hosts.remove(0).path()
            } else {
                let host_names: Vec<String> = hosts
                    .iter()
                    .map(|e| e.file_name().to_string_lossy().to_string())
                    .collect();
                let selection = select_item(
                    &host_names,
                    |h: &String| h.clone(),
                    "Select host backup to push",
                )?
                .ok_or_else(|| eyre::eyre!("No host selected"))?;
                backup_root.join(&selection)
            }
        }
    };

    match backup_id {
        Some(id) => {
            let dir = host_dir.join(id);
            if !dir.exists() {
                eyre::bail!("Backup not found: {}", dir.display());
            }
            Ok(dir)
        }
        None => {
            let mut timestamps: Vec<_> = fs::read_dir(&host_dir)?
                .filter_map(Result::ok)
                .filter(|e| e.path().is_dir() && !e.path().is_symlink())
                .filter(|e| {
                    let name = e.file_name().to_string_lossy().to_string();
                    name.contains('_') && name.starts_with("20")
                })
                .collect();

            timestamps.sort_by_key(|b| std::cmp::Reverse(b.file_name()));

            timestamps
                .first()
                .map(|e| e.path())
                .ok_or_else(|| eyre::eyre!("No backup timestamps found in {}", host_dir.display()))
        }
    }
}

fn get_host_or_select(host_arg: Option<String>) -> Result<Host> {
    match host_arg {
        Some(name) => HostManager::get_host(&name),
        None => {
            let hosts = HostManager::load_hosts()?;
            select_item(
                &hosts,
                |h: &Host| format!("{} ({}:{})", h.name, h.address, h.port),
                "Select host",
            )?
            .ok_or_else(|| eyre::eyre!("No host selected"))
        }
    }
}

fn default_backup_dir() -> PathBuf {
    dirs::data_local_dir()
        .map(|d| d.join("auberge").join("backups"))
        .unwrap_or_else(|| PathBuf::from("~/.local/share/auberge/backups"))
}

fn backup_app(
    host: &Host,
    config: &AppBackupConfig,
    backup_dest: &Path,
    ssh_key: &Path,
    timestamp: &str,
    verbose: bool,
) -> Result<u64> {
    let spinner = output::spinner(&format!("Backing up {}", config.name));
    let app_backup_dir = backup_dest
        .join(&host.name)
        .join(timestamp)
        .join(config.name);

    fs::create_dir_all(&app_backup_dir).wrap_err_with(|| {
        format!(
            "Failed to create backup directory: {}",
            app_backup_dir.display()
        )
    })?;

    let mut stopped_services: Vec<&str> = Vec::new();
    if !config.systemd_services.is_empty() {
        spinner.set_message(format!("Backing up {} (stopping services)", config.name));
        for service in &config.systemd_services {
            if let Err(e) = remote_systemctl(host, ssh_key, "stop", service) {
                for previously_stopped in &stopped_services {
                    let _ = remote_systemctl(host, ssh_key, "start", previously_stopped);
                }
                return Err(e).wrap_err_with(|| format!("Failed to stop service {}", service));
            }
            stopped_services.push(service);
        }
    }

    if let Some(db) = &config.db {
        spinner.set_message(format!("Backing up {} (dumping database)", config.name));
        if let Err(e) = remote_pg_dump(host, ssh_key, db) {
            remote_pg_dump_cleanup(host, ssh_key, db);
            for service in &stopped_services {
                let _ = remote_systemctl(host, ssh_key, "start", service);
            }
            return Err(e).wrap_err("pg_dump failed");
        }
    }

    spinner.set_message(format!("Backing up {} (copying files)", config.name));
    let rsync_result = (|| -> Result<()> {
        for path in &config.paths {
            rsync_from_remote(host, ssh_key, path, &app_backup_dir)?;
        }
        if let Some(db) = &config.db {
            scp_from_remote(
                host,
                ssh_key,
                db.remote_dump_path,
                &app_backup_dir.join("db.dump"),
            )?;
        }
        Ok(())
    })();

    if let Some(db) = &config.db {
        remote_pg_dump_cleanup(host, ssh_key, db);
    }

    let mut start_failures: Vec<String> = Vec::new();
    if !config.systemd_services.is_empty() {
        spinner.set_message(format!("Backing up {} (starting services)", config.name));
        for service in &config.systemd_services {
            if let Err(e) = remote_systemctl(host, ssh_key, "start", service) {
                start_failures.push(format!("{}: {}", service, e));
            }
        }
    }

    match rsync_result {
        Ok(()) => {
            if !start_failures.is_empty() {
                eyre::bail!(
                    "Backup of {} succeeded but failed to restart services:\n  {}",
                    config.name,
                    start_failures.join("\n  ")
                );
            }
        }
        Err(e) => {
            if !start_failures.is_empty() {
                eyre::bail!(
                    "Backup of {} failed during file copy: {}\nAdditionally, failed to restart services:\n  {}",
                    config.name,
                    e,
                    start_failures.join("\n  ")
                );
            }
            return Err(e);
        }
    }

    let backup_size = calculate_dir_size(&app_backup_dir)?;

    if verbose {
        spinner.finish_and_clear();
    } else {
        spinner.finish_with_message(format!(
            "  ✓ {} ({})",
            config.name,
            output::format_size(backup_size)
        ));
    }

    Ok(backup_size)
}

fn resolve_ssh_key_path(host: &Host, override_key: Option<PathBuf>) -> Result<PathBuf> {
    if let Some(key_path) = override_key {
        if !key_path.exists() {
            eyre::bail!(
                "Specified SSH key not found: {}\nCheck the path and try again",
                key_path.display()
            );
        }

        validate_key_file(&key_path)?;
        return Ok(key_path);
    }

    if let Some(ref configured_key) = host.ssh_key {
        let key_path = PathBuf::from(shellexpand::tilde(configured_key).as_ref());
        if key_path.exists() {
            validate_key_file(&key_path)?;
            return Ok(key_path);
        }
        eprintln!(
            "⚠ Warning: Configured SSH key not found: {}",
            key_path.display()
        );
        eprintln!("  Falling back to default key derivation");
    }

    let ssh_key = dirs::home_dir()
        .ok_or_else(|| eyre::eyre!("Could not determine home directory"))?
        .join(format!(".ssh/identities/{}_{}", host.user, host.name));

    if !ssh_key.exists() {
        eyre::bail!(
            "SSH key not found: {}\nRun 'auberge ssh keygen --host {} --user {}' or configure with 'auberge host edit {}'",
            ssh_key.display(),
            host.name,
            host.user,
            host.name
        );
    }

    Ok(ssh_key)
}

fn validate_key_file(key_path: &Path) -> Result<()> {
    let metadata = std::fs::metadata(key_path)
        .wrap_err_with(|| format!("Cannot read SSH key: {}", key_path.display()))?;

    if !metadata.is_file() {
        eyre::bail!("SSH key path is not a file: {}", key_path.display());
    }

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = metadata.permissions();
        let mode = perms.mode() & 0o777;
        if mode & 0o077 != 0 {
            eprintln!(
                "⚠ Warning: SSH key has overly permissive permissions: {:o}",
                mode
            );
            eprintln!("  Consider running: chmod 600 {}", key_path.display());
        }
    }

    Ok(())
}

fn remote_ssh_command(host: &Host, ssh_key: &Path, command: &str) -> Result<std::process::Output> {
    let output = SshSession::new(host, ssh_key).run(command)?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        eyre::bail!("Remote command failed: {}", stderr.trim());
    }

    Ok(output)
}

fn remote_ssh_command_unchecked(
    host: &Host,
    ssh_key: &Path,
    command: &str,
) -> Result<std::process::Output> {
    SshSession::new(host, ssh_key).run(command)
}

fn remote_pg_dump(host: &Host, ssh_key: &Path, db: &DbBackupConfig) -> Result<()> {
    let cmd = format!(
        "sudo -u postgres pg_dump -Fc {} > {}",
        db.db_name, db.remote_dump_path
    );
    remote_ssh_command(host, ssh_key, &cmd)?;
    Ok(())
}

fn remote_pg_dump_cleanup(host: &Host, ssh_key: &Path, db: &DbBackupConfig) {
    let cmd = format!("rm -f {}", db.remote_dump_path);
    let _ = remote_ssh_command(host, ssh_key, &cmd);
}

fn remote_pg_restore(host: &Host, ssh_key: &Path, db: &DbBackupConfig) -> Result<()> {
    let cmd = format!(
        "sudo -u postgres pg_restore --clean --if-exists -d {} {} 2>&1",
        db.db_name, db.remote_dump_path
    );
    let output = remote_ssh_command_unchecked(host, ssh_key, &cmd)?;

    if !output.status.success() {
        let combined_output = String::from_utf8_lossy(&output.stdout);
        let ssh_stderr = String::from_utf8_lossy(&output.stderr);

        if !ssh_stderr.trim().is_empty() {
            eyre::bail!("pg_restore SSH error: {}", ssh_stderr.trim());
        }

        if combined_output.trim().is_empty() {
            eyre::bail!(
                "pg_restore failed with exit code {}",
                output.status.code().unwrap_or(-1)
            );
        }

        let warnings_only = combined_output.lines().all(|line| {
            let trimmed = line.trim().to_lowercase();
            trimmed.is_empty()
                || trimmed.contains("warning")
                || trimmed.starts_with("pg_restore: warning")
        });
        if !warnings_only {
            eyre::bail!("pg_restore failed: {}", combined_output.trim());
        }
    }

    Ok(())
}

fn scp_from_remote(
    host: &Host,
    ssh_key: &Path,
    remote_path: &str,
    local_path: &Path,
) -> Result<()> {
    SshSession::new(host, ssh_key).scp_from(remote_path, local_path)
}

fn scp_to_remote(host: &Host, ssh_key: &Path, local_path: &Path, remote_path: &str) -> Result<()> {
    SshSession::new(host, ssh_key).scp_to(local_path, remote_path)
}

fn remote_systemctl(host: &Host, ssh_key: &Path, action: &str, service: &str) -> Result<()> {
    SshSession::new(host, ssh_key).systemctl(action, service)
}

fn set_remote_ownership(
    host: &Host,
    ssh_key: &Path,
    remote_path: &str,
    user: &str,
    group: &str,
) -> Result<()> {
    let session = SshSession::new(host, ssh_key);
    let status = Command::new("ssh")
        .args(session.ssh_args())
        .arg("sudo")
        .arg("chown")
        .arg("-R")
        .arg(format!("{}:{}", user, group))
        .arg(remote_path)
        .status()
        .wrap_err_with(|| {
            format!(
                "Failed to set ownership of {} to {}:{}",
                remote_path, user, group
            )
        })?;

    if !status.success() {
        eyre::bail!("chown -R {}:{} {} failed", user, group, remote_path);
    }

    Ok(())
}

fn rsync_from_remote(
    host: &Host,
    ssh_key: &Path,
    remote_path: &str,
    local_dest: &Path,
) -> Result<()> {
    let session = SshSession::new(host, ssh_key);
    let mut cmd = Command::new("rsync");
    cmd.arg("-az")
        .arg("--relative")
        .arg("--rsync-path=sudo rsync")
        .stdout(Stdio::null())
        .stderr(Stdio::null());

    for pattern in RSYNC_EXCLUDES {
        cmd.arg(format!("--exclude={}", pattern));
    }

    cmd.arg("-e")
        .arg(session.rsync_e_arg())
        .arg(format!("{}@{}:{}", host.user, host.address, remote_path))
        .arg(local_dest);

    let status = cmd.status().wrap_err("Failed to execute rsync")?;

    if !status.success() {
        eyre::bail!("rsync failed for {}", remote_path);
    }

    Ok(())
}

fn check_remote_service_exists(host: &Host, ssh_key: &Path, service: &str) -> Result<bool> {
    let output = SshSession::new(host, ssh_key).run_raw(&[
        "systemctl",
        "list-unit-files",
        &format!("{}.service", service),
    ])?;
    Ok(output.status.success()
        && String::from_utf8_lossy(&output.stdout).contains(&format!("{}.service", service)))
}

fn check_remote_disk_space(host: &Host, ssh_key: &Path, path: &str) -> Result<u64> {
    let output = SshSession::new(host, ssh_key)
        .run(&format!("df --output=avail {} | tail -1", path))
        .wrap_err("Failed to check disk space")?;

    if !output.status.success() {
        eyre::bail!("Failed to check disk space on remote host");
    }

    let kb_available = String::from_utf8_lossy(&output.stdout)
        .trim()
        .parse::<u64>()
        .wrap_err("Failed to parse disk space output")?;

    Ok(kb_available * 1024)
}

fn validate_cross_host_restore(
    host: &Host,
    ssh_key: &Path,
    apps: &[String],
    backup_size_bytes: u64,
) -> Result<()> {
    eprintln!("\n--- Pre-flight Validation ---");

    eprintln!("  Checking SSH connectivity...");
    let ssh_test = Command::new("ssh")
        .arg("-o")
        .arg("ConnectTimeout=10")
        .arg("-o")
        .arg("BatchMode=yes")
        .arg("-i")
        .arg(ssh_key)
        .arg("-p")
        .arg(host.port.to_string())
        .arg(format!("{}@{}", host.user, host.address))
        .arg("echo")
        .arg("ok")
        .output();

    match ssh_test {
        Ok(output) if output.status.success() => {
            eprintln!("    ✓ SSH connection successful");
        }
        _ => {
            eyre::bail!(
                "Cannot connect to target host {}:{}. Check SSH key and network connectivity",
                host.address,
                host.port
            );
        }
    }

    eprintln!("  Checking services on target...");
    for app in apps {
        let config = AppBackupConfig::by_name(app, false);
        if let Some(cfg) = config {
            for service in &cfg.systemd_services {
                match check_remote_service_exists(host, ssh_key, service) {
                    Ok(true) => {
                        eprintln!("    ✓ {} service exists", service);
                    }
                    Ok(false) => {
                        eprintln!("    ⚠ {} service not found on target", service);
                        eprintln!(
                            "      Run 'auberge ansible run --host {}' to install services",
                            host.name
                        );
                        eyre::bail!("Required service {} not found on target host", service);
                    }
                    Err(e) => {
                        eprintln!("    ⚠ Failed to check {}: {}", service, e);
                    }
                }
            }
        }
    }

    eprintln!("  Checking disk space...");
    match check_remote_disk_space(host, ssh_key, "/") {
        Ok(available_bytes) => {
            let required_bytes = (backup_size_bytes as f64 * 1.2) as u64;
            eprintln!(
                "    Available: {}, Required: {} (with 20% buffer)",
                format_size(available_bytes),
                format_size(required_bytes)
            );

            if available_bytes < required_bytes {
                eyre::bail!(
                    "Insufficient disk space: need {}, have {}",
                    format_size(required_bytes),
                    format_size(available_bytes)
                );
            }
            eprintln!("    ✓ Sufficient disk space available");
        }
        Err(e) => {
            eprintln!("    ⚠ Failed to check disk space: {}", e);
            eprintln!("    Proceeding anyway (use at your own risk)");
        }
    }

    eprintln!("✓ Pre-flight validation completed\n");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_paperless_has_db_config() {
        let config = AppBackupConfig::paperless();
        assert!(config.db.is_some());
    }

    #[test]
    fn test_other_apps_have_no_db_config() {
        assert!(AppBackupConfig::baikal().db.is_none());
        assert!(AppBackupConfig::bichon().db.is_none());
        assert!(AppBackupConfig::freshrss().db.is_none());
        assert!(AppBackupConfig::headscale().db.is_none());
        assert!(AppBackupConfig::navidrome(false).db.is_none());
        assert!(AppBackupConfig::navidrome(true).db.is_none());
        assert!(AppBackupConfig::calibre().db.is_none());
        assert!(AppBackupConfig::vdirsyncer().db.is_none());
        assert!(AppBackupConfig::webdav().db.is_none());
        assert!(AppBackupConfig::yourls().db.is_none());
    }

    #[test]
    fn test_db_backup_config_fields() {
        let config = AppBackupConfig::paperless();
        let db = config.db.unwrap();
        assert_eq!(db.db_name, "paperless");
        assert_eq!(db.remote_dump_path, "/tmp/paperless_db.dump");
    }

    #[test]
    fn test_push_variant_exists() {
        let _push = BackupCommands::Push {
            host: None,
            backup_id: None,
        };
    }

    #[test]
    fn test_prune_variant_exists() {
        let _prune = BackupCommands::Prune { dry_run: true };
    }

    #[test]
    fn test_all_apps_count() {
        let all = AppBackupConfig::all();
        assert_eq!(all.len(), 9);
    }

    #[test]
    fn test_by_name_unknown_returns_none() {
        assert!(AppBackupConfig::by_name("nonexistent", false).is_none());
    }

    #[test]
    fn test_navidrome_music_paths() {
        let without = AppBackupConfig::navidrome(false);
        assert!(!without.paths.contains(&"/srv/music"));

        let with = AppBackupConfig::navidrome(true);
        assert!(with.paths.contains(&"/srv/music"));
    }

    #[test]
    fn test_resolve_backup_dir_empty_root() {
        let tmp = tempfile::tempdir().unwrap();
        let result = resolve_backup_dir(tmp.path(), None, None);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("No backups found"));
    }

    #[test]
    fn test_resolve_backup_dir_single_host_auto_selects() {
        let tmp = tempfile::tempdir().unwrap();
        let host_dir = tmp.path().join("myserver");
        fs::create_dir(&host_dir).unwrap();
        let ts_dir = host_dir.join("2026-03-09_14-30-00");
        fs::create_dir(&ts_dir).unwrap();

        let result = resolve_backup_dir(tmp.path(), None, None).unwrap();
        assert_eq!(result, ts_dir);
    }

    #[test]
    fn test_resolve_backup_dir_with_host_filter() {
        let tmp = tempfile::tempdir().unwrap();
        let host_a = tmp.path().join("server-a");
        let host_b = tmp.path().join("server-b");
        fs::create_dir(&host_a).unwrap();
        fs::create_dir(&host_b).unwrap();
        let ts = host_b.join("2026-03-09_14-30-00");
        fs::create_dir(&ts).unwrap();

        let result = resolve_backup_dir(tmp.path(), Some("server-b"), None).unwrap();
        assert_eq!(result, ts);
    }

    #[test]
    fn test_resolve_backup_dir_host_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let result = resolve_backup_dir(tmp.path(), Some("nonexistent"), None);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("No backups found for host")
        );
    }

    #[test]
    fn test_resolve_backup_dir_picks_latest_timestamp() {
        let tmp = tempfile::tempdir().unwrap();
        let host_dir = tmp.path().join("myserver");
        fs::create_dir(&host_dir).unwrap();
        fs::create_dir(host_dir.join("2026-03-01_10-00-00")).unwrap();
        fs::create_dir(host_dir.join("2026-03-09_14-30-00")).unwrap();
        fs::create_dir(host_dir.join("2026-03-05_12-00-00")).unwrap();

        let result = resolve_backup_dir(tmp.path(), Some("myserver"), None).unwrap();
        assert_eq!(result, host_dir.join("2026-03-09_14-30-00"));
    }

    #[test]
    fn test_resolve_backup_dir_excludes_symlinks_and_non_timestamp_dirs() {
        let tmp = tempfile::tempdir().unwrap();
        let host_dir = tmp.path().join("myserver");
        fs::create_dir(&host_dir).unwrap();
        let ts_dir = host_dir.join("2026-03-09_14-30-00");
        fs::create_dir(&ts_dir).unwrap();
        fs::create_dir(host_dir.join("not-a-timestamp")).unwrap();
        #[cfg(unix)]
        std::os::unix::fs::symlink(&ts_dir, host_dir.join("latest")).unwrap();

        let result = resolve_backup_dir(tmp.path(), Some("myserver"), None).unwrap();
        assert_eq!(result, ts_dir);
    }

    #[test]
    fn test_resolve_backup_dir_specific_backup_id() {
        let tmp = tempfile::tempdir().unwrap();
        let host_dir = tmp.path().join("myserver");
        fs::create_dir(&host_dir).unwrap();
        let ts = host_dir.join("2026-03-09_14-30-00");
        fs::create_dir(&ts).unwrap();

        let result =
            resolve_backup_dir(tmp.path(), Some("myserver"), Some("2026-03-09_14-30-00")).unwrap();
        assert_eq!(result, ts);
    }

    #[test]
    fn test_resolve_backup_dir_specific_backup_id_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let host_dir = tmp.path().join("myserver");
        fs::create_dir(&host_dir).unwrap();

        let result = resolve_backup_dir(tmp.path(), Some("myserver"), Some("2026-01-01_00-00-00"));
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Backup not found"));
    }

    fn test_host() -> Host {
        Host {
            name: "test".to_string(),
            address: "192.0.2.1".to_string(),
            user: "deploy".to_string(),
            port: 2222,
            ssh_key: None,
            tags: vec![],
            description: None,
            python_interpreter: None,
            become_method: "sudo".to_string(),
        }
    }

    #[test]
    fn test_ssh_args_contains_mux_options() {
        let host = test_host();
        let key = Path::new("/home/user/.ssh/id_ed25519");
        let session = SshSession::new(&host, key);
        let args = session.ssh_args();
        let strs: Vec<String> = args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(strs.contains(&"ControlMaster=auto".to_string()));
        assert!(strs.contains(&"ControlPath=/tmp/ssh-%r@%h:%p".to_string()));
        assert!(strs.contains(&"ControlPersist=60s".to_string()));
    }

    #[test]
    fn test_ssh_args_includes_key_port_user_host() {
        let host = test_host();
        let key = Path::new("/home/user/.ssh/id_ed25519");
        let session = SshSession::new(&host, key);
        let args = session.ssh_args();
        let strs: Vec<String> = args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(strs.contains(&"/home/user/.ssh/id_ed25519".to_string()));
        assert!(strs.contains(&"2222".to_string()));
        assert!(strs.contains(&"deploy@192.0.2.1".to_string()));
    }

    #[test]
    fn test_scp_args_uses_uppercase_p_for_port() {
        let host = test_host();
        let key = Path::new("/tmp/key");
        let session = SshSession::new(&host, key);
        let args = session.scp_args();
        let strs: Vec<String> = args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(strs.contains(&"-P".to_string()));
        assert!(!strs.contains(&"-p".to_string()));
    }

    #[test]
    fn test_rsync_e_arg_contains_mux_and_key() {
        let host = test_host();
        let key = Path::new("/home/user/.ssh/id_ed25519");
        let session = SshSession::new(&host, key);
        let e_arg = session.rsync_e_arg();
        assert!(e_arg.starts_with("ssh "));
        assert!(e_arg.contains("ControlMaster=auto"));
        assert!(e_arg.contains("ControlPath=/tmp/ssh-%r@%h:%p"));
        assert!(e_arg.contains("ControlPersist=60s"));
        assert!(e_arg.contains("-i /home/user/.ssh/id_ed25519"));
        assert!(e_arg.contains("-p 2222"));
    }

    #[test]
    fn test_rsync_e_arg_escapes_spaces_in_key_path() {
        let host = test_host();
        let key = Path::new("/home/user/my keys/id_ed25519");
        let session = SshSession::new(&host, key);
        let e_arg = session.rsync_e_arg();
        assert!(!e_arg.contains("-i /home/user/my keys/id_ed25519"));
        assert!(e_arg.contains("'/home/user/my keys/id_ed25519'"));
    }

    #[test]
    fn test_mux_args_pairs_options_correctly() {
        let args = SshSession::mux_args();
        let strs: Vec<String> = args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        for (i, s) in strs.iter().enumerate() {
            if s == "-o" {
                assert!(
                    strs[i + 1].contains('='),
                    "option after -o should be key=value"
                );
            }
        }
    }
}
