use std::sync::Arc;

use crate::{
    config::{SshConfigManager, SshCredentials, SshOPNSenseShell},
    error::Error,
    modules::{
        caddy::CaddyConfig, dhcp::DhcpConfig, dns::DnsConfig, load_balancer::LoadBalancerConfig,
        tftp::TftpConfig,
    },
};
use log::{debug, info, trace, warn};
use opnsense_config_xml::OPNsense;
use russh::client;
use serde::Serialize;

use super::{ConfigManager, OPNsenseShell};

#[derive(Debug)]
pub struct Config {
    opnsense: OPNsense,
    repository: Arc<dyn ConfigManager>,
    shell: Arc<dyn OPNsenseShell>,
}

impl Serialize for Config {
    fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        todo!()
    }
}

impl Config {
    pub async fn new(
        repository: Arc<dyn ConfigManager>,
        shell: Arc<dyn OPNsenseShell>,
    ) -> Result<Self, Error> {
        Ok(Self {
            opnsense: Self::get_opnsense_instance(repository.clone()).await?,
            repository,
            shell,
        })
    }

    pub fn dhcp(&mut self) -> DhcpConfig {
        DhcpConfig::new(&mut self.opnsense, self.shell.clone())
    }

    pub fn dns(&mut self) -> DnsConfig {
        DnsConfig::new(&mut self.opnsense)
    }

    pub fn tftp(&mut self) -> TftpConfig {
        TftpConfig::new(&mut self.opnsense, self.shell.clone())
    }

    pub fn caddy(&mut self) -> CaddyConfig {
        CaddyConfig::new(&mut self.opnsense, self.shell.clone())
    }

    pub fn load_balancer(&mut self) -> LoadBalancerConfig {
        LoadBalancerConfig::new(&mut self.opnsense, self.shell.clone())
    }

    pub async fn upload_files(&self, source: &str, destination: &str) -> Result<String, Error> {
        self.shell.upload_folder(source, destination).await
    }

    /// Checks in config file if system.firmware.plugins csv field contains the specified package
    /// name.
    ///
    /// Given this
    /// ```xml
    /// <opnsense>
    ///     <system>
    ///         <firmware>
    ///             <plugins>os-haproxy,os-iperf,os-cpu-microcode-intel</plugins>
    ///         </firmware>
    ///     </system>
    /// </opnsense>
    /// ```
    ///
    /// is_package_installed("os-cpu"); // false
    /// is_package_installed("os-haproxy"); // true
    /// is_package_installed("os-cpu-microcode-intel"); // true
    pub fn is_package_installed(&self, package_name: &str) -> bool {
        match &self.opnsense.system.firmware.plugins.content {
            Some(plugins) => is_package_in_csv(plugins, package_name),
            None => false,
        }
    }

    // Here maybe we should take ownership of `mut self` instead of `&mut self`
    // I don't think there can be faulty pointers to previous versions of the config but I have a
    // hard time wrapping my head around it right now :
    // - the caller has a mutable reference to us
    // - caller gets a reference to a piece of configuration (.haproxy.general.servers[0])
    // - caller calls install_package wich reloads the config from remote
    // - haproxy.general.servers[0] does not exist anymore
    // - broken?
    //
    // Although I did not try explicitely the above workflow so maybe rust prevents taking a
    // read-only reference across the &mut call
    pub async fn install_package(&mut self, package_name: &str) -> Result<(), Error> {
        info!("Installing opnsense package {package_name}");
        self.check_pkg_opnsense_org_connection().await?;

        let output = self.shell
            .exec(&format!("/bin/sh -c \"export LOCKFILE=/dev/stdout && /usr/local/opnsense/scripts/firmware/install.sh {package_name}\""))
            .await?;
        info!("Installation output {output}");

        self.reload_config().await?;
        let is_installed = self.is_package_installed(package_name);
        debug!("Verifying package installed successfully {is_installed}");

        if is_installed {
            info!("Installation successful for {package_name}");
            Ok(())
        } else {
            let msg = format!("Package installation failed for {package_name}, see above logs");
            warn!("{}", msg);
            Err(Error::Unexpected(msg))
        }
    }

    pub async fn check_pkg_opnsense_org_connection(&mut self) -> Result<(), Error> {
        let pkg_url = "https://pkg.opnsense.org";
        info!("Verifying connection to {pkg_url}");
        let output = self
            .shell
            .exec(&format!("/bin/sh -c \"curl -v {pkg_url}\""))
            .await?;
        info!("{}", output);
        Ok(())
    }

    async fn reload_config(&mut self) -> Result<(), Error> {
        info!("Reloading opnsense live config");
        self.opnsense = Self::get_opnsense_instance(self.repository.clone()).await?;
        Ok(())
    }

    pub async fn restart_dns(&self) -> Result<(), Error> {
        self.shell.exec("configctl unbound restart").await?;
        Ok(())
    }

    /// Save the config to the repository. This method is meant NOT to reload services, only save
    /// the config to the live file/database and perhaps take a backup when relevant.
    pub async fn save(&self) -> Result<(), Error> {
        self.repository.save_config(&self.opnsense.to_xml()).await
    }

    /// Save the configuration and reload all services. Be careful with this one as it will cause
    /// downtime in many cases, such as a PPPoE renegociation
    pub async fn apply(&self) -> Result<(), Error> {
        self.repository
            .apply_new_config(&self.opnsense.to_xml())
            .await
    }

    pub async fn from_credentials(
        ipaddr: std::net::IpAddr,
        port: Option<u16>,
        username: &str,
        password: &str,
    ) -> Self {
        let config = Arc::new(client::Config {
            inactivity_timeout: None,
            ..<_>::default()
        });

        let credentials = SshCredentials::Password {
            username: String::from(username),
            password: String::from(password),
        };

        let port = port.unwrap_or(22);

        let shell = Arc::new(SshOPNSenseShell::new((ipaddr, port), credentials, config));
        let manager = Arc::new(SshConfigManager::new(shell.clone()));

        Config::new(manager, shell).await.unwrap()
    }

    async fn get_opnsense_instance(repository: Arc<dyn ConfigManager>) -> Result<OPNsense, Error> {
        let xml = repository.load_as_str().await?;
        trace!("xml {}", xml);

        Ok(OPNsense::from(xml))
    }

    pub async fn run_command(&self, command: &str) -> Result<String, Error> {
        self.shell.exec(command).await
    }
}

#[cfg(test)]
mod tests {
    use crate::config::{DummyOPNSenseShell, LocalFileConfigManager};
    use crate::modules::dhcp::DhcpConfig;
    use std::fs;
    use std::net::Ipv4Addr;

    use super::*;
    use pretty_assertions::assert_eq;
    use std::path::PathBuf;

    #[tokio::test]
    async fn test_load_config_from_local_file() {
        for path in vec![
            "src/tests/data/config-opnsense-25.1.xml",
            "src/tests/data/config-vm-test.xml",
            "src/tests/data/config-structure.xml",
            "src/tests/data/config-full-1.xml",
        ] {
            let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
            test_file_path.push(path);

            let config_file_path = test_file_path.to_str().unwrap().to_string();
            println!("File path {config_file_path}");
            let repository = Arc::new(LocalFileConfigManager::new(config_file_path));
            let shell = Arc::new(DummyOPNSenseShell {});
            let config_file_str = repository.load_as_str().await.unwrap();
            let config = Config::new(repository, shell)
                .await
                .expect("Failed to load config");

            println!("Config {:?}", config);

            let serialized = config.opnsense.to_xml();

            fs::write("/tmp/serialized.xml", &serialized).unwrap();

            // Since the order of all fields is not always the same in opnsense config files
            // I think it is good enough to have exactly the same amount of the same lines
            let config_file_str_sorted = vec![config_file_str.lines().collect::<Vec<_>>()].sort();
            let serialized_sorted = vec![config_file_str.lines().collect::<Vec<_>>()].sort();
            assert_eq!(config_file_str_sorted, serialized_sorted);
        }
    }

    #[tokio::test]
    async fn test_add_dhcpd_static_entry() {
        let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        test_file_path.push("src/tests/data/config-structure.xml");

        let config_file_path = test_file_path.to_str().unwrap().to_string();
        println!("File path {config_file_path}");
        let repository = Arc::new(LocalFileConfigManager::new(config_file_path));
        let shell = Arc::new(DummyOPNSenseShell {});
        let mut config = Config::new(repository, shell.clone())
            .await
            .expect("Failed to load config");

        println!("Config {:?}", config);

        let mut dhcp_config = DhcpConfig::new(&mut config.opnsense, shell);
        dhcp_config
            .add_static_mapping(
                "00:00:00:00:00:00",
                Ipv4Addr::new(192, 168, 20, 100),
                "hostname",
            )
            .expect("Should add static mapping");

        let serialized = config.opnsense.to_xml();

        fs::write("/tmp/serialized.xml", &serialized).unwrap();

        let mut test_file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
        test_file_path.push("src/tests/data/config-structure-with-dhcp-staticmap-entry.xml");

        let config_file_path = test_file_path.to_str().unwrap().to_string();
        println!("File path {config_file_path}");
        let repository = Box::new(LocalFileConfigManager::new(config_file_path));
        let expected_config_file_str = repository.load_as_str().await.unwrap();
        assert_eq!(expected_config_file_str, serialized);
    }
}

/// Checks if a given package name exists in a comma-separated list of packages.
///
/// # Arguments
///
/// * `csv_string` - A string containing comma-separated package names.
/// * `package_name` - The package name to search for.
///
/// # Returns
///
/// * `true` if the package name is found in the CSV string, `false` otherwise.
fn is_package_in_csv(csv_string: &str, package_name: &str) -> bool {
    package_name.len() > 0 && csv_string.split(',').any(|pkg| pkg.trim() == package_name)
}

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

    #[test]
    fn test_is_package_in_csv() {
        let csv_string = "os-haproxy,os-iperf,os-cpu-microcode-intel";

        assert!(is_package_in_csv(csv_string, "os-haproxy"));
        assert!(is_package_in_csv(csv_string, "os-iperf"));
        assert!(is_package_in_csv(csv_string, "os-cpu-microcode-intel"));

        assert!(!is_package_in_csv(csv_string, "os-cpu"));
        assert!(!is_package_in_csv(csv_string, "non-existent-package"));
    }

    #[test]
    fn test_is_package_in_csv_empty() {
        let csv_string = "";

        assert!(!is_package_in_csv(csv_string, "os-haproxy"));
        assert!(!is_package_in_csv(csv_string, ""));
    }

    #[test]
    fn test_is_package_in_csv_whitespace() {
        let csv_string = " os-haproxy , os-iperf , os-cpu-microcode-intel ";

        assert!(is_package_in_csv(csv_string, "os-haproxy"));
        assert!(is_package_in_csv(csv_string, "os-iperf"));
        assert!(is_package_in_csv(csv_string, "os-cpu-microcode-intel"));

        assert!(!is_package_in_csv(csv_string, " os-haproxy "));
    }
}
