//! Struct for the CLI application config.
use crate::ingest::DEFAULT_MAX_ELEMENTS_TO_PARSE;
use crate::system::try_get_project_dirs;
use anyhow::{Context, Result};
use config::Config;
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use tracing::instrument;

/// Our config file name.
const CONFIG_FILENAME: &str = "config.toml";

/// The config key for the Omry web address.
pub(crate) const WEB_ADDR_KEY: &str = "omry_web_addr";

/// Config key for the authentication token.
pub(crate) const AUTH_TOKEN_KEY: &str = "omry_auth_token";

/// Returns [`DEFAULT_MAX_ELEMENTS_TO_PARSE`].
fn default_max_elements_to_parse() -> usize {
    DEFAULT_MAX_ELEMENTS_TO_PARSE
}

/// App config.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    /// Omry web server address.
    #[serde(rename = "omry_web_addr")]
    pub web_addr: String,

    /// Maximum number of HTML elements to parse.
    #[serde(
        rename = "omry_max_elements_to_parse",
        default = "default_max_elements_to_parse"
    )]
    pub max_elements_to_parse: usize,

    /// Authentication token for the omry RPC server.
    #[serde(rename = "omry_auth_token")]
    pub auth_token: String,
}

impl TryFrom<Config> for AppConfig {
    type Error = anyhow::Error;

    fn try_from(value: Config) -> Result<Self> {
        Ok(value.try_deserialize::<Self>()?)
    }
}

/// A struct that manages reading and writing the [`AppConfig`] on the file system.
#[derive(Debug)]
pub struct StoredConfig {
    /// The underlying flora config.
    config: AppConfig,

    /// The OS-standard location where the config is expected to be stored.
    project_dirs: ProjectDirs,
}

impl StoredConfig {
    /// Creates an instance with the given parameters.
    fn new(config: AppConfig, project_dirs: ProjectDirs) -> Self {
        Self {
            config,
            project_dirs,
        }
    }

    /// Returns a builder for the config.
    #[must_use]
    pub fn builder() -> StoredConfigBuilder {
        StoredConfigBuilder::default()
    }

    /// Returns a reference to the underlying config.
    #[must_use]
    pub fn config(&self) -> &AppConfig {
        &self.config
    }

    /// Reads the flora-cli config from the given [`ProjectDirs`].
    #[instrument(skip(project_dirs))]
    fn from_project_dirs(project_dirs: ProjectDirs) -> Result<Self> {
        let config_path = get_config_path(&project_dirs);
        tracing::info!("trying to read the config at {}", config_path.display());

        let config_file: config::File<_, _> = config_path.into();
        let config = Config::builder()
            .add_source(config_file)
            .add_source(config::Environment::with_prefix("OMRY").keep_prefix(true))
            .build()?
            .try_into()?;

        Ok(Self::new(config, project_dirs))
    }

    /// Tries to read the config from the OS-default location, if one exists.
    ///
    /// Internally uses [`ProjectDirs`] to find that location.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't find or use the OS-default project location.
    pub fn read_existing() -> Result<Self> {
        let project_dirs = try_get_project_dirs()?;
        Self::from_project_dirs(project_dirs)
    }

    /// Creates an instance of [`StoredConfig`] from the given config values.
    /// Internally sets the expected location in the file system, which may not yet exist.
    ///
    /// This is useful when we have config values (e.g. supplied by the user), but don't
    /// yet have a serialized config, and want to serialize it to the standard location.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't find or use the OS-default project location.
    pub fn from_config(config: AppConfig) -> Result<Self> {
        let project_dirs = try_get_project_dirs()?;
        Ok(Self::new(config, project_dirs))
    }

    /// Writes the given config to the default location, internally returned by [`ProjectDirs`].
    ///
    /// If the config file exists, we will overwrite it.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't:
    ///
    /// - Create the parent directory for the config
    /// - Create a new config file in that directory
    /// - Write the config data to that file
    #[instrument]
    pub fn try_write(&self) -> Result<()> {
        let config_path = get_config_path(&self.project_dirs);
        if config_path.exists() {
            tracing::info!(
                "Will overwrite the existing config at {}",
                config_path.display()
            );
        }

        if let Some(parent) = config_path.parent() {
            fs::create_dir_all(parent)?;
        }

        let config_toml = toml::to_string(&self.config)?;
        let mut config_file = OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(&config_path)?;

        config_file
            .write_all(config_toml.as_bytes())
            .context("Cannot write the config to file")?;

        tracing::info!("Wrote the config to {config_path:#?}");

        Ok(())
    }
}

/// A builder for [`StoredConfig`].
#[derive(Debug, Default)]
pub struct StoredConfigBuilder {
    /// The underlying flora config.
    config: Option<AppConfig>,

    /// The OS-standard location where the config is expected to be stored.
    project_dirs: Option<ProjectDirs>,
}

impl StoredConfigBuilder {
    /// Sets config to the given value.
    #[must_use]
    pub fn with_config(mut self, config: AppConfig) -> Self {
        self.config = Some(config);
        self
    }

    /// Sets the expected location of the config to the given project dirs.
    #[allow(dead_code)]
    fn with_project_dirs(mut self, project_dirs: ProjectDirs) -> Self {
        self.project_dirs = Some(project_dirs);
        self
    }

    /// Attempts to build a [`StoredConfig`] with the given values.
    ///
    /// ## Errors
    /// Returns [`anyhow::Error`] if we can't create the config.
    /// Depending on what fields were set on this struct, the errors correspond to the following functions:
    ///
    /// - [`StoredConfig::from_config`]
    /// - [`StoredConfig::read_existing`]
    pub fn build(self) -> Result<StoredConfig> {
        match (self.config, self.project_dirs) {
            (Some(config), Some(project_dirs)) => Ok(StoredConfig::new(config, project_dirs)),
            (Some(config), None) => StoredConfig::from_config(config),
            (None, Some(project_dirs)) => StoredConfig::from_project_dirs(project_dirs),
            (None, None) => StoredConfig::read_existing(),
        }
    }
}

/// Gets the path to the config from the given [`ProjectDirs`].
fn get_config_path(project_dirs: &ProjectDirs) -> PathBuf {
    let config_dir = project_dirs.config_dir();
    config_dir.join(CONFIG_FILENAME)
}

#[cfg(test)]
pub(crate) mod test_helpers {
    use std::net::{IpAddr, Ipv4Addr, SocketAddr};

    use super::*;

    pub(crate) fn test_config() -> AppConfig {
        let web_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 12346);
        let web_addr = format!("http://{web_addr}");
        AppConfig {
            auth_token: "test".to_string(),
            web_addr,
            max_elements_to_parse: 42,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::Result;
    use tempfile::{TempDir, tempdir};

    fn get_tmp_project_dirs() -> Result<(TempDir, ProjectDirs)> {
        let tmp_dir = tempdir()?;
        let tmp_dir_path = tmp_dir.path().canonicalize()?;
        let tmp_project_dirs = ProjectDirs::from_path(tmp_dir_path)
            .ok_or_else(|| anyhow::anyhow!("Can't get temporary project dirs."))?;

        Ok((tmp_dir, tmp_project_dirs))
    }

    #[test]
    fn can_get_config_path() -> Result<()> {
        let (_, tmp_project_dirs) = get_tmp_project_dirs()?;
        let config_path = get_config_path(&tmp_project_dirs);

        insta::assert_debug_snapshot!(config_path.file_name());
        Ok(())
    }

    #[test]
    fn can_write_config() -> Result<()> {
        let test_config = test_helpers::test_config();
        let (_, tmp_project_dirs) = get_tmp_project_dirs()?;
        let stored_config = StoredConfig::new(test_config, tmp_project_dirs);

        assert!(stored_config.try_write().is_ok());
        Ok(())
    }

    #[test]
    fn reading_missing_config_returns_error() -> Result<()> {
        let (_, tmp_project_dirs) = get_tmp_project_dirs()?;
        assert!(StoredConfig::from_project_dirs(tmp_project_dirs).is_err());
        Ok(())
    }

    #[test]
    fn can_read_existing_config() -> Result<()> {
        let test_config = test_helpers::test_config();
        let (_, tmp_project_dirs) = get_tmp_project_dirs()?;
        let stored_config = StoredConfig::new(test_config, tmp_project_dirs.clone());

        stored_config.try_write()?;

        let config = StoredConfig::from_project_dirs(tmp_project_dirs)?;
        insta::assert_ron_snapshot!(config.config(), {
            ".omry_web_addr" => "[addr]",
        });

        Ok(())
    }

    #[test]
    fn can_write_existing_config() -> Result<()> {
        let test_config = test_helpers::test_config();
        let (_, tmp_project_dirs) = get_tmp_project_dirs()?;
        let stored_config = StoredConfig::builder()
            .with_config(test_config)
            .with_project_dirs(tmp_project_dirs.clone())
            .build()?;

        assert!(stored_config.try_write().is_ok());

        let stored_config = StoredConfig::builder()
            .with_project_dirs(tmp_project_dirs)
            .build()?;

        insta::assert_ron_snapshot!(stored_config.config());

        Ok(())
    }

    #[test]
    fn can_read_config_with_human_host() -> Result<()> {
        let config = Config::builder()
            .set_default(WEB_ADDR_KEY, "http://localhost:12346")?
            .set_default(AUTH_TOKEN_KEY, "test")?
            .build()?;

        let config: AppConfig = config.try_into()?;
        insta::assert_ron_snapshot!(config, {
            ".omry_rpc_addr" => "[addr]",
        });

        Ok(())
    }
}
