// rakuos-software-tray — Background daemon + system tray for RakuOS Software Center

mod checker;
mod settings;
mod tray;

use checker::run_checks;
use settings::Settings;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc as stdmpsc;
use std::time::Duration;
use tokio::sync::mpsc;
use tray::TrayStatus;

static OVERLAY_RETRY_PENDING: AtomicBool = AtomicBool::new(false);

#[derive(Debug)]
pub enum DaemonMsg {
    CheckNow,
    Quit,
    OpenUi,
    /// Fired by the background service watcher when a bootc upgrade/switch
    /// service transitions from running to exited successfully.
    RebootDetected,
}

fn pid_file() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
    std::path::PathBuf::from(home).join(".cache/rakuos/software-ui.pid")
}

fn show_flag_path() -> std::path::PathBuf {
    std::env::temp_dir().join("rakuos-software-show")
}

fn quit_flag_path() -> std::path::PathBuf {
    std::env::temp_dir().join("rakuos-software-quit")
}

fn check_trigger_path() -> std::path::PathBuf {
    std::env::temp_dir().join("rakuos-software-check-requested")
}

fn badge_count_path() -> std::path::PathBuf {
    std::env::temp_dir().join("rakuos-software-badge-count")
}

fn overlay_update_running() -> bool {
    std::path::Path::new("/run/rakuos-overlay.lock").exists()
}

/// Returns true if the UI process is currently running (pid file + /proc check).
fn ui_is_running() -> bool {
    std::fs::read_to_string(pid_file())
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
        .map(|pid| std::path::Path::new(&format!("/proc/{}", pid)).exists())
        .unwrap_or(false)
}

fn daemon_cache_path() -> std::path::PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
    std::path::PathBuf::from(home).join(".cache/rakuos/daemon-update-cache.json")
}

/// Locate the installed UI frontend binary.
/// Checks sibling directory of this binary first (covers dev builds), then libexec.
fn ui_binary() -> std::path::PathBuf {
    let libexec = std::path::Path::new("/usr/libexec/rakuos/software");
    let frontend = std::env::var("RAKUOS_SOFTWARE_FRONTEND")
        .unwrap_or_default()
        .to_lowercase();
    let desktop = std::env::var("XDG_CURRENT_DESKTOP")
        .unwrap_or_default()
        .to_lowercase();
    let candidates: &[&str] = if frontend == "cosmic" {
        &["rakuos-software-cosmic", "rakuos-software-gtk", "rakuos-software-qt"]
    } else if frontend == "qt" {
        &["rakuos-software-qt", "rakuos-software-gtk", "rakuos-software-cosmic"]
    } else if frontend == "gtk" {
        &["rakuos-software-gtk", "rakuos-software-qt", "rakuos-software-cosmic"]
    } else if desktop.contains("cosmic") {
        &["rakuos-software-cosmic", "rakuos-software-gtk", "rakuos-software-qt"]
    } else if desktop.contains("kde") || desktop.contains("plasma") {
        &["rakuos-software-qt", "rakuos-software-gtk", "rakuos-software-cosmic"]
    } else {
        &["rakuos-software-gtk", "rakuos-software-qt", "rakuos-software-cosmic"]
    };

    // Check sibling directory (dev builds in target/debug/)
    if let Ok(exe) = std::env::current_exe() {
        if let Some(dir) = exe.parent() {
            for name in candidates.iter().copied() {
                let p = dir.join(name);
                if p.exists() {
                    return p;
                }
            }
        }
    }

    // Check installed libexec location
    for name in candidates.iter().copied() {
        let p = libexec.join(name);
        if p.exists() {
            return p;
        }
    }

    // Last resort: hope it's on PATH
    std::path::PathBuf::from("rakuos-software-gtk")
}

/// If the UI is already running, write the show-flag so its polling timer
/// picks it up. Otherwise spawn a fresh instance.
fn signal_or_spawn_ui() {
    if ui_is_running() {
        let _ = std::fs::write(show_flag_path(), "1");
    } else {
        let _ = std::process::Command::new(ui_binary()).spawn();
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    env_logger::init();

    // std::sync::mpsc so ksni's non-tokio thread can send without needing a runtime.
    let (std_tx, std_rx) = stdmpsc::sync_channel::<DaemonMsg>(8);

    // Spawn tray icon (registers D-Bus StatusNotifierItem)
    let tray_handle = tray::spawn(std_tx.clone())?;

    // On startup: apply any existing cache immediately so the tray icon
    // reflects the last known state instead of showing "Checking" for 20s.
    if let Ok(j) = std::fs::read_to_string(daemon_cache_path()) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&j) {
            let count  = v["total"].as_i64().unwrap_or(0) as usize;
            let reboot = v["reboot_required"].as_bool().unwrap_or(false);
            tray_handle.update(|t| t.status = if reboot {
                TrayStatus::RebootRequired
            } else if count > 0 {
                TrayStatus::Available(count)
            } else {
                TrayStatus::UpToDate
            });
        }
    }

    // Bridge: read from the std channel in a blocking thread and forward into
    // the tokio channel so the async loop below can await on it normally.
    let (tok_tx, mut tok_rx) = mpsc::channel::<DaemonMsg>(8);
    std::thread::spawn(move || {
        while let Ok(msg) = std_rx.recv() {
            if tok_tx.blocking_send(msg).is_err() {
                break;
            }
        }
    });

    // ── Scheduled check loop ──────────────────────────────────────────────────
    let sched_tx = std_tx.clone();
    tokio::spawn(async move {
        // Delay so the tray registers and the overlay-update lock has time to be written
        tokio::time::sleep(Duration::from_secs(20)).await;
        loop {
            let _ = sched_tx.send(DaemonMsg::CheckNow);
            let settings = Settings::load();
            match settings.effective_interval_secs() {
                Some(secs) => tokio::time::sleep(Duration::from_secs(secs)).await,
                None => tokio::time::sleep(Duration::from_secs(u64::MAX)).await,
            }
        }
    });

    // ── Bootc service completion watcher ─────────────────────────────────────
    // Monitors the bootc upgrade and switch services. When either transitions
    // from running to exited (success), fires RebootDetected so the tray and
    // a desktop notification immediately reflect the completed update — even
    // when the main window is hidden or the user never opened it.
    {
        let watcher_tx = std_tx.clone();
        tokio::spawn(async move {
            watch_bootc_services(watcher_tx).await;
        });
    }

    // ── Badge sync loop ───────────────────────────────────────────────────────
    // The UI writes this file as rows complete so the tray badge can decrement
    // immediately, instead of waiting for the next scheduled background check.
    {
        let tray_c = tray_handle.clone();
        tokio::spawn(async move {
            let mut last_seen: Option<std::time::SystemTime> = None;
            loop {
                tokio::time::sleep(Duration::from_secs(1)).await;
                let path = badge_count_path();
                let Ok(meta) = std::fs::metadata(&path) else { continue };
                let mtime = meta.modified().ok();
                if mtime.is_some() && mtime == last_seen {
                    continue;
                }
                last_seen = mtime;
                if let Ok(raw) = std::fs::read_to_string(&path) {
                    if let Ok(count) = raw.trim().parse::<usize>() {
                        tray_c.update(|t| t.status = if count > 0 {
                            TrayStatus::Available(count)
                        } else {
                            TrayStatus::UpToDate
                        });
                    }
                }
            }
        });
    }

    // ── Message loop ──────────────────────────────────────────────────────────
    while let Some(msg) = tok_rx.recv().await {
        match msg {
            DaemonMsg::Quit => {
                log::info!("Quitting daemon.");
                // Signal the UI to quit via flag file (SIGTERM is blocked by the
                // close-request handler that hides instead of closing the window).
                let _ = std::fs::write(quit_flag_path(), "1");
                std::process::exit(0);
            }
            DaemonMsg::OpenUi => {
                signal_or_spawn_ui();
            }
            DaemonMsg::RebootDetected => {
                log::info!("Bootc service completed — setting tray to RebootRequired.");
                tray_handle.update(|t| t.status = TrayStatus::RebootRequired);
                // Write check_trigger so any running UI frontend (GTK / COSMIC / Qt)
                // picks it up on its next poll and refreshes the updates page to show
                // the reboot button — even if the window is currently hidden.
                let _ = std::fs::write(check_trigger_path(), "1");
                send_notification(
                    "System Update Ready",
                    "Your system image has been updated. Reboot to apply the changes.",
                ).await;
            }
            DaemonMsg::CheckNow => {
                if overlay_update_running() {
                    log::info!("Overlay update in progress — skipping update check, will retry when lock clears.");
                    tray_handle.update(|t| t.status = TrayStatus::Checking);
                    // Spawn a one-shot poller that sends CheckNow once the lock is gone.
                    // The AtomicBool prevents multiple pollers stacking up.
                    if !OVERLAY_RETRY_PENDING.swap(true, Ordering::Relaxed) {
                        let retry_tx = std_tx.clone();
                        tokio::spawn(async move {
                            loop {
                                tokio::time::sleep(Duration::from_secs(30)).await;
                                if !overlay_update_running() {
                                    OVERLAY_RETRY_PENDING.store(false, Ordering::Relaxed);
                                    let _ = retry_tx.send(DaemonMsg::CheckNow);
                                    break;
                                }
                            }
                        });
                    }
                    continue;
                }

                // Always run the daemon's own check. Delegating to the UI via
                // check_trigger is unreliable — the UI may not process it, and
                // Qt's handler only covers the reboot case, not a full check.
                log::info!("Running update check.");
                let settings = Settings::load();
                let result = run_checks(&settings).await;
                let count = result.total;
                let reboot = result.image_info["reboot_required"].as_bool().unwrap_or(false);

                let cache = serde_json::json!({
                    "total":           count,
                    "packages":        result.packages,
                    "flatpak":         result.flatpak,
                    "webapps":         result.webapps,
                    "appimages":       result.appimages,
                    "image_available": result.image_available,
                    "reboot_required": reboot,
                    "image_info":      result.image_info,
                });
                let cache_path = daemon_cache_path();
                if let Some(parent) = cache_path.parent() {
                    let _ = std::fs::create_dir_all(parent);
                }
                let _ = std::fs::write(&cache_path,
                    serde_json::to_string_pretty(&cache).unwrap_or_default());

                tray_handle.update(|t| t.status = if reboot {
                    TrayStatus::RebootRequired
                } else if count > 0 {
                    TrayStatus::Available(count)
                } else {
                    TrayStatus::UpToDate
                });

                // Signal any running UI to refresh its display from the new cache.
                let _ = std::fs::write(check_trigger_path(), "1");

                if let Some(body) = result.notification_body() {
                    send_notification("Updates Available", &body).await;
                }

                log::info!("Check complete: {} update(s), reboot={}.", count, reboot);
            }
        }
    }

    Ok(())
}

/// Build a human-readable notification body from the daemon cache JSON.
/// Returns None if there's nothing to notify about (total == 0).
fn notification_body_from_cache(cache: &serde_json::Value) -> Option<String> {
    let mut parts = Vec::new();

    let pkgs = cache["packages"].as_array().map(|a| a.len()).unwrap_or(0);
    let fps  = cache["flatpak"].as_array().map(|a| a.len()).unwrap_or(0);
    let webs = cache["webapps"].as_array().map(|a| a.len()).unwrap_or(0);
    let ais  = cache["appimages"].as_array().map(|a| a.len()).unwrap_or(0);
    let img  = cache["image_available"].as_bool().unwrap_or(false);

    if pkgs > 0 {
        parts.push(format!("{} package update{}", pkgs, if pkgs != 1 { "s" } else { "" }));
    }
    if fps > 0 {
        parts.push(format!("{} Flatpak update{}", fps, if fps != 1 { "s" } else { "" }));
    }
    if ais > 0 {
        parts.push(format!("{} AppImage update{}", ais, if ais != 1 { "s" } else { "" }));
    }
    if webs > 0 {
        parts.push(format!("{} web app update{}", webs, if webs != 1 { "s" } else { "" }));
    }
    if img {
        let ver = cache["image_info"]["available"].as_str().unwrap_or("new version");
        parts.push(format!("System image {} available", ver));
    }

    if parts.is_empty() { None } else { Some(parts.join("\n")) }
}

async fn send_notification(summary: &str, body: &str) {
    let _ = notify_rust::Notification::new()
        .summary(summary)
        .body(body)
        .appname("RakuOS Software")
        .icon("system-software-update")
        .timeout(notify_rust::Timeout::Milliseconds(8000))
        .show_async()
        .await;
}

/// Poll SubState for both bootc services every 3 seconds. When either
/// transitions from "running" or "start" to "exited" (exit code 0), fire
/// RebootDetected. This works even when the main UI window is hidden or
/// the app was never opened — the daemon runs independently.
async fn watch_bootc_services(tx: stdmpsc::SyncSender<DaemonMsg>) {
    const SERVICES: &[&str] = &[
        "rakuos-bootc-upgrade.service",
        "rakuos-bootc-switch.service",
    ];
    const ACTIVE_STATES: &[&str] = &["running", "start", "auto-restart"];

    let mut prev_sub: [String; 2] = [String::new(), String::new()];

    // On startup: read current SubState so we don't misfire for a service
    // that was already in "exited" state before the daemon started.
    for (i, svc) in SERVICES.iter().enumerate() {
        prev_sub[i] = read_sub_state(svc);
    }

    loop {
        tokio::time::sleep(Duration::from_secs(3)).await;

        for (i, svc) in SERVICES.iter().enumerate() {
            let sub = read_sub_state(svc);
            let was_running = ACTIVE_STATES.contains(&prev_sub[i].as_str());
            let now_exited  = sub == "exited";

            if was_running && now_exited {
                // Confirm exit code 0 before declaring success.
                let code = read_exec_main_status(svc);
                log::info!("{} completed (ExecMainStatus={}).", svc, code);
                if code == 0 {
                    let _ = tx.send(DaemonMsg::RebootDetected);
                }
            }

            prev_sub[i] = sub;
        }
    }
}

fn read_sub_state(unit: &str) -> String {
    std::process::Command::new("systemctl")
        .args(["show", unit, "-p", "SubState", "--value"])
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_default()
}

fn read_exec_main_status(unit: &str) -> i32 {
    std::process::Command::new("systemctl")
        .args(["show", unit, "-p", "ExecMainStatus", "--value"])
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .and_then(|s| s.trim().parse().ok())
        .unwrap_or(1)
}
