use std::cell::OnceCell;
use std::ffi::CStr;
use std::ffi::c_char;
use std::os::unix::ffi::OsStringExt;
use std::path::Path;

use crate::Tag;

type Result<T> = std::result::Result<T, std::io::Error>;

pub(crate) struct FileType(u8);

impl FileType {
    /// Whether a file is a directory.
    ///
    /// According to glibc's documentation:
    ///
    ///   Currently, only some filesystems (among them: Btrfs, ext2,
    ///   ext3, and ext4) have full support for returning the file type
    ///   in d_type.  All applications must properly handle a re‐ turn
    ///   of DT_UNKNOWN.
    pub fn is_dir(&self) -> bool {
        self.0 == libc::DT_DIR
    }

    /// Whether a file's type is not known.
    pub fn is_unknown(&self) -> bool {
        self.0 == libc::DT_UNKNOWN
    }
}

// Not all Unix platforms have 64-bit variants of stat64, etc.  Rust's
// libc doesn't define a nice way to figure out if those functions are
// available.  Eventually, we can use something like:
//
//   #[cfg_accessible(libc::stat64)]
//
// (See https://github.com/rust-lang/rust/issues/64797), but that
// hasn't been stabilized yet.
//
// For now, we copy Rust's libc's strategy:
//
//   https://github.com/rust-lang/rust/blob/5a2dd7d4f3210629e65879aeecbe643ba3b86bb4/library/std/src/sys/pal/unix/fs.rs#L22
#[cfg(any(all(target_os = "linux", not(target_env = "musl")), target_os = "hurd"))]
mod libc64 {
    pub(super) use libc::stat64 as stat;
    pub(super) use libc::fstatat64 as fstatat;
    pub(super) use libc::dirent64 as dirent;
    pub(super) use libc::readdir64 as readdir;
}
#[cfg(not(any(all(target_os = "linux", not(target_env = "musl")), target_os = "hurd")))]
mod libc64 {
    pub(super) use libc::stat;
    pub(super) use libc::fstatat;
    pub(super) use libc::dirent;
    pub(super) use libc::readdir;
}

/// A thin wrapper around a `libc::stat64`.
pub(crate) struct Metadata(libc64::stat);

impl Metadata {
    /// The size.
    pub fn size(&self) -> u64 {
        self.0.st_size as u64
    }

    /// The modification time as the time since the Unix epoch.
    pub fn modified(&self) -> std::time::Duration {
        // Netbsd-like systems.  See:
        // https://github.com/rust-lang/libc/blob/a0f5b4b21391252fe38b2df9310dc65e37b07d9f/src/unix/bsd/mod.rs#L931
        #[cfg(any(target_os = "openbsd", target_os = "netbsd"))]
        return std::time::Duration::new(
            self.0.st_mtime as u64,
            self.0.st_mtimensec as u32);

        #[cfg(not(any(target_os = "openbsd", target_os = "netbsd")))]
        return std::time::Duration::new(
            self.0.st_mtime as u64,
            self.0.st_mtime_nsec as u32);
    }

    /// Whether a file is a directory.
    pub fn is_dir(&self) -> bool {
        (self.0.st_mode & libc::S_IFMT) == libc::S_IFDIR
    }
}

impl std::convert::From<&crate::unixdir::Metadata> for Tag {
    fn from(m: &Metadata) -> Self {
        let d = m.modified();
        let size = m.size();

        Tag::new(d.as_secs(), d.subsec_nanos(), size, m.is_dir())
    }
}

impl Metadata {
    fn fstat(dir: *mut libc::DIR,
             nul_terminated_filename: &[u8])
             -> Result<Self>
    {
        // The last character must be a NUL, i.e., this has to be a c string.
        assert_eq!(nul_terminated_filename[nul_terminated_filename.len() - 1],
                   0);

        let dirfd = unsafe { libc::dirfd(dir) };
        if dirfd == -1 {
            return Err(std::io::Error::last_os_error());
        }

        let mut statbuf = std::mem::MaybeUninit::<libc64::stat>::uninit();

        let result = unsafe {
            libc64::fstatat(
                dirfd,
                nul_terminated_filename.as_ptr() as *const c_char,
                statbuf.as_mut_ptr(),
                libc::AT_SYMLINK_NOFOLLOW,
            )
        };
        if result == -1 {
            return Err(std::io::Error::last_os_error());
        }

        Ok(Metadata(unsafe { statbuf.assume_init() }))
    }
}

/// A thin wrapper for a `libc::dirent64`.
///
/// [`libc::dirent64`](https://docs.rs/libc/latest/libc/struct.dirent64.html)
pub(crate) struct DirEntry {
    dir: *mut libc::DIR,

    // Get ready for some insanity.
    //
    // On some platforms, `dirent.d_name` (or `dirent.d_name`) may be
    // a fixed-sized array, but the implementation uses a different
    // structure, which has a flex array.  This means that if we're
    // not careful about how we use the raw pointer, we may end up
    // copying the struct, which results in a seg fault when the
    // underlying struct is smaller than the public struct!  A Rust
    // libc comment corroborates this:
    //
    // > The dirent64 pointers that libc returns from readdir64 are
    // > allowed to point to allocations smaller _or_ LARGER than
    // > implied by the definition of the struct.
    //
    // https://github.com/rust-lang/rust/blob/5a2dd7d4f3210629e65879aeecbe643ba3b86bb4/library/std/src/sys/pal/unix/fs.rs#L733
    //
    // This means that we can't do the following, which copies the
    // `struct dirent`:
    //
    // ```
    // unsafe { *self.entry }.d_type
    // ```
    //
    // Instead, we need to do the following, which returns a reference
    // to the `struct dirent`:
    //
    // ```
    // unsafe { (&*self.entry).d_type }
    // ```
    entry: *mut libc64::dirent,

    name_len: OnceCell<usize>,
    // We save the metadata inline to avoid a heap allocation.
    metadata: OnceCell<Result<Metadata>>,
}

impl DirEntry {
    // Return `self.entry`'s d_type, safely.
    //
    // See the insanity comment above.
    fn entry_d_type(&self) -> libc::c_uchar {
        unsafe { (&*self.entry).d_type }
    }

    // Return `self.entry`'s d_name, safely.
    //
    // See the insanity comment above.
    fn entry_d_name(&self) -> *const c_char {
        unsafe { (&*self.entry).d_name.as_ptr() as *const c_char }
    }

    /// Returns the file's type, as recorded in the directory.
    pub fn file_type(&self) -> FileType {
        FileType(self.entry_d_type())
    }

    /// Returns the filename.
    ///
    /// Note: this is not NUL terminated.
    pub fn file_name(&self) -> &[u8] {
        unsafe {
            let name = self.entry_d_name();

            let name_len = *self.name_len.get_or_init(|| {
                libc::strlen(name)
            });

            std::slice::from_raw_parts(
                name as *const u8,
                name_len)
        }
    }

    /// Stats the file.
    ///
    /// To avoid a heap allocation, the data struct is stored inline.
    /// To avoid races, the lifetime is bound to self.
    pub fn metadata(&self) -> Result<&Metadata> {
        // Rewrite this to use OnceCell::get_or_try_init once that has
        // stabilized.  Until then we do a little dance with the
        // Result.
        let result = self.metadata.get_or_init(|| {
            let dirfd = unsafe { libc::dirfd(self.dir) };
            if dirfd == -1 {
                return Err(std::io::Error::last_os_error());
            }

            let mut statbuf = std::mem::MaybeUninit::<libc64::stat>::uninit();

            let result = unsafe {
                libc64::fstatat(
                    dirfd,
                    self.entry_d_name(),
                    statbuf.as_mut_ptr(),
                    libc::AT_SYMLINK_NOFOLLOW,
                )
            };
            if result == -1 {
                return Err(std::io::Error::last_os_error());
            }

            Ok(Metadata(unsafe { statbuf.assume_init() }))
        });

        match result {
            Ok(metadata) => Ok(metadata),
            Err(err) => {
                if let Some(underlying) = err.get_ref() {
                    // We can't clone the error, so we clone the error
                    // kind and turn the error into a string.  It's
                    // not great, but its good enough for us.
                    Err(std::io::Error::new(
                        err.kind(),
                        underlying.to_string()))
                } else {
                    Err(std::io::Error::from(err.kind()))
                }
            },
        }
    }
}

pub(crate) struct Dir {
    dir: Option<*mut libc::DIR>,
    entry: Option<DirEntry>,
}

impl Drop for Dir {
    fn drop(&mut self) {
        if let Some(dir) = self.dir.take() {
            unsafe { libc::closedir(dir) };
        }
        self.entry = None;
    }
}

impl Dir {
    pub fn open(dir: &Path) -> Result<Self> {
        let mut dir = dir.as_os_str().to_os_string().into_vec();
        // NUL-terminate it.
        dir.push(0);
        let dir = unsafe { CStr::from_ptr(dir.as_ptr() as *const c_char) };
        let dir = unsafe { libc::opendir(dir.as_ptr().cast()) };
        if dir.is_null() {
            return Err(std::io::Error::last_os_error());
        }

        let dir = Dir {
            dir: Some(dir),
            entry: None,
        };
        Ok(dir)
    }

    /// Get the next directory entry.
    ///
    /// Returns None, if the end of directory has been reached.
    ///
    /// DirEntry is deallocated when the directory pointer is
    /// advanced.  Hence, the lifetime of the returned DirEntry is
    /// tied to the lifetime of the &mut to self.
    pub fn readdir(&mut self) -> Option<&mut DirEntry> {
       let dir = self.dir?;

        let entry = unsafe { libc64::readdir(dir) };
        if entry.is_null() {
            unsafe { libc::closedir(dir) };
            self.dir = None;
            self.entry = None;
            return None;
        }

        self.entry = Some(DirEntry {
            dir,
            entry,
            name_len: OnceCell::default(),
            metadata: OnceCell::default(),
        });
        self.entry.as_mut()
    }

    /// Stat an entry in the directory.
    pub fn fstat(&mut self, nul_terminated_filename: &[u8]) -> Result<Metadata> {
        let dir = self.dir.ok_or_else(|| {
            std::io::Error::new(std::io::ErrorKind::Other, "Directory closed")
        })?;

        Metadata::fstat(dir, nul_terminated_filename)
    }
}
