//! This module provides approximate reimplementations of file-finding routines
//! from the original WINGs utilities.
//!
//! The [`ffi`] submodule provides functions which may be called directly from C
//! that has not yet been ported to Rust.
//!
//! The original utilities expanded environment variables in path names
//! (expanding `$FOO/bar/baz` to use the value of the environment variable
//! `FOO`) and respected Unix-style denotations of user home directories
//! (resolving `~someuser/foo.txt` to `(home directory of
//! someuser)/foo.txt`. These behaviors have not been preserved. But a path
//! whose first component is `~` will still be resolved relatively to the
//! current user's home directory.
//!
//! ## Rust rewrite notes
//!
//! Many of these utilities are not strictly correct as originally designed: a
//! file path that appears valid when it is checked in a subroutine may become
//! invalid if the file is deleted between when the path is checked and when
//! downstream code attempts to open the file. (This is a TOCTOU issue.) A
//! better design would open the file and return a live file pointer instead of
//! simply returning a path that is likely to work. Future work should redesign
//! this module to avoid this issue.

use crate::defines;

use std::{
    env,
    ffi::{CStr, OsStr},
    fs::File,
    io,
    path::{Component, Path, PathBuf},
};

/// Tries to interpret `s` as a UTF-8 path. Returns `None` if decoding `s`
/// fails.
pub fn path_from_cstr(s: &CStr) -> Option<PathBuf> {
    s.to_str().ok().map(PathBuf::from)
}

/// If `file` is an absolute path can be opened, returns that path. Paths
/// starting with `~` are treated as absolute, and the user's home directory is
/// substituted for `~` (so `~/foo` becomes `(users's home directory)/foo`). If
/// the user's home directory cannot be determined, `/` is used instead.
///
/// Returns `None` otherwise.
pub fn absolute(file: &Path) -> Option<PathBuf> {
    if file.is_absolute() {
        return Some(file.to_path_buf());
    } else {
        let mut components = file.components();
        if components.next() == Some(Component::Normal(OsStr::new("~"))) {
            let mut path = env::home_dir().unwrap_or_else(|| PathBuf::from("/"));
            for c in components {
                path.push(c);
            }
            Some(path)
        } else {
            None
        }
    }
}

/// Resolves `file` to a path that can be opened relative to an element of
/// `paths`, or `None` if it cannot be found. If `paths` is empty, an attempt to
/// resolve `file` relative to the current working directory will be made.
pub fn in_paths<'a>(paths: impl Iterator<Item = &'a Path>, file: &Path) -> Option<PathBuf> {
    if file.file_name().map(|f| f.is_empty()).unwrap_or(false) {
        return None;
    }
    let mut paths = paths.peekable();

    if paths.peek().is_none() {
        if let Ok(_) = File::open(file) {
            return Some(file.to_path_buf());
        }
        return None;
    }

    let mut buf = PathBuf::new();
    for parent in paths {
        buf.clear();
        buf.push(parent);
        buf.push(file);
        if let Ok(_) = File::open(&buf) {
            return Some(buf);
        }
    }
    None
}

/// Returns the root path that user data will be stored at. This is probably
/// `$HOME/GNUstep` (or some other default if a different path was specified for
/// [`defaults::gsuser_subdir`] at compilation time), unless the environment
/// variable `WMAKER_USER_ROOT` is defined, in which case it's that.
pub fn user_gnustep_path() -> Option<PathBuf> {
    match env::var("WMAKER_USER_ROOT") {
        Ok(path) => {
            if let Some(path) = absolute(&PathBuf::from(&path)) {
                return Some(path);
            }
        }
        Err(env::VarError::NotUnicode(_)) => {
            // TODO: warn.
        }
        Err(env::VarError::NotPresent) => (),
    }

    match (env::home_dir(), defines::gsuser_subdir()) {
        (None, _) => {
            // TODO: warn.
            None
        }
        (Some(mut parent), Some(subdir)) => {
            parent.push(subdir);
            Some(parent)
        }
        (Some(_), None) => {
            // TODO: warn.
            None
        }
    }
}

/// Creates the directory at `p` and all of its parents, respecting the current
/// umask as much as possible. Only allows creation of paths under
/// [`user_gnustep_path`].
pub fn create_path_hierarchy<P: AsRef<Path>>(p: P) -> io::Result<()> {
    let p = p.as_ref();
    let Ok(canonical_p) = p.canonicalize() else {
        return Err(io::Error::other("cannot canonicalize requested path"));
    };
    let Some(wmaker_user_root) = user_gnustep_path() else {
        return Err(io::Error::other("cannot determine WMAKER_USER_ROOT. try setting the environment variable WMAKER_USER_ROOT."));
    };
    let Ok(wmaker_user_root) = Path::new(&wmaker_user_root).canonicalize() else {
        return Err(io::Error::other(
            "cannot canonicalize path for WMAKER_USER_ROOT",
        ));
    };

    if !canonical_p.starts_with(&wmaker_user_root) {
        return Err(io::Error::other(
            "requested path is not under WMAKER_USER_ROOT",
        ));
    }

    create_path_hierarchy_impl(&canonical_p)
}

/// Removes the file at `p` (and anything under it, if `p` is a directory). Only
/// allows deletion of files under [`user_gnustep_path`]`/Defaults` or
/// [`user_gnustep_path`]`/Library`.
pub fn remove_path_hierarchy<P: AsRef<Path>>(p: P) -> io::Result<()> {
    let p = p.as_ref();
    let Ok(canonical_p) = p.canonicalize() else {
        return Err(io::Error::other("cannot canonicalize requested path"));
    };
    let Some(wmaker_user_root) = user_gnustep_path() else {
        return Err(io::Error::other("cannot determine WMAKER_USER_ROOT. try setting the environment variable WMAKER_USER_ROOT."));
    };
    let Ok(wmaker_user_root) = Path::new(&wmaker_user_root).canonicalize() else {
        return Err(io::Error::other(
            "cannot canonicalize path for WMAKER_USER_ROOT",
        ));
    };

    let mut defaults = wmaker_user_root.clone();
    defaults.push("Defaults");
    let mut library = wmaker_user_root;
    library.push("Library");
    if !canonical_p.starts_with(&defaults) && !canonical_p.starts_with(&library) {
        return Err(io::Error::other(
            "requested path is not under WMAKER_USER_ROOT/Defaults or WMAKER_USER_ROOT/Library",
        ));
    }

    std::fs::remove_dir_all(canonical_p)
}

#[cfg(target_family = "unix")]
fn create_path_hierarchy_impl(p: &Path) -> io::Result<()> {
    use std::os::unix::fs::DirBuilderExt;
    std::fs::DirBuilder::new()
        .recursive(true)
        .mode(0o777)
        .create(p)
}

#[cfg(not(target_family = "unix"))]
fn create_path_hierarchy_impl(p: &Path) -> io::Result<()> {
    std::fs::DirBuilder::new().recursive(true).create(p)
}

pub mod ffi {
    use super::{absolute, create_path_hierarchy, in_paths, path_from_cstr, remove_path_hierarchy, user_gnustep_path};
    use crate::memory::alloc_bytes;

    use std::{
        env,
        ffi::{c_char, c_int, CStr, OsStr},
        iter,
        os::unix::ffi::OsStrExt,
        path::{Path, PathBuf},
        ptr,
    };

    fn split_paths(paths: &CStr) -> impl Iterator<Item = &[u8]> {
        paths.to_bytes().split(|b| *b == b':')
    }

    fn to_c_str(p: &Path) -> *mut c_char {
        let os_bytes = p.as_os_str().as_encoded_bytes();
        let buf = alloc_bytes(os_bytes.len() + 1);
        unsafe {
            ptr::copy_nonoverlapping(os_bytes.as_ptr(), buf, os_bytes.len());
        }
        buf.cast::<c_char>()
    }

    /// Attempts to find `file` under colon-separated `paths`. Checks if `file`
    /// is absolute or prefixed with `~` before attempting to resolve it
    /// relatively. If no file can be found, returns NULL. Non-NULL return
    /// values must be freed with [`crate::memory::free_bytes`] or
    /// [`crate::memory::ffi::wfree`].
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wfindfile(paths: *const c_char, file: *const c_char) -> *mut c_char {
        if file.is_null() {
            return ptr::null_mut();
        }
        let file = unsafe { CStr::from_ptr(file) };
        let file = Path::new(OsStr::from_bytes(file.to_bytes()));
        if let Some(path) = absolute(&file) {
            return to_c_str(&path);
        }
        let path = if paths.is_null() {
            in_paths(iter::empty(), file)
        } else {
            let paths = unsafe { CStr::from_ptr(paths) };
            in_paths(
                split_paths(paths).map(|p| Path::new(OsStr::from_bytes(p))),
                file,
            )
        };
        path.map(|x| to_c_str(x.as_ref()))
            .unwrap_or(ptr::null_mut())
    }

    /// Attempts to find `file` under an element of NULL-terminated
    /// `path_list`. Checks if `file` is absolute or prefixed with `~` before
    /// attempting to resolve it relatively. If no file can be found, returns
    /// NULL. Non-NULL return values must be freed with
    /// [`crate::memory::free_bytes`] or [`crate::memory::ffi::wfree`].
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wfindfileinlist(
        path_list: *const *const c_char,
        file: *const c_char,
    ) -> *mut c_char {
        if file.is_null() {
            return ptr::null_mut();
        }
        let file = unsafe { CStr::from_ptr(file) };
        let file = Path::new(OsStr::from_bytes(file.to_bytes()));
        if let Some(path) = absolute(&file) {
            return to_c_str(&path);
        }

        let path = if path_list.is_null() {
            in_paths(iter::empty(), file)
        } else {
            let paths = (0usize..)
                .map(|offset| unsafe { path_list.add(offset) })
                .take_while(|&p| unsafe { !(*p).is_null() })
                .map(|p| Path::new(OsStr::from_bytes(unsafe { CStr::from_ptr(*p).to_bytes() })));
            in_paths(paths, file)
        };

        path.map(|x| to_c_str(x.as_ref()))
            .unwrap_or(ptr::null_mut())
    }

    /// Attempts to expand `path` if it starts with `~` by replacing the first
    /// path element with the user's home directory. Returns NULL if `path` is
    /// NULL. Non-NULL return values must be freed with
    /// [`crate::memory::free_bytes`] or [`crate::memory::ffi::wfree`].
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wexpandpath(path: *const c_char) -> *mut c_char {
        if path.is_null() {
            return ptr::null_mut();
        }
        let path = unsafe { CStr::from_ptr(path) };
        let path = Path::new(OsStr::from_bytes(path.to_bytes()));
        absolute(path)
            .map(|p| to_c_str(&p))
            .unwrap_or_else(|| to_c_str(&path))
    }

    /// Returns the home directory of the current user, or `"/"` if it cannot be
    /// determined. The returned value must be freed with
    /// [`crate::memory::free_bytes`] or [`crate::memory::ffi::wfree`].
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wgethomedir() -> *mut c_char {
        match env::home_dir() {
            Some(x) => to_c_str(x.as_ref()),
            None => to_c_str(Path::new("/")),
        }
    }

    /// Copies `src_file` into `dest_dir/dest_file`. Returns 0 on success, or -1
    /// on error.
    ///
    /// This is provided solely to support code that has not yet been ported to
    /// Rust. Prefer `std::fs::copy` or another utility if you can.
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wcopy_file(
        dest_dir: *const c_char,
        src_file: *const c_char,
        dest_file: *const c_char,
    ) -> c_int {
        if dest_dir.is_null() || src_file.is_null() || dest_file.is_null() {
            return -1;
        }
        let src_file = unsafe { CStr::from_ptr(src_file) };
        let dest_dir = unsafe { CStr::from_ptr(dest_dir) };
        let dest_file = unsafe { CStr::from_ptr(dest_file) };
        let src = Path::new(OsStr::from_bytes(src_file.to_bytes()));
        let mut dest = PathBuf::from(OsStr::from_bytes(dest_dir.to_bytes()));
        dest.push(OsStr::from_bytes(dest_file.to_bytes()));
        if std::fs::copy(src, dest).is_ok() {
            return 0;
        } else {
            return -1;
        }
    }

    /// Delegates to [`create_path_hierarchy`].
    ///
    /// ## Rust rewrite notes
    ///
    /// The original C implementation of this function stripped the last path
    /// component if it did not end in a `'/'` (i.e., it looked like a regular
    /// file instead of a directory). This behavior does not appear to be relied
    /// upon by any existing callers except for `WMWritePropListToFile`, which
    /// has been rewritten, and it is not super portable (and annoying because
    /// it adds weird edge cases). So each component of `path` is treated as a
    /// directory to be made.
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wmkdirhier(path: *const c_char) -> c_int {
        if path.is_null() {
            return 0;
        }
        let path = unsafe { CStr::from_ptr(path) };
        let Some(path) = path_from_cstr(path) else {
            return 0;
        };
        if create_path_hierarchy(path).is_ok() {
            return 1;
        } else {
            return 0;
        }
    }

    /// Delegates to [`remove_path_hierarchy`].
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wrmdirhier(path: *const c_char) -> c_int {
        if path.is_null() {
            return 0;
        }
        let path = unsafe { CStr::from_ptr(path) };
        let Some(path) = path_from_cstr(path) else {
            return 0;
        };
        if remove_path_hierarchy(path).is_ok() {
            return 1;
        } else {
            return 0;
        }
    }

    /// Delegates to [`user_gnustep_path`]. Returns the path, which must be The
    /// returned value must be freed with [`crate::memory::free_bytes`] or
    /// [`crate::memory::ffi::wfree`], path cannot be determined.
    ///
    /// ## Rust rewrite notes
    ///
    /// This was originally in `WINGs/userdefaults.c`.
    #[unsafe(no_mangle)]
    pub unsafe extern "C" fn wusergnusteppath() -> *mut c_char {
        if let Some(path) = user_gnustep_path() {
            to_c_str(&path)
        } else {
            ptr::null_mut()
        }
    }
}
