// Copyright © 2025 Cyberus Technology GmbH
//
// SPDX-License-Identifier: Apache-2.0
//

//! Helpers for advisory file locking.
//!
//! Under the hood, the implementation uses OFD locks for the entire file,
//! as described in [[0]]. The advantage over `F_SETLKW` (currently used by
//! Rust std: `File::try_lock()`) is that only the very last `close()` on a
//! file descriptor releases the lock. This prevents mistakes and unexpected
//! behavior.
//!
//! [0]: <https://apenwarr.ca/log/20101213>.

use std::fmt::Debug;
use std::io;
use std::os::fd::{AsRawFd, RawFd};
use std::str::FromStr;

use thiserror::Error;

/// Errors that can happen when working with file locks.
#[derive(Error, Debug)]
pub enum LockError {
    /// The file is already locked.
    ///
    /// A call to [`get_lock_state`] can help to identify the reason.
    #[error("The file is already locked")]
    AlreadyLocked,
    /// IO error.
    #[error("The lock state could not be checked or set")]
    Io(#[source] io::Error),
}

/// Commands for use with [`fcntl`].
#[expect(non_camel_case_types)]
enum FcntlArg<'a> {
    /// Set an OFD lock from the given lock description.
    F_OFD_SETLK(&'a libc::flock),
    /// Get the first OFD lock for the given lock description.
    F_OFD_GETLK(&'a mut libc::flock),
}

/// Wrapper for [`libc::fcntl`] that properly sets the function arguments.
fn fcntl(fd: RawFd, arg: FcntlArg) -> libc::c_int {
    // SAFETY: We use a valid FD.
    unsafe {
        match arg {
            FcntlArg::F_OFD_SETLK(flock) => libc::fcntl(fd, libc::F_OFD_SETLK, flock),
            FcntlArg::F_OFD_GETLK(flock) => libc::fcntl(fd, libc::F_OFD_GETLK, flock),
        }
    }
}

/// Describes the type of lock you want to set.
#[derive(Clone, Copy, Debug)]
pub enum LockType {
    /// Clear a lock.
    Unlock,
    /// Set a write lock (exclusive).
    Write,
    /// Set a read lock (shared).
    Read,
}

impl LockType {
    pub const fn to_libc_val(self) -> libc::c_int {
        match self {
            Self::Unlock => libc::F_UNLCK as libc::c_int,
            Self::Write => libc::F_WRLCK as libc::c_int,
            Self::Read => libc::F_RDLCK as libc::c_int,
        }
    }
}

/// Describes the current state of a lock.
#[derive(Debug)]
pub enum LockState {
    /// No lock set.
    Unlocked,
    /// Locked for reading (non-exclusive).
    SharedRead,
    /// Locked for writing (exclusive mode).
    ExclusiveWrite,
}

impl LockState {
    fn new(value: libc::c_int) -> Self {
        const F_UNLCK: libc::c_int = libc::F_UNLCK as libc::c_int;
        const F_WRLCK: libc::c_int = libc::F_WRLCK as libc::c_int;
        const F_RDLCK: libc::c_int = libc::F_RDLCK as libc::c_int;
        match value {
            F_UNLCK => Self::Unlocked,
            F_WRLCK => Self::ExclusiveWrite,
            F_RDLCK => Self::SharedRead,
            // This is so unlikely that we want to avoid the complexity of
            // coping with this error case. Can only fail if either Linux
            // is broken or memory is messed up.
            other => panic!("Unexpected lock state: {other}"),
        }
    }
}

/// The granularity of the advisory lock.
///
/// The granularity has significant implications in typical cloud deployments
/// with network storage. The Linux kernel will sync advisory locks to network
/// file systems, but these backends may have different policies and handle
/// locks differently. For example, Netapp speaks a NFS API but will treat
/// advisory OFD locks for the whole file as mandatory locks, whereas byte-range
/// locks for the whole file will remain advisory [0].
///
/// As it is a valid use case to prevent multiple CHV instances from accessing
/// the same disk but disk management software (e.g., Cinder in OpenStack)
/// should be able to snapshot disks while VMs are running, we need special
/// control over the lock granularity. Therefore, it is a valid use case to lock
/// the whole byte range of a disk image without technically locking the whole
/// file - to get the best of both worlds.
///
/// [0] https://kb.netapp.com/on-prem/ontap/da/NAS/NAS-KBs/How_is_Mandatory_Locking_supported_for_NFSv4_on_ONTAP_9
#[derive(Clone, Copy, Debug)]
pub enum LockGranularity {
    WholeFile,
    ByteRange(u64 /* from, inclusive */, u64 /* len */),
}

impl LockGranularity {
    const fn l_start(self) -> u64 {
        match self {
            LockGranularity::WholeFile => 0,
            LockGranularity::ByteRange(start, _) => start,
        }
    }

    const fn l_len(self) -> u64 {
        match self {
            LockGranularity::WholeFile => 0, /* EOF */
            LockGranularity::ByteRange(_, len) => len,
        }
    }
}

/// User-facing choice for the lock granularity.
///
/// This allows external management software to create snapshots of the disk
/// image. Without a byte-range lock, some NFS implementations may treat the
/// entire file as exclusively locked and prevent such operations (e.g. NetApp).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub enum LockGranularityChoice {
    /// Byte-range lock covering [0, size).
    #[default]
    ByteRange,
    /// Whole-file lock (l_start=0, l_len=0) - original OFD whole-file lock behavior.
    Full,
}

/// Error returned when parsing a [`LockGranularityChoice`] from a string.
#[derive(Error, Debug)]
#[error("Invalid lock granularity value: {0}, expected 'byte-range' or 'full'")]
pub struct LockGranularityParseError(String);

impl FromStr for LockGranularityChoice {
    type Err = LockGranularityParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "byte-range" => Ok(LockGranularityChoice::ByteRange),
            "full" => Ok(LockGranularityChoice::Full),
            _ => Err(LockGranularityParseError(s.to_owned())),
        }
    }
}

/// Returns a [`struct@libc::flock`] structure for the whole file.
const fn get_flock(lock_type: LockType, granularity: LockGranularity) -> libc::flock {
    libc::flock {
        l_type: lock_type.to_libc_val() as libc::c_short,
        l_whence: libc::SEEK_SET as libc::c_short,
        l_start: granularity.l_start() as libc::c_long,
        l_len: granularity.l_len() as libc::c_long,
        l_pid: 0, /* filled by callee */
    }
}

/// Tries to acquire a lock using [`fcntl`] with respect to the given
/// parameters.
///
/// Please note that `fcntl()` OFD locks are **advisory locks**, which do not
/// prevent to `open()` a file if a lock is already placed.
///
/// # Parameters
/// - `file`: The file to acquire a lock for [`LockType`]. The file's state will
///   be logically mutated, but not technically.
/// - `lock_type`: The [`LockType`]
/// - `granularity`: The [`LockGranularity`].
pub fn try_acquire_lock<Fd: AsRawFd>(
    file: &Fd,
    lock_type: LockType,
    granularity: LockGranularity,
) -> Result<(), LockError> {
    let flock = get_flock(lock_type, granularity);

    loop {
        let res = fcntl(file.as_raw_fd(), FcntlArg::F_OFD_SETLK(&flock));
        match res {
            0 => return Ok(()),
            -1 => {
                let io_error = io::Error::last_os_error();
                let errno = io_error.raw_os_error().unwrap();
                match errno {
                    // See man page for error code:
                    // <https://man7.org/linux/man-pages/man2/fcntl.2.html>
                    libc::EAGAIN | libc::EACCES => return Err(LockError::AlreadyLocked),
                    libc::EINTR => continue,
                    _ => return Err(LockError::Io(io_error)),
                }
            }
            val => panic!("Unexpected return value from fcntl(): {val}"),
        }
    }
}

/// Clears a lock.
///
/// # Parameters
/// - `file`: The file to clear all locks for [`LockType`].
/// - `granularity`: The [`LockGranularity`].
pub fn clear_lock<Fd: AsRawFd>(file: &Fd, granularity: LockGranularity) -> Result<(), LockError> {
    try_acquire_lock(file, LockType::Unlock, granularity)
}

/// Returns the current lock state using [`fcntl`] with respect to the given
/// parameters.
///
/// # Parameters
/// - `file`: The file for which to get the lock state.
/// - `granularity`: The [`LockGranularity`].
pub fn get_lock_state<Fd: AsRawFd>(
    file: &Fd,
    granularity: LockGranularity,
) -> Result<LockState, LockError> {
    let mut flock = get_flock(LockType::Write, granularity);
    let res = fcntl(file.as_raw_fd(), FcntlArg::F_OFD_GETLK(&mut flock));
    match res {
        0 => {
            let state = flock.l_type as libc::c_int;
            let state = LockState::new(state);
            Ok(state)
        }
        -1 => {
            let io_error = io::Error::last_os_error();
            Err(LockError::Io(io_error))
        }
        val => panic!("Unexpected return value from fcntl(): {val}"),
    }
}
