use std::{
    fs::{self, File, OpenOptions},
    io::{ErrorKind, Read as _, Result, Write as _},
    path::Path,
};

use crate::error::{NetavarkError, NetavarkResult};

pub fn apply_sysctl_value(ns_value: impl AsRef<str>, val: impl AsRef<str>) -> NetavarkResult<()> {
    _apply_sysctl_value(&ns_value, val)
        .map_err(|e| NetavarkError::wrap(format!("set sysctl {}", ns_value.as_ref()), e.into()))
}

/// Set a sysctl value by value's namespace.
/// ns_value is the path of the sysctl (using slashes not dots!) and without the "/proc/sys/" prefix.
fn _apply_sysctl_value(ns_value: impl AsRef<str>, val: impl AsRef<str>) -> Result<()> {
    const PREFIX: &str = "/proc/sys/";
    let ns_value = ns_value.as_ref();
    let mut path = String::with_capacity(PREFIX.len() + ns_value.len());
    path.push_str(PREFIX);
    path.push_str(ns_value);
    let val = val.as_ref();

    log::debug!("Setting sysctl value for {ns_value} to {val}");

    let mut f = File::open(&path)?;
    let mut buf = String::with_capacity(1);
    f.read_to_string(&mut buf)?;

    if buf.trim() == val {
        return Ok(());
    }

    let mut f = OpenOptions::new().write(true).open(&path)?;
    f.write_all(val.as_bytes())
}

pub fn disable_ipv6_autoconf(if_name: &str) -> NetavarkResult<()> {
    // make sure autoconf is off, we want manual config only
    if let Err(err) = _apply_sysctl_value(format!("net/ipv6/conf/{if_name}/autoconf"), "0") {
        match err.kind() {
            ErrorKind::NotFound => {
                // if the sysctl is not found we likely run on a system without ipv6
                // just ignore that case
            }

            // if we have a read only /proc we ignore it as well
            ErrorKind::ReadOnlyFilesystem => {}

            _ => {
                return Err(NetavarkError::wrap(
                    "failed to set autoconf sysctl",
                    err.into(),
                ));
            }
        }
    };
    Ok(())
}

pub fn get_bridge_sysctl_d_path(bridge_name: &str) -> String {
    format!("/run/sysctl.d/10-netavark-{bridge_name}.conf")
}

pub struct SysctlDWriter<'a, P: AsRef<Path>, V: AsRef<str> + 'a> {
    path: Option<P>,
    sysctls: Vec<(V, &'a str)>,
    commit: bool,
}

impl<'a, P: AsRef<Path>, V: AsRef<str> + 'a> SysctlDWriter<'a, P, V> {
    /// Create a new sysctl writer for the given file path and sysctls values.
    /// When path is not None is set it will write a sysctl.d(5) file to the path.
    ///
    /// Note SysctlDWriter should be created before any interfaces that are referenced
    /// in the given sysctls. The reason is systemd will read the file and automatically
    /// apply the values as oon as the interface is created.
    /// If the config file is created after the interface there is the race condition in
    /// which systemd-sysctl might not read the confif file and writes the old values.
    /// And that then races against the write_sysctls() call.
    ///
    /// Also write_sysctls() is separate because we can only directly write to /proc file
    /// once the interface exist so we need two steps.
    ///
    /// Important one must call commit() otherwise we remove the file again on drop.
    pub fn new(path: Option<P>, sysctls: Vec<(V, &'a str)>) -> Self {
        if let Some(path) = &path {
            // Note using create_new here as we don't want to overwrite an existing file
            // If such file already exists it should be safe to assume it has the right
            // content so we can skip writing it again.
            let _ = File::create_new(path).and_then(|mut f| {
                if Self::write_file(&mut f, &sysctls).is_err() {
                    // remove file again on write errors
                    fs::remove_file(path)
                } else {
                    Ok(())
                }
            });
        }
        Self {
            path,
            sysctls,
            commit: false,
        }
    }

    /// write the given sysctl in format according to sysctl.d(5)
    fn write_file(f: &mut File, sysctls: &[(V, &'a str)]) -> Result<()> {
        let mut buf = String::with_capacity(1024);
        buf.push_str("# autogenerated by netavark\n");
        for (key, val) in sysctls {
            buf.push_str(key.as_ref());
            buf.push_str(" = ");
            buf.push_str(val);
            buf.push('\n');
        }
        f.write_all(buf.as_bytes())
    }

    // write the sysctls to /proc/sys/
    pub fn write_sysctls(&self) -> NetavarkResult<()> {
        for (key, val) in &self.sysctls {
            apply_sysctl_value(key, val)?;
        }
        Ok(())
    }

    /// mark as successfully written so the file is not removed on drop
    /// The reason we do this is to avoid manually removing the file on
    /// each early exit due errors.
    /// This also consumes the writer so it is impossible to keep writing
    /// after this is called.
    pub fn commit(mut self) {
        self.commit = true;
    }
}

impl<P: AsRef<Path>, V: AsRef<str>> Drop for SysctlDWriter<'_, P, V> {
    fn drop(&mut self) {
        if self.commit {
            // do nothing
            return;
        }
        if let Some(p) = &self.path {
            // ignore errors here
            let _ = fs::remove_file(p);
        }
    }
}

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

    #[test]
    fn test_sysctl_d_writer() {
        let tmp_dir = Builder::new()
            .prefix("example")
            .tempdir()
            .expect("failed to create tmpdir");
        let filepath = tmp_dir.path().join("test.conf");
        let w = SysctlDWriter::new(Some(&filepath), vec![("abc", "1"), ("def", "2")]);
        w.commit();
        let res = fs::read_to_string(filepath).expect("failed to read file");
        assert_eq!(res, "# autogenerated by netavark\nabc = 1\ndef = 2\n");
    }

    #[test]
    fn test_sysctl_d_writer_nocommit() {
        let tmp_dir = Builder::new()
            .prefix("example")
            .tempdir()
            .expect("failed to create tmpdir");
        let filepath = tmp_dir.path().join("test.conf");
        let w = SysctlDWriter::new(Some(&filepath), vec![("abc", "1"), ("def", "2")]);
        // drop the writer here which should delete the file
        drop(w);
        assert!(
            !filepath.exists(),
            "file should not exist after drop without commit()"
        )
    }
}
