use std::{
    collections::HashMap,
    io::{self, BufRead, BufReader, Read},
    num::NonZeroU32,
    os::unix::ffi::OsStrExt,
    path::{Path, PathBuf},
    rc::Rc,
    sync::OnceLock,
};

mod env;

// Adapted from Resources (src/utils/app.rs:83)
fn executable_exceptions() -> &'static HashMap<&'static str, &'static str> {
    static EXECUTABLE_EXCEPTIONS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();

    EXECUTABLE_EXCEPTIONS.get_or_init(|| {
        HashMap::from([
            ("firefox-bin", "firefox"),
            ("oosplash", "libreoffice"),
            ("soffice.bin", "libreoffice"),
            ("resources-processes", "resources"),
            ("gnome-terminal-server", "gnome-terminal"),
            ("chrome", "google-chrome-stable"),
        ])
    })
}

fn app_id_replacement() -> &'static HashMap<&'static str, &'static str> {
    static APP_ID_REPLACEMENT: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();

    APP_ID_REPLACEMENT
        .get_or_init(|| HashMap::from([("gnome-system-monitor-kde", "org.gnome.SystemMonitor")]))
}

/// A running process
///
/// Used to identify running applications. ApplicationEntry's exec field is compared against the
/// process's executable path to determine if the process is an application.
pub trait Process {
    /// The process's PID
    fn pid(&self) -> NonZeroU32;
    /// The process's executable path
    ///
    /// It is expected that the path is canonical
    fn executable_path(&self) -> Option<PathBuf>;
    /// The process's name
    ///
    /// Essentially the name of the process as seen in `ps`
    fn name(&self) -> &str;
}

/// And data structure representing an application
///
/// It contains a subset of the fields from an XDG desktop entry file
#[derive(Clone, Debug)]
pub struct ApplicationEntry {
    /// The application's id
    ///
    /// The filename of the desktop entry file without the `.desktop` extension and is used
    /// to uniquely identify an application
    pub id: Rc<str>,
    /// The application's name
    ///
    /// The name that is displayed to the user in the application menu, taskbar, etc.
    pub name: Rc<str>,
    /// The application's executable
    ///
    /// The absolute path to the application's executable, obtained from the `Exec` field in
    /// the desktop entry file
    pub exec: Option<Rc<str>>,
    /// The application's icon
    ///
    /// If available, taken verbatim from the `Icon` field in the desktop entry file
    pub icon: Option<Rc<str>>,
}

/// Returns a map of all applications available to the current user
///
/// Reads all desktop entry files in `$XDG_DATA_DIRS` and `$HOME/.local/share/applications`
/// and returns a map of the applications found.
pub fn installed_apps() -> HashMap<Rc<str>, ApplicationEntry> {
    installed_apps_impl(env::xdg_data_dirs(), env::path())
}

/// Returns a list of running applications
///
/// Requires a list of available applications and a list of running processes. The list of available
/// applications can be obtained using `installed_apps`.
///
/// Returns a list of running applications along with the PIDs for each running app.
///
/// **Example:**
/// ```
/// use app_rummage::{installed_apps, running_apps};
/// # struct MyProcess { pid: std::num::NonZeroU32, exe: Option<std::rc::Rc<str>> }
/// # impl app_rummage::Process for MyProcess { fn pid(&self) -> std::num::NonZeroU32 { self.pid } fn executable_path(&self) -> Option<std::path::PathBuf> { self.exe.as_ref().map(|e| std::path::Path::new(e.as_ref()).to_owned()) } fn name(&self) -> &str { "" } }
/// # fn running_processes() -> Vec<MyProcess> { vec![] }
///
/// let available_apps = installed_apps();
/// let processes = running_processes();
/// let running_apps = running_apps(&available_apps, &processes);
/// for (app, pids) in running_apps {
///    println!("{}: {:?}", app.name, pids);
/// }
/// ```
pub fn running_apps<'a, P: Process + 'a>(
    available_applications: &'a HashMap<Rc<str>, ApplicationEntry>,
    processes: impl IntoIterator<Item = &'a P>,
) -> Vec<(&'a ApplicationEntry, Vec<NonZeroU32>)> {
    fn normalize_app_id(id: &str) -> &str {
        if let Some(real_id) = app_id_replacement().get(id) {
            *real_id
        } else {
            id
        }
    }

    let mut running_apps = running_xdg_conformant_apps(available_applications);

    let mut apps_by_proc = running_apps_by_process(available_applications, processes);
    for (app_id, (app, pids)) in apps_by_proc.drain() {
        let normalized_id = normalize_app_id(app_id.as_ref());
        if !running_apps.contains_key(normalized_id) {
            running_apps.insert(app_id, (app, pids));
        }
    }
    running_apps
        .values_mut()
        .map(|(app, pids)| (*app, std::mem::take(pids)))
        .collect()
}

fn installed_apps_impl(
    xdg_data_dirs: &[String],
    env_path: &[String],
) -> HashMap<Rc<str>, ApplicationEntry> {
    let mut result = HashMap::new();
    let mut buffer = String::new();

    for dir in xdg_data_dirs {
        let dir = Path::new(&dir).join("applications");
        if dir.exists() {
            let dir = match dir.read_dir() {
                Ok(dc) => dc,
                Err(e) => {
                    log::warn!("Failed to read directory '{}': {:?}", dir.display(), e);
                    continue;
                }
            };

            for entry in dir {
                let entry = match entry {
                    Ok(e) => e,
                    Err(e) => {
                        log::warn!("Failed to read entry directory entry: {:?}", e);
                        continue;
                    }
                };

                let path = entry.path();
                if path
                    .extension()
                    .map(|ext| ext == "desktop")
                    .unwrap_or(false)
                {
                    match extract_application_info(&path, env_path, &mut buffer) {
                        Ok(app) => {
                            result.insert(app.id.clone(), app);
                        }
                        _ => {}
                    }
                }
            }
        }
    }

    result
}

fn extract_application_info(
    path: &Path,
    env_path: &[String],
    buffer: &mut String,
) -> io::Result<ApplicationEntry> {
    buffer.clear();
    std::fs::File::options()
        .read(true)
        .open(path)?
        .read_to_string(buffer)?;

    let desktop_file_content = buffer;

    let mut name = None;
    let mut exec = None;
    let mut icon = None;

    let mut desktop_entry_group_found = false;
    for line in desktop_file_content.split('\n') {
        if line != "[Desktop Entry]" && !desktop_entry_group_found {
            continue;
        }

        if line == "[Desktop Entry]" {
            desktop_entry_group_found = true;
            continue;
        }

        if line.starts_with("NoDisplay=true") {
            name = None;
            break;
        }

        if line.starts_with("Type=") {
            if line[5..].trim() != "Application" {
                name = None;
                break;
            }
        }

        if line.starts_with("Name=") {
            name = Some(&line[5..]);
        } else if line.starts_with("Exec=") {
            exec = Some(&line[5..]);
        } else if line.starts_with("Icon=") {
            icon = Some(&line[5..]);
        } else if line.starts_with("[") {
            break;
        }
    }

    if let (Some(name), Some(exec)) = (name, exec) {
        let Some(file_name) = path.file_name() else {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                "The file name of the Desktop file could not be determined",
            ));
        };
        let file_name = file_name.to_string_lossy();

        let app_id = file_name
            .strip_suffix(".desktop")
            .map(|id| Rc::<str>::from(id))
            .unwrap_or(Rc::<str>::from(file_name));

        return Ok(ApplicationEntry {
            id: app_id,
            name: Rc::from(name),
            exec: sanitize_exec(exec, env_path),
            icon: icon.map(|i| Rc::from(i)),
        });
    }

    Err(io::Error::new(
        io::ErrorKind::InvalidInput,
        "Desktop file does not describe a valid user-facing application",
    ))
}

fn sanitize_exec(exec: &str, env_path: &[String]) -> Option<Rc<str>> {
    const CMDLINE_PROGRAMS: &[&str] = &[
        "sh",
        "ash",
        "bash",
        "dash",
        "fish",
        "zsh",
        "powershell",
        "awk",
        "ruby",
        "perl",
        "lua",
        "php",
        "python",
        "python2",
        "python2.7",
        "python3",
        "node",
        "nodejs",
        "java",
        "dotnet",
        // coreutils
        "arch",
        "cp",
        "stty",
        "base32",
        "date",
        "base64",
        "dd",
        "basename",
        "df",
        "basenc",
        "expr",
        "cat",
        "install",
        "chcon",
        "join",
        "chgrp",
        "ls",
        "chmod",
        "more",
        "chown",
        "numfmt",
        "chroot",
        "od",
        "cksum",
        "pr",
        "comm",
        "printf",
        "csplit",
        "sort",
        "cut",
        "split",
        "dircolors",
        "tac",
        "dirname",
        "tail",
        "du",
        "test",
        "echo",
        "env",
        "expand",
        "factor",
        "false",
        "fmt",
        "fold",
        "groups",
        "hashsum",
        "head",
        "hostid",
        "hostname",
        "id",
        "kill",
        "link",
        "ln",
        "logname",
        "md5sum",
        "sha1sum",
        "sha224sum",
        "sha256sum",
        "sha384sum",
        "sha512sum",
        "mkdir",
        "mkfifo",
        "mknod",
        "mktemp",
        "mv",
        "nice",
        "nl",
        "nohup",
        "nproc",
        "paste",
        "pathchk",
        "pinky",
        "printenv",
        "ptx",
        "pwd",
        "readlink",
        "realpath",
        "relpath",
        "rm",
        "rmdir",
        "runcon",
        "seq",
        "shred",
        "shuf",
        "sleep",
        "stat",
        "stdbuf",
        "sum",
        "sync",
        "tee",
        "timeout",
        "touch",
        "tr",
        "true",
        "truncate",
        "tsort",
        "tty",
        "uname",
        "unexpand",
        "uniq",
        "unlink",
        "uptime",
        "users",
        "wc",
        "who",
        "whoami",
        "yes",
    ];

    const LAUNCHERS: &[&str] = &[
        "distrobox",
        "distrobox-enter",
        "toolbx",
        "toolbx-enter",
        "toolbox",
        "toolbox-enter",
        "flatpak",
        "snap",
        "env",
    ];

    for cmd in exec
        .split_ascii_whitespace()
        .map(|item| item.trim())
        .filter(|item| !item.is_empty() && !item.starts_with('-'))
        .map(|item| {
            let path = Path::new(item.trim_start_matches('"').trim_end_matches('"'));
            return if path.is_absolute() {
                Some(path.to_owned())
            } else {
                for dir in env_path.into_iter() {
                    let path = Path::new(&dir).join(item);
                    if path.exists() {
                        return Some(path);
                    }
                }
                None
            };
        })
        .filter_map(|path| path.and_then(|p| p.canonicalize().ok()))
        .skip_while(|item| {
            LAUNCHERS.contains(
                &item
                    .file_name()
                    .unwrap_or_default()
                    .to_string_lossy()
                    .as_ref(),
            )
        })
    {
        let file_name = cmd.file_name().unwrap_or_default().to_string_lossy();
        if CMDLINE_PROGRAMS.contains(&file_name.as_ref()) {
            return None;
        }

        return Some(Rc::from(cmd.to_string_lossy()));
    }

    None
}

fn find_pids_for_cgroup(cgroup_path: &Path) -> Vec<NonZeroU32> {
    fn find_pids_for_cgroup(cgroup_path: &Path, result: &mut Vec<NonZeroU32>) {
        let procs = cgroup_path.join("cgroup.procs");
        if let Ok(file) = std::fs::File::open(procs) {
            for line in BufReader::new(file).lines() {
                if let Ok(pid_str) = line.as_ref().map(|l| l.trim()) {
                    if let Ok(pid) = pid_str.parse::<u32>() {
                        if let Some(pid) = NonZeroU32::new(pid) {
                            result.push(pid);
                        }
                    }
                }
            }
        }

        let cgroup_entries = match cgroup_path.read_dir() {
            Ok(r) => r,
            Err(_) => {
                return;
            }
        };

        for entry in cgroup_entries.filter_map(|e| e.ok()) {
            if let Ok(kind) = entry.file_type() {
                if kind.is_dir() {
                    find_pids_for_cgroup(&entry.path(), result);
                }
            }
        }
    }

    let mut result = vec![];
    find_pids_for_cgroup(cgroup_path, &mut result);
    result.sort_unstable();
    result
}

fn app_id(path: &Path) -> Option<Rc<str>> {
    // https://systemd.io/DESKTOP_ENVIRONMENTS/#xdg-standardization-for-applications

    let dir_name = path.file_name()?.to_string_lossy();

    if dir_name.starts_with("snap.") {
        let mut app_id = String::new();

        // snaps cgroups names don't conform to the suggested XDG standard they, instead, look like:
        // snap.<appname1>.<appname2>-<UUID>.scope
        //
        // The app id is the concatenation of appname1 and appname2 separated by an underscore
        for part in dir_name.split('.').skip(1) {
            if part == "scope" {
                break;
            }

            if !app_id.is_empty() {
                app_id.push('_');
            }

            // Try to skip over the uuid part by counting the number of '-' characters
            // from the end of the part
            let mut uuid_pos = part.len();
            let mut counter = 0;
            for (i, c) in part.as_bytes().iter().enumerate().rev() {
                if *c == b'-' {
                    counter += 1;
                }

                if counter == 5 {
                    uuid_pos = i;
                    break;
                }
            }
            app_id.push_str(&part[..uuid_pos]);
        }

        Some(Rc::from(app_id))
    } else if dir_name.starts_with("app-") {
        let extension = path.extension()?.to_string_lossy();
        // Include the '.' in the extension
        let extension = &dir_name[dir_name.len() - extension.len() - 1..];
        let mut app_id: Option<&str> = None;

        for part in dir_name.split('-').skip(1).filter(|p| !p.is_empty()) {
            if app_id.is_some() && part.ends_with(extension) {
                break;
            }

            app_id = Some(part.trim_end_matches(extension));
        }

        app_id.map(|s| Rc::from(s.replace("\\x2d", "-")))
    } else {
        None
    }
}

fn running_xdg_conformant_apps(
    available_apps: &HashMap<Rc<str>, ApplicationEntry>,
) -> HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> {
    // We only show running apps for the current user
    let uid = nix::unistd::getuid();
    let app_slice_dir =
        format!("/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service/app.slice");
    let app_slice_dir = match Path::new(&app_slice_dir).read_dir() {
        Ok(r) => r,
        Err(e) => {
            log::warn!(
                "Error reading cgroup information from {}: {}",
                app_slice_dir,
                e
            );
            return HashMap::new();
        }
    };

    let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
    result.reserve(available_apps.len());

    for entry in app_slice_dir.filter_map(|e| e.ok()).filter(|e| {
        let file_name = e.file_name();
        let file_name = file_name.as_bytes();
        file_name.ends_with(b".slice")
            || file_name.ends_with(b".scope")
            || file_name.ends_with(b".service")
    }) {
        let path = entry.path();

        if let Some(app_id) = app_id(&path) {
            let mut pids = find_pids_for_cgroup(&path);
            if !pids.is_empty() {
                if let Some(app) = available_apps.get(&app_id) {
                    if let Some((_, existing_pids)) = result.get_mut(&app.id) {
                        existing_pids.extend(pids);
                        existing_pids.sort_unstable();
                    } else {
                        pids.sort_unstable();
                        result.insert(app.id.clone(), (app, pids));
                    }
                }
            }
        }
    }

    result
}

fn running_apps_by_process<'a, P: Process + 'a>(
    available_apps: &'a HashMap<Rc<str>, ApplicationEntry>,
    processes: impl IntoIterator<Item = &'a P>,
) -> HashMap<Rc<str>, (&'a ApplicationEntry, Vec<NonZeroU32>)> {
    let mut result: HashMap<Rc<str>, (&ApplicationEntry, Vec<NonZeroU32>)> = HashMap::new();
    result.reserve(available_apps.len());

    let apps_by_exec = available_apps
        .values()
        .filter_map(|app| {
            app.exec
                .as_ref()
                .map(|exec| (Path::new(exec.as_ref()), app))
        })
        .collect::<HashMap<&Path, &ApplicationEntry>>();

    for process in processes.into_iter() {
        let proc_exec = if let Some(proc_exe) = process.executable_path().clone() {
            proc_exe
        } else {
            env::path()
                .iter()
                .filter_map(|dir| {
                    let mut path = Path::new(dir).join(process.name());
                    if !path.exists() {
                        if let Some(alternate_name) = executable_exceptions().get(process.name()) {
                            path = Path::new(dir).join(alternate_name);
                        }
                    }

                    if path.exists() {
                        if let Ok(exec) = path.canonicalize() {
                            return Some(exec);
                        }
                    }

                    None
                })
                .next()
                .unwrap_or(PathBuf::new())
        };

        if let Some(app) = apps_by_exec.get(&proc_exec.as_path()) {
            let app_id = &app.id;
            if let Some((_, pids)) = result.get_mut(app_id) {
                pids.push(process.pid());
                pids.sort_unstable();
            } else {
                result.insert(app_id.clone(), (app, vec![process.pid()]));
            }
        }
    }

    result
}

#[cfg(test)]
mod tests {
    use crate::env;

    use super::*;

    #[test]
    fn test_available_applications() {
        let result = installed_apps_impl(env::xdg_data_dirs(), env::path());
        dbg!(&result);
    }

    #[test]
    fn test_find_pids_for_cgroup() {
        let result = find_pids_for_cgroup(Path::new("/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice"));
        dbg!(&result);
    }

    #[test]
    fn test_running_applications_xdg() {
        let available_apps = installed_apps();
        let result = running_xdg_conformant_apps(&available_apps);
        dbg!(&result);
    }

    #[test]
    fn test_running_applications_process() {
        #[derive(Debug)]
        struct MyProcess {
            pid: NonZeroU32,
            name: Rc<str>,
            exe: Option<Rc<str>>,
        }

        impl Process for MyProcess {
            fn pid(&self) -> NonZeroU32 {
                self.pid
            }

            fn executable_path(&self) -> Option<PathBuf> {
                self.exe.as_ref().map(|e| Path::new(e.as_ref()).to_owned())
            }

            fn name(&self) -> &str {
                self.name.as_ref()
            }
        }

        fn running_processes() -> Vec<MyProcess> {
            let mut result = vec![];

            let readdir = match Path::new("/proc").read_dir() {
                Ok(r) => r,
                Err(_) => {
                    return vec![];
                }
            };

            for entry in readdir.filter_map(|e| e.ok()) {
                let path = entry.path();
                let mut exe = Rc::<str>::from("");
                if let Some(pid) = path
                    .file_name()
                    .and_then(|f| f.to_str())
                    .and_then(|f| f.parse().ok())
                {
                    let bin_path = path.join("exe");
                    if let Ok(bin_path) =
                        std::fs::read_link(&bin_path).and_then(|p| p.canonicalize())
                    {
                        if bin_path.exists() {
                            exe = Rc::from(bin_path.to_string_lossy());
                        }
                    } else {
                        if let Some(bin_path) = std::fs::read_to_string(path.join("cmdline"))
                            .ok()
                            .and_then(|s| match s.split('\0').next() {
                                Some("") => None,
                                Some(s) => Some(s.to_owned()),
                                None => None,
                            })
                            .map(|s| Path::new(&s).to_owned())
                            .and_then(|p| p.canonicalize().ok())
                        {
                            if bin_path.exists() && bin_path.is_file() && bin_path.is_absolute() {
                                exe = Rc::from(bin_path.to_string_lossy());
                            }
                        }
                    }

                    let proc_name = path.join("comm");
                    if let Ok(name) = std::fs::read_to_string(&proc_name) {
                        result.push(MyProcess {
                            pid,
                            exe: Some(exe),
                            name: Rc::from(name.trim()),
                        });
                    }
                }
            }

            result
        }

        let available_apps = installed_apps();
        let processes = running_processes();
        let result = running_apps_by_process(&available_apps, &processes);
        dbg!(&result);
    }
}
