//! Mount options — fstab(5) field 4 (`fs_mntops`).
//!
//! Provides parsing, serialization, classification (VFS/filesystem/userspace),
//! and querying of comma-separated mount options with support for quoted
//! values containing commas.

use crate::error::{OptItemError, OptionsError};
use crate::escape::decode_escapes;
use std::fmt;
use std::str::FromStr;

/// Classification of a mount option.
///
/// Determines whether an option belongs to the VFS layer, a specific
/// filesystem driver, or userspace.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum OptionClass {
    /// VFS (Virtual File System) option — applies at the kernel VFS layer.
    /// Examples: `ro`, `noatime`, `bind`, `suid`.
    Vfs,
    /// Filesystem-specific option — handled by the individual filesystem driver.
    /// Examples: `sync`, `async`.
    Filesystem,
    /// Userspace option — consumed by userspace mount helpers.
    /// Examples: `defaults`, `user`, `_netdev`, `nofail`.
    Userspace,
}

/// A single mount option item, consisting of a name and an optional value.
///
/// # Examples
///
/// ```
/// # use mount_fstab::options::OptItem;
/// let item = OptItem::flag("ro").unwrap();
/// assert_eq!(item.name(), "ro");
/// assert!(item.value().is_none());
///
/// let item = OptItem::new("size", Some("10G".into())).unwrap();
/// assert_eq!(item.value(), Some("10G"));
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OptItem {
    name: String,
    value: Option<String>,
}

impl OptItem {
    /// Create a new option item with an optional value.
    ///
    /// # Errors
    ///
    /// Returns [`OptItemError::EmptyName`] if the name is empty.
    pub fn new(name: impl Into<String>, value: Option<String>) -> Result<Self, OptItemError> {
        let name = name.into();
        if name.is_empty() {
            return Err(OptItemError::EmptyName);
        }
        Ok(OptItem { name, value })
    }

    /// Create a flag option (no value).
    ///
    /// # Errors
    ///
    /// Returns [`OptItemError::EmptyName`] if the name is empty.
    pub fn flag(name: impl Into<String>) -> Result<Self, OptItemError> {
        Self::new(name, None)
    }

    /// The option name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// The option value, if present.
    #[must_use]
    pub fn value(&self) -> Option<&str> {
        self.value.as_deref()
    }

    /// The classification of this option (VFS, filesystem, or userspace).
    ///
    /// Returns `None` for unknown options.
    #[must_use]
    pub fn class(&self) -> Option<OptionClass> {
        classify_option(&self.name)
    }

    /// Whether this option is a known option (has a classification).
    #[must_use]
    pub fn is_known(&self) -> bool {
        self.class().is_some()
    }
}

impl fmt::Display for OptItem {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.value {
            Some(v) if v.contains(',') => write!(f, "{}=\"{}\"", self.name, v),
            Some(v) => write!(f, "{}={}", self.name, v),
            None => write!(f, "{}", self.name),
        }
    }
}

/// Ordered collection of mount options — fstab(5) field 4.
///
/// Options are stored in order and parsed from a comma-separated string
/// with support for quoted values containing commas.
///
/// # Examples
///
/// ```
/// # use mount_fstab::options::Options;
/// let opts = Options::parse("rw,noatime,size=10G").unwrap();
/// assert!(opts.has("rw"));
/// assert_eq!(opts.get("size"), Some("10G"));
/// assert_eq!(opts.len(), 3);
/// ```
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Options {
    items: Vec<OptItem>,
}

impl Options {
    /// Create an empty options collection.
    #[must_use]
    pub fn new() -> Self {
        Options { items: Vec::new() }
    }

    /// Create options with a single `defaults` flag.
    ///
    /// This matches the convention that "defaults" is the standard
    /// placeholder for default mount options in `/etc/fstab`.
    #[must_use]
    pub fn defaults() -> Self {
        Options {
            items: vec![OptItem {
                name: "defaults".to_owned(),
                value: None,
            }],
        }
    }

    /// Parse a comma-separated options string.
    ///
    /// Supports quoted values (both single and double quotes) to preserve
    /// commas within values (e.g., `context="unconfined_u:object_r:user_tmp_t:s0"`).
    ///
    /// # Errors
    ///
    /// Returns [`OptionsError::EmptyOptionName`] if an option has an empty name.
    pub fn parse(raw: &str) -> Result<Self, OptionsError> {
        if raw.is_empty() {
            return Ok(Options::new());
        }
        let tokens = split_options(raw);
        let mut items = Vec::with_capacity(tokens.len());
        for token in tokens {
            let decoded = decode_escapes(token);
            let item = if let Some(eq) = decoded.find('=') {
                let name = decoded[..eq].to_owned();
                let raw_value = &decoded[eq + 1..];
                let value = strip_quotes(raw_value);
                OptItem::new(name, Some(value.to_owned()))
                    .map_err(|_| OptionsError::EmptyOptionName)?
            } else {
                OptItem::flag(decoded).map_err(|_| OptionsError::EmptyOptionName)?
            };
            items.push(item);
        }
        Ok(Options { items })
    }

    /// Returns `true` if the options collection is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// Number of options in this collection.
    #[must_use]
    pub fn len(&self) -> usize {
        self.items.len()
    }

    /// Get the value of the last occurrence of an option by name.
    ///
    /// Returns `None` if the option is not found or is a flag (no value).
    /// Returns `Some("")` if the option was specified with an empty value
    /// (e.g., `key=`).
    ///
    /// # Last-option-wins semantics
    ///
    /// Per mount(8), if an option appears multiple times, the last occurrence
    /// wins. This method scans from the end of the list.
    ///
    /// # Examples
    ///
    /// ```
    /// # use mount_fstab::Options;
    /// let opts = Options::parse("size=10G,ro").unwrap();
    /// assert_eq!(opts.get("size"), Some("10G"));
    /// assert_eq!(opts.get("ro"), None); // flag option has no value
    /// assert_eq!(opts.get("nonexistent"), None);
    /// ```
    #[must_use]
    pub fn get(&self, name: &str) -> Option<&str> {
        self.items
            .iter()
            .rev()
            .find(|item| item.name == name)
            .and_then(|item| item.value.as_deref())
    }

    /// Check whether an option by name is present (as a flag or key=value).
    ///
    /// # Examples
    ///
    /// ```
    /// # use mount_fstab::Options;
    /// let opts = Options::parse("rw,noatime,size=10G").unwrap();
    /// assert!(opts.has("rw"));
    /// assert!(opts.has("noatime"));
    /// assert!(opts.has("size"));
    /// assert!(!opts.has("ro"));
    /// ```
    #[must_use]
    pub fn has(&self, name: &str) -> bool {
        self.items.iter().any(|item| item.name == name)
    }

    /// Alias for [`has`](Self::has), for consistency with `std::collections::HashSet`.
    #[must_use]
    pub fn contains(&self, name: &str) -> bool {
        self.has(name)
    }

    /// Iterate over all option items in order.
    pub fn iter(&self) -> impl Iterator<Item = &OptItem> {
        self.items.iter()
    }

    /// Whether the options imply a read-only mount.
    ///
    /// Uses last-option-wins semantics: `ro` returns `true`,
    /// `rw` or `defaults` returns `false`.
    #[must_use]
    pub fn is_readonly(&self) -> bool {
        for item in self.items.iter().rev() {
            match item.name.as_str() {
                "ro" => return true,
                "rw" => return false,
                "defaults" => return false,
                _ => {}
            }
        }
        false
    }

    /// Whether the options imply `noauto` (do not mount automatically at boot).
    ///
    /// Uses last-option-wins semantics.
    #[must_use]
    pub fn is_noauto(&self) -> bool {
        for item in self.items.iter().rev() {
            match item.name.as_str() {
                "noauto" => return true,
                "auto" => return false,
                "defaults" => return false,
                _ => {}
            }
        }
        false
    }

    /// Whether the `nofail` option is present (do not halt boot on mount failure).
    #[must_use]
    pub fn has_nofail(&self) -> bool {
        self.has("nofail")
    }

    /// Whether the `_netdev` option is present (network-backed device).
    #[must_use]
    pub fn is_netdev(&self) -> bool {
        self.has("_netdev")
    }

    /// Determine the mount permission model based on options.
    ///
    /// Uses last-option-wins semantics.
    #[must_use]
    pub fn mount_permission(&self) -> MountPermission {
        for item in self.items.iter().rev() {
            match item.name.as_str() {
                "user" => return MountPermission::User,
                "users" => return MountPermission::Users,
                "owner" => return MountPermission::Owner,
                "group" => return MountPermission::Group,
                "nouser" => return MountPermission::None,
                "defaults" => return MountPermission::None,
                _ => {}
            }
        }
        MountPermission::None
    }

    /// Set a mount option, replacing any existing occurrence with the same name.
    ///
    /// The option is appended to the end (last-option-wins position).
    ///
    /// # Examples
    ///
    /// ```
    /// # use mount_fstab::Options;
    /// let mut opts = Options::parse("rw,noatime").unwrap();
    /// opts.set("ro", None).set("size", Some("10G"));
    /// assert!(opts.has("ro"));
    /// assert_eq!(opts.get("size"), Some("10G"));
    /// // Original "rw" replaced by "ro" (last-option-wins)
    /// assert!(opts.is_readonly());
    /// ```
    ///
    /// Returns `&mut Self` for chaining.
    pub fn set(&mut self, name: &str, value: Option<&str>) -> &mut Self {
        self.items.retain(|item| item.name != name);
        self.items.push(OptItem {
            name: name.to_owned(),
            value: value.map(|v| v.to_owned()),
        });
        self
    }

    /// Remove all occurrences of a mount option by name.
    ///
    /// Returns `&mut Self` for chaining.
    pub fn remove(&mut self, name: &str) -> &mut Self {
        self.items.retain(|item| item.name != name);
        self
    }

    /// Append an option item to the end of the list.
    ///
    /// Returns `&mut Self` for chaining.
    pub fn append(&mut self, item: OptItem) -> &mut Self {
        self.items.push(item);
        self
    }

    /// Prepend an option item at the beginning of the list.
    ///
    /// Returns `&mut Self` for chaining.
    pub fn prepend(&mut self, item: OptItem) -> &mut Self {
        self.items.insert(0, item);
        self
    }

    /// Iterate over VFS-classified options only.
    pub fn vfs_options(&self) -> impl Iterator<Item = &OptItem> {
        self.items
            .iter()
            .filter(|item| item.class() == Some(OptionClass::Vfs))
    }

    /// Iterate over filesystem-specific options only.
    pub fn fs_options(&self) -> impl Iterator<Item = &OptItem> {
        self.items
            .iter()
            .filter(|item| item.class() == Some(OptionClass::Filesystem))
    }

    /// Iterate over userspace options only.
    pub fn user_options(&self) -> impl Iterator<Item = &OptItem> {
        self.items
            .iter()
            .filter(|item| item.class() == Some(OptionClass::Userspace))
    }
}

impl FromStr for Options {
    type Err = OptionsError;

    /// Parse a comma-separated options string into an `Options` collection.
    ///
    /// Equivalent to [`Options::parse`].
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Options::parse(s)
    }
}

/// Try to convert a string into `Options`.
///
/// Equivalent to [`Options::parse`].
impl TryFrom<&str> for Options {
    type Error = OptionsError;

    fn try_from(s: &str) -> Result<Self, Self::Error> {
        Options::parse(s)
    }
}

/// Mount permission level for non-root users.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum MountPermission {
    /// No special permission granted.
    None,
    /// Any user can mount (`user`).
    User,
    /// Any user can mount or unmount (`users`).
    Users,
    /// Only the device owner can mount (`owner`).
    Owner,
    /// Only group members can mount (`group`).
    Group,
}

impl fmt::Display for Options {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        for (i, item) in self.items.iter().enumerate() {
            if i > 0 {
                f.write_str(",")?;
            }
            write!(f, "{item}")?;
        }
        Ok(())
    }
}

// ── Internal helpers ──

/// Split a comma-separated options string, respecting quoted values.
fn split_options(raw: &str) -> Vec<&str> {
    let mut items = Vec::new();
    let mut start = 0;
    let mut quote: Option<char> = None;
    for (i, ch) in raw.char_indices() {
        match (quote, ch) {
            (None, '"') => quote = Some('"'),
            (None, '\'') => quote = Some('\''),
            (Some(q), c) if c == q => quote = None,
            (None, ',') => {
                items.push(&raw[start..i]);
                start = i + 1;
            }
            _ => {}
        }
    }
    items.push(&raw[start..]);
    items
}

/// Strip matching surrounding quotes from a value string.
fn strip_quotes(s: &str) -> &str {
    let s = s.trim();
    if s.len() >= 2 {
        let bytes = s.as_bytes();
        if (bytes[0] == b'"' && bytes[s.len() - 1] == b'"')
            || (bytes[0] == b'\'' && bytes[s.len() - 1] == b'\'')
        {
            return &s[1..s.len() - 1];
        }
    }
    s
}

/// Known VFS mount option names.
const VFS_OPTIONS: &[&str] = &[
    "ro",
    "rw",
    "exec",
    "noexec",
    "suid",
    "nosuid",
    "dev",
    "nodev",
    "remount",
    "bind",
    "rbind",
    "atime",
    "noatime",
    "diratime",
    "nodiratime",
    "relatime",
    "norelatime",
    "strictatime",
    "nostrictatime",
    "symfollow",
    "nosymfollow",
    "silent",
    "loud",
    "iversion",
    "noiversion",
    "shared",
    "rshared",
    "slave",
    "rslave",
    "private",
    "rprivate",
    "unbindable",
    "runbindable",
];

/// Known filesystem-specific mount option names.
const FS_OPTIONS: &[&str] = &["sync", "async", "dirsync"];

/// Known userspace mount option names.
const USER_OPTIONS: &[&str] = &[
    "defaults",
    "auto",
    "noauto",
    "user",
    "nouser",
    "users",
    "owner",
    "group",
    "_netdev",
    "nofail",
    "loop",
    "offset",
    "sizelimit",
    "encryption",
    "uhelper",
    "helper",
];

/// Classify a mount option by name.
fn classify_option(name: &str) -> Option<OptionClass> {
    if name.starts_with("X-") || name.starts_with("x-") || name == "comment" {
        return Some(OptionClass::Userspace);
    }
    if name.starts_with("verity.") {
        return Some(OptionClass::Userspace);
    }
    if VFS_OPTIONS.contains(&name) {
        return Some(OptionClass::Vfs);
    }
    if FS_OPTIONS.contains(&name) {
        return Some(OptionClass::Filesystem);
    }
    if USER_OPTIONS.contains(&name) {
        return Some(OptionClass::Userspace);
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_options_with_quotes() {
        let result = split_options(r#"context="a,b",noatime"#);
        assert_eq!(result, vec![r#"context="a,b""#, "noatime"]);
    }

    #[test]
    fn split_simple() {
        assert_eq!(split_options("a,b,c"), vec!["a", "b", "c"]);
    }

    #[test]
    fn strip_double_quotes() {
        assert_eq!(strip_quotes("\"hello\""), "hello");
    }

    #[test]
    fn strip_single_quotes() {
        assert_eq!(strip_quotes("'hello'"), "hello");
    }

    #[test]
    fn parse_simple_flag() {
        let opts = Options::parse("defaults").unwrap();
        assert!(opts.has("defaults"));
    }

    #[test]
    fn parse_multiple_flags() {
        let opts = Options::parse("rw,noatime,nofail").unwrap();
        assert!(opts.has("rw"));
        assert!(opts.has("noatime"));
        assert!(opts.has("nofail"));
    }

    #[test]
    fn parse_key_value() {
        let opts = Options::parse("size=10G,mode=755").unwrap();
        assert_eq!(opts.get("size"), Some("10G"));
        assert_eq!(opts.get("mode"), Some("755"));
    }

    #[test]
    fn last_option_wins() {
        let opts = Options::parse("ro,rw").unwrap();
        assert!(!opts.is_readonly());
        let opts2 = Options::parse("rw,ro").unwrap();
        assert!(opts2.is_readonly());
    }

    #[test]
    fn quoted_value_preserves_comma() {
        let opts =
            Options::parse(r#"context="system_u:object_r:tmp_t:s0:c127,c456",noatime"#).unwrap();
        assert_eq!(
            opts.get("context"),
            Some("system_u:object_r:tmp_t:s0:c127,c456")
        );
        assert!(opts.has("noatime"));
    }

    #[test]
    fn single_quoted_value() {
        let opts = Options::parse("key='value,with,commas'").unwrap();
        assert_eq!(opts.get("key"), Some("value,with,commas"));
    }

    #[test]
    fn parse_empty_options() {
        let opts = Options::parse("").unwrap();
        assert!(opts.is_empty());
    }

    #[test]
    fn serialize_simple() {
        let opts = Options::parse("rw,noatime").unwrap();
        assert_eq!(opts.to_string(), "rw,noatime");
    }

    #[test]
    fn serialize_with_value() {
        let opts = Options::parse("size=10G,mode=755").unwrap();
        assert_eq!(opts.to_string(), "size=10G,mode=755");
    }

    #[test]
    fn serialize_roundtrip() {
        let inputs = [
            "defaults",
            "rw,noatime,nofail",
            "size=10G,mode=755",
            "ro,nosuid,nodev",
        ];
        for input in inputs {
            let opts = Options::parse(input).unwrap();
            assert_eq!(opts.to_string(), input, "roundtrip failed for: {input}");
        }
    }

    #[test]
    fn len_and_is_empty() {
        let opts = Options::new();
        assert!(opts.is_empty());
        assert_eq!(opts.len(), 0);

        let opts = Options::parse("a,b").unwrap();
        assert_eq!(opts.len(), 2);
        assert!(!opts.is_empty());
    }

    #[test]
    fn contains_works() {
        let opts = Options::parse("rw,noatime").unwrap();
        assert!(opts.contains("rw"));
        assert!(!opts.contains("foobar"));
    }

    #[test]
    fn set_adds_option() {
        let mut opts = Options::parse("rw").unwrap();
        opts.set("noatime", None);
        assert!(opts.has("noatime"));
    }

    #[test]
    fn remove_option() {
        let mut opts = Options::parse("rw,noatime,nofail").unwrap();
        opts.remove("noatime");
        assert!(!opts.has("noatime"));
        assert!(opts.has("rw"));
        assert!(opts.has("nofail"));
    }

    #[test]
    fn append_option() {
        let mut opts = Options::parse("rw").unwrap();
        opts.append(OptItem::flag("noatime").unwrap());
        assert_eq!(opts.to_string(), "rw,noatime");
    }

    #[test]
    fn is_readonly() {
        assert!(Options::parse("ro").unwrap().is_readonly());
        assert!(!Options::parse("rw").unwrap().is_readonly());
    }

    #[test]
    fn has_nofail() {
        assert!(Options::parse("nofail").unwrap().has_nofail());
        assert!(!Options::parse("defaults").unwrap().has_nofail());
    }

    #[test]
    fn is_netdev() {
        assert!(Options::parse("_netdev").unwrap().is_netdev());
        assert!(!Options::parse("defaults").unwrap().is_netdev());
    }

    #[test]
    fn option_classification() {
        let opts = Options::parse("ro,noexec").unwrap();
        for item in opts.vfs_options() {
            assert_eq!(item.class(), Some(OptionClass::Vfs));
        }
        let opts = Options::parse("sync").unwrap();
        for item in opts.fs_options() {
            assert_eq!(item.class(), Some(OptionClass::Filesystem));
        }
        let opts = Options::parse("nofail,_netdev").unwrap();
        for item in opts.user_options() {
            assert_eq!(item.class(), Some(OptionClass::Userspace));
        }
    }

    #[test]
    fn unknown_option_is_none_class() {
        let item = OptItem::flag("mycustomopt").unwrap();
        assert_eq!(item.class(), None);
    }

    #[test]
    fn optitem_empty_name_is_error() {
        assert!(OptItem::flag("").is_err());
    }

    #[test]
    fn iter_preserves_order() {
        let opts = Options::parse("a,b,c,d").unwrap();
        let names: Vec<&str> = opts.iter().map(|i| i.name()).collect();
        assert_eq!(names, vec!["a", "b", "c", "d"]);
    }

    #[test]
    fn defaults_constructor() {
        let opts = Options::defaults();
        assert!(opts.has("defaults"));
    }

    #[test]
    fn from_str_works() {
        let opts: Options = "rw,noatime".parse().unwrap();
        assert!(opts.has("rw"));
        assert!(opts.has("noatime"));
    }

    #[test]
    fn set_returns_self_for_chaining() {
        let mut opts = Options::parse("rw").unwrap();
        opts.set("noatime", None).set("nofail", None);
        assert!(opts.has("rw"));
        assert!(opts.has("noatime"));
        assert!(opts.has("nofail"));
    }

    #[test]
    fn remove_returns_self_for_chaining() {
        let mut opts = Options::parse("a,b,c").unwrap();
        opts.remove("a").remove("b");
        let names: Vec<&str> = opts.iter().map(|o| o.name()).collect();
        assert_eq!(names, vec!["c"]);
    }
}
