use crate::{
    fds::wopen_cloexec,
    flog, flogf,
    path::{DirRemoteness, path_remoteness},
    prelude::*,
    wutil::{FileId, INVALID_FILE_ID, file_id_for_file, file_id_for_path, wdirname, wunlink},
};
use fish_tempfile::random_filename;
use fish_widestring::{osstr2wcstring, wcs2bytes, wcs2osstring};
use libc::{LOCK_EX, LOCK_SH, c_int};
use nix::{fcntl::OFlag, sys::stat::Mode};
use std::{
    ffi::OsString,
    fs::{File, OpenOptions},
    os::{
        fd::AsRawFd as _,
        unix::{ffi::OsStringExt as _, fs::MetadataExt as _},
    },
    path::PathBuf,
};

/// Creates a temporary file in the same directory as as `original_path`, meaning `original_path`
/// must be a valid file path. The filename will be created by appending random alphanumeric ASCII
/// chars to the `original_filename`.
fn create_temporary_file(original_path: &wstr) -> std::io::Result<(File, WString)> {
    let original_path = PathBuf::from(OsString::from_vec(wcs2bytes(original_path)));
    // original path must be a valid file path, so file_name should never return None.
    let prefix = original_path.file_name().unwrap().to_owned();
    let dir = original_path.parent().unwrap();
    let (path, result) =
        fish_tempfile::create_file_with_retry(|| dir.join(random_filename(prefix.clone())));
    match result {
        Ok(file) => Ok((file, osstr2wcstring(path))),
        Err(e) => {
            flog!(
                error,
                wgettext_fmt!(
                    "Unable to create temporary file '%s': %s",
                    path.to_wstring(),
                    e
                )
            );
            Err(e)
        }
    }
}

/// Use this struct for all accesses to file which need mutual exclusion.
/// Otherwise, races on the file are possible.
/// The lock is released when this struct is dropped.
pub struct LockedFile {
    /// This is the file which requires mutual exclusion.
    /// It should only be accessed through this struct,
    /// because the locks used here do not protect from other accesses to the file.
    data_file: File,
    /// The file descriptor of the parent directory, used for locking.
    /// The lock is not placed on the data file directly due to issues with renaming.
    /// If the data file is renamed after opening and before locking it,
    /// There are two independent files around, whose locks do not interact.
    /// In some cases this can be identified by checking file identifiers and timestamps,
    /// but even with such checks races and corresponding file corruption can occur.
    /// It is simpler to lock a different path, which does not change.
    ///
    /// We may fail to lock (e.g. on lockless NFS - see issue #685.
    /// In that case, we proceed as if locking succeeded.
    /// This might result in corruption,
    /// but the alternative of not being able to access the file at all is not desirable either.
    _locked_fd: File,
}

pub const LOCKED_FILE_MODE: Mode = Mode::from_bits_truncate(0o600);

pub enum LockingMode {
    Shared,
    Exclusive(WriteMethod),
}
pub enum WriteMethod {
    Append,
    RenameIntoPlace,
}

impl LockingMode {
    pub fn flock_op(&self) -> c_int {
        match self {
            Self::Shared => LOCK_SH,
            Self::Exclusive(_) => LOCK_EX,
        }
    }

    pub fn file_flags(&self) -> OFlag {
        match self {
            Self::Shared => OFlag::O_RDONLY,
            Self::Exclusive(WriteMethod::Append) => {
                OFlag::O_WRONLY | OFlag::O_APPEND | OFlag::O_CREAT
            }
            Self::Exclusive(WriteMethod::RenameIntoPlace) => OFlag::O_RDWR | OFlag::O_CREAT,
        }
    }
}

impl LockedFile {
    /// Creates a [`LockedFile`].
    /// Use this for any access to a which requires mutual exclusion, as it ensures correct locking.
    /// Use [`LockingMode::Exclusive`] if you want to modify the file in any way.
    /// Otherwise you should use [`LockingMode::Shared`].
    /// Two modes of modification are supported:
    /// - Appending
    /// - Writing to a temporary file which is then renamed into place.
    ///   File flags are derived from the [`LockingMode`].
    ///   `file_name` should just be a name, not a full path.
    pub fn new(locking_mode: LockingMode, file_path: &wstr) -> std::io::Result<Self> {
        let dir_path = wdirname(file_path);

        if path_remoteness(dir_path) == DirRemoteness::Remote {
            return Err(std::io::Error::new(
                std::io::ErrorKind::Unsupported,
                "Directory considered remote. Locking is disabled on remote file systems.",
            ));
        }

        // We start by locking the directory.
        // This is required to avoid racing modifications by other threads/processes.
        let dir_fd = wopen_cloexec(dir_path, OFlag::O_RDONLY, Mode::empty())?;

        {
            // Cygwin's `flock` is currently not thread safe (#11933)
            #[cfg(cygwin)]
            let _lock = {
                static FLOCK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
                FLOCK_LOCK.lock().unwrap()
            };

            // Try locking the directory. Retry if locking was interrupted.
            while unsafe { libc::flock(dir_fd.as_raw_fd(), locking_mode.flock_op()) } == -1 {
                let err = std::io::Error::last_os_error();
                if err.kind() != std::io::ErrorKind::Interrupted {
                    return Err(err);
                }
            }
        }

        // Open the data file
        let data_file = wopen_cloexec(file_path, locking_mode.file_flags(), LOCKED_FILE_MODE)?;

        Ok(Self {
            data_file,
            _locked_fd: dir_fd,
        })
    }

    pub fn get(&self) -> &File {
        &self.data_file
    }

    pub fn get_mut(&mut self) -> &mut File {
        &mut self.data_file
    }

    /// Calling `fsync` and then closing the file is required for correctness on some file systems
    /// before renaming the file.
    /// <https://www.comp.nus.edu.sg/~lijl/papers/ferrite-asplos16.pdf>
    /// <https://archive.kernel.org/oldwiki/btrfs.wiki.kernel.org/index.php/FAQ.html#What_are_the_crash_guarantees_of_overwrite-by-rename.3F>
    /// Returns the lock file so that the lock can be kept.
    pub fn fsync_close_and_keep_lock(self) -> std::io::Result<File> {
        fsync(&self.data_file)?;
        Ok(self._locked_fd)
    }
}

/// Reimplementation of `std::sys::fs::unix::File::fsync` using publicly accessible functionality.
/// This function is used instead of `sync_all` due to concerns of that being too slow on macOS,
/// since there `libc::fcntl(fd, libc::F_FULLFSYNC)` is used internally.
/// This weakens our guarantees on macOS.
pub fn fsync(file: &File) -> std::io::Result<()> {
    let fd = file.as_raw_fd();
    loop {
        match unsafe { libc::fsync(fd) } {
            0 => return Ok(()),
            -1 => {
                let os_error = std::io::Error::last_os_error();
                if os_error.kind() != std::io::ErrorKind::Interrupted {
                    flogf!(synced_file_access, "fsync failed: %s", os_error);
                    return Err(os_error);
                }
            }
            _ => panic!("fsync should only ever return 0 or -1"),
        }
    }
}

/// Runs the `load` function, which should see a consistent state of the file at `path`.
/// To ensure a consistent state, we use locks to prevent modifications by others.
/// If locking is unavailable, the `load` function might be executed multiple times,
/// until it manages a run without the file being modified in the meantime, or until the maximum
/// number of allowed attempts is reached.
/// If the file does not exist this function will return an error.
pub fn lock_and_load<F, UserData>(path: &wstr, load: F) -> std::io::Result<(FileId, UserData)>
where
    F: Fn(&File, FileId) -> std::io::Result<UserData>,
{
    match LockedFile::new(LockingMode::Shared, path) {
        Ok(locked_file) => {
            let file_id = file_id_for_file(locked_file.get());
            let user_data = load(locked_file.get(), file_id.clone())?;
            return Ok((file_id, user_data));
        }
        Err(e) => {
            flogf!(
                synced_file_access,
                "Error acquiring shared lock on the directory of '%s': %s",
                path,
                e,
            );
            // This function might be called when the file does not exist.
            if e.kind() == std::io::ErrorKind::NotFound {
                // There is no point in continuing in this function if the file does not
                // exist.
                return Err(e);
            }
        }
    }

    flog!(
        synced_file_access,
        "flock-based locking is disabled. Using fallback implementation."
    );

    // Fallback implementation for situations where locking is unavailable.
    let max_attempts = 1000;
    for _ in 0..max_attempts {
        // If we cannot open the file, there is nothing we can do,
        // so just return immediately.
        let file = wopen_cloexec(path, OFlag::O_RDONLY, Mode::empty())?;
        let initial_file_id = file_id_for_file(&file);
        let loaded_data = match load(&file, initial_file_id.clone()) {
            Ok(update_data) => update_data,
            Err(_) => {
                // Retry if load function failed. Because we do not hold a lock, this might be
                // caused by concurrent modifications.
                continue;
            }
        };

        let final_file_id = file_id_for_path(path);
        if initial_file_id != final_file_id {
            continue;
        }
        // If the file id did not change, we assume that we loaded a consistent state.
        return Ok((final_file_id, loaded_data));
    }
    Err(std::io::Error::other(
        "Failed to update the file. Locking is disabled, and the fallback code did not succeed within the permissible number of attempts.",
    ))
}

pub struct PotentialUpdate<UserData> {
    pub do_save: bool,
    pub data: UserData,
}

/// Use this function for updating a file based on its current content,
/// for files which might be accessed at the same time by other threads/processes.
/// The basic principle is to create a temporary file, take a lock on the file to be rewritten,
/// do the rewrite (which might involve reading the old file contents),
/// and finally release the lock.
/// Error handling (especially the case where locking is not possible) makes the implementation
/// non-straightforward.
///
/// # Arguments
///
/// - `path`: The path to the file which should be updated.
/// - `rewrite`: The function which handles reading from the file and writing to a temporary file.
///   The first argument is for the file to read from, the second for the temporary file to
///   write to.  On success, the value returned by `rewrite` is included in this functions
///   return value. Be careful about side effects of `rewrite`. It might get executed multiple
///   times. Try to avoid side effects and instead extract any data you might need and return
///   them on success.  Then, apply the desired side effects once this function has returned
///   successfully.
///
/// # Return value
///
/// On success, the [`FileId`] of the rewritten file is returned, alongside the value returned by
/// `rewrite`. Note that if locking is unavailable, the [`FileId`] might be the id of a different
/// version of the file, which was written after this function renamed the temporary file to `path`
/// but before we obtained the [`FileId`] from `path`. This is a race condition we do not detect.
pub fn rewrite_via_temporary_file<F, UserData>(
    path: &wstr,
    rewrite: F,
) -> std::io::Result<(FileId, PotentialUpdate<UserData>)>
where
    F: Fn(&File, &mut File) -> std::io::Result<PotentialUpdate<UserData>>,
{
    /// Updates the metadata of the `new_file` to match the `old_file`.
    /// Also updates the mtime of the `new_file` to the current time manually, to work around
    /// operating systems which do not update the internal time used for such time stamps
    /// frequently enough. This is important for [`FileId`] comparisons.
    fn update_metadata(old_file: &File, new_file: &File) {
        // Ensure we maintain the ownership and permissions of the original (#2355). If the
        // stat fails, we assume (hope) our default permissions are correct. This
        // corresponds to e.g. someone running sudo -E as the very first command. If they
        // did, it would be tricky to set the permissions correctly. (bash doesn't get this
        // case right either).
        if let Ok(md) = old_file.metadata() {
            if let Err(e) = std::os::unix::fs::fchown(new_file, Some(md.uid()), Some(md.gid())) {
                flog!(
                    synced_file_access,
                    "Error when changing ownership of file:",
                    e
                );
            }
            if let Err(e) = new_file.set_permissions(md.permissions()) {
                flog!(synced_file_access, "Error when changing mode of file:", e);
            }
        } else {
            flog!(synced_file_access, "Could not get metadata for file");
        }
        // Linux by default stores the mtime with low precision, low enough that updates that occur
        // in quick succession may result in the same mtime (even the nanoseconds field). So
        // manually set the mtime of the new file to a high-precision clock. Note that this is only
        // necessary because Linux aggressively reuses inodes, causing the ABA problem; on other
        // platforms we tend to notice the file has changed due to a different inode.
        //
        // The current time within the Linux kernel is cached, and generally only updated on a timer
        // interrupt. So if the timer interrupt is running at 10 milliseconds, the cached time will
        // only be updated once every 10 milliseconds.
        #[cfg(any(target_os = "linux", target_os = "android"))]
        {
            use libc::timespec;
            let mut time = timespec {
                tv_sec: 0,
                tv_nsec: 0,
            };
            if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut time) } == 0 {
                let times = [
                    timespec {
                        tv_sec: 0,
                        tv_nsec: libc::UTIME_OMIT, // don't change atime,
                    },
                    time,
                ];
                unsafe {
                    // This accesses both times[0] and times[1]. Check `utimensat(2)` for details.
                    libc::futimens(new_file.as_raw_fd(), &times[0]);
                }
            }
        }
    }

    /// Renames a file from `old_name` to `new_name`.
    fn rename(old_name: &wstr, new_name: &wstr) -> std::io::Result<()> {
        if let Err(e) = std::fs::rename(wcs2osstring(old_name), wcs2osstring(new_name)) {
            flog!(
                error,
                wgettext_fmt!("Error when renaming file: %s", e.to_string())
            );
            return Err(e);
        }
        Ok(())
    }

    // This contains the main body of the surrounding function.
    // It is wrapped as its own function to allow unlinking the tmpfile reliably.
    // This can be achieved more concisely using a `ScopeGuard` which unlinks the file when it is
    // closed, but this does not work if the file should be closed before renaming, which is
    // necessary for the whole process to work reliably on some filesystems.
    fn try_rewriting<F, UserData>(
        path: &wstr,
        rewrite: F,
        tmp_name: &wstr,
        mut tmp_file: File,
    ) -> std::io::Result<(FileId, PotentialUpdate<UserData>)>
    where
        F: Fn(&File, &mut File) -> std::io::Result<PotentialUpdate<UserData>>,
    {
        // We want to rewrite the file.
        // To avoid issues with crashes during writing,
        // we write to a temporary file and once we are done, this file is renamed such that it
        // replaces the original file.
        // To avoid races, we need to have exclusive access to the file for the entire
        // duration, which we get via an exclusive lock on the parent directory.
        // Taking a shared lock first and later upgrading to an exclusive one could result in a
        // deadlock, so we take an exclusive one immediately.
        match LockedFile::new(LockingMode::Exclusive(WriteMethod::RenameIntoPlace), path) {
            Ok(locked_file) => {
                let potential_update = rewrite(locked_file.get(), &mut tmp_file)?;
                // In case the `LockedFile` is destroyed, this variable keeps the lock file in
                // scope, to prevent releasing the lock too early.
                let mut _lock_file = None;
                if potential_update.do_save {
                    update_metadata(locked_file.get(), &tmp_file);
                    _lock_file = Some(locked_file.fsync_close_and_keep_lock()?);
                    rename(tmp_name, path)?;
                }
                return Ok((file_id_for_path(path), potential_update));
            }
            Err(e) => {
                flogf!(
                    synced_file_access,
                    "Error acquiring exclusive lock on the directory of '%s': %s",
                    path,
                    e,
                );
            }
        }

        // If this is reached, we assume that locking is not available so we use a fallback
        // implementation which tries to avoid race conditions, but in the case of contention it is
        // possible that some writes are lost.

        flog!(
            synced_file_access,
            "flock-based locking is disabled. Using fallback implementation."
        );

        // Give up after this many unsuccessful attempts.
        // TODO: which value, should this be a constant shared with other retrying logic?
        let max_attempts = 1000;
        for _ in 0..max_attempts {
            // Reopen the tmpfile. This is important because it might have been closed by
            // explicitly dropping it.
            tmp_file = OpenOptions::new()
                .write(true)
                .truncate(true)
                .open(wcs2osstring(tmp_name))?;

            // If the file does not exist yet, this will be `INVALID_FILE_ID`.
            let initial_file_id = file_id_for_path(path);
            // If we cannot open the file, there is nothing we can do,
            // so just return immediately.
            let old_file = wopen_cloexec(path, OFlag::O_RDONLY | OFlag::O_CREAT, LOCKED_FILE_MODE)?;
            let opened_file_id = file_id_for_file(&old_file);
            if initial_file_id != INVALID_FILE_ID && initial_file_id != opened_file_id {
                // File ID changed (and not just because the file was created by us).
                continue;
            }
            let Ok(potential_update) = rewrite(&old_file, &mut tmp_file) else {
                // Retry if rewrite function failed. Because we do not hold a lock, this might be
                // caused by concurrent modifications.
                continue;
            };

            if potential_update.do_save {
                update_metadata(&old_file, &tmp_file);
                // fsync + close as described in
                // https://archive.kernel.org/oldwiki/btrfs.wiki.kernel.org/index.php/FAQ.html#What_are_the_crash_guarantees_of_overwrite-by-rename.3F
                fsync(&tmp_file)?;
                std::mem::drop(tmp_file);
            }

            let mut final_file_id = file_id_for_path(path);
            if opened_file_id != final_file_id {
                continue;
            }

            // If we reach this point, the file ID did not change while we read the old file and wrote
            // to `tmp_file`. Now we replace the old file with the `tmp_file`.
            // Note that we cannot prevent races here.
            // If the file is modified by someone else between the syscall for determining the [`FileId`]
            // and the rename syscall, these modifications will be lost.

            if potential_update.do_save {
                // Do not retry on rename failures, as it is unlikely that these will disappear if we retry.
                rename(tmp_name, path)?;
                final_file_id = file_id_for_path(path);
            }
            // Note that this might not match the version of the file we just wrote.
            // (If we did write.)
            return Ok((final_file_id, potential_update));
        }
        Err(std::io::Error::other(
            "Failed to update the file. Locking is disabled, and the fallback code did not succeed within the permissible number of attempts.",
        ))
    }

    let (tmp_file, tmp_name) = create_temporary_file(path)?;
    let result = try_rewriting(path, rewrite, &tmp_name, tmp_file);
    // Do not leave the tmpfile around.
    // Note that we do not unlink when renaming succeeded.
    // In that case, it would be unnecessary, because the file will no longer exist at the path,
    // but it also enables a race condition, where after renaming succeeded, a different fish
    // instance creates a tmpfile with the same name (unlikely but possible), which we would then
    // delete here.
    if result.is_err()
        || result
            .as_ref()
            .is_ok_and(|(_file_id, potential_update)| !potential_update.do_save)
    {
        let _ = wunlink(&tmp_name);
    }
    result
}
