#![forbid(unsafe_code)]

pub mod errors;

use crate::errors::LeptosConfigError;
use config::{Case, Config, File, FileFormat};
use std::{
    env::VarError,
    fs,
    net::SocketAddr,
    path::{Path, PathBuf},
    str::FromStr,
    sync::Arc,
};
use typed_builder::TypedBuilder;

/// A Struct to allow us to parse [`LeptosOptions`] from the file.
/// Not really needed, most interactions should occur with `LeptosOptions`.
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct ConfFile {
    pub leptos_options: LeptosOptions,
}

/// This struct serves as a convenient place to store details used for configuring Leptos.
/// It's used in our actix and axum integrations to generate the
/// correct path for WASM, JS, and Websockets, as well as other configuration tasks.
/// It shares keys with cargo-leptos, to allow for easy interoperability
#[derive(TypedBuilder, Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub struct LeptosOptions {
    /// The name of the WASM and JS files generated by wasm-bindgen.
    ///
    /// This should match the name that will be output when building your application.
    ///
    /// You can easily set this using `env!("CARGO_CRATE_NAME")`.
    #[builder(setter(into))]
    pub output_name: Arc<str>,
    /// The path of the all the files generated by cargo-leptos.
    ///
    /// This defaults to '.' for convenience when integrating with other tools.
    #[builder(setter(into), default=default_site_root())]
    #[serde(default = "default_site_root")]
    pub site_root: Arc<str>,
    /// The path of the WASM and JS files generated by wasm-bindgen from the root of your app.
    ///
    /// By default, wasm-bindgen puts them in `pkg`.
    #[builder(setter(into), default=default_site_pkg_dir())]
    #[serde(default = "default_site_pkg_dir")]
    pub site_pkg_dir: Arc<str>,
    /// Used to configure the running environment of Leptos.
    /// Can be used to load dev constants and keys v prod,
    /// or change things based on the deployment environment.
    ///
    /// I recommend passing in the result of `env::var("LEPTOS_ENV")`.
    #[builder(setter(into), default=default_env())]
    #[serde(default = "default_env")]
    pub env: Env,
    /// Provides a way to control the address leptos is served from.
    /// Using an env variable here would allow you to run the same code in dev and prod.
    ///
    /// Defaults to `127.0.0.1:3000`.
    #[builder(setter(into), default=default_site_addr())]
    #[serde(default = "default_site_addr")]
    pub site_addr: SocketAddr,
    /// The port the Websocket watcher listens on.
    /// Should match the `reload_port` in cargo-leptos (if using).
    ///
    /// Defaults to `3001`.
    #[builder(default = default_reload_port())]
    #[serde(default = "default_reload_port")]
    pub reload_port: u32,
    /// The port the Websocket watcher listens on when on the client, e.g.,
    /// when behind a reverse proxy.
    ///
    /// Defaults to match `reload_port`.
    #[builder(default)]
    #[serde(default)]
    pub reload_external_port: Option<u32>,
    /// The protocol the Websocket watcher uses on the client: `ws` in most cases, `wss` when behind
    /// a reverse https proxy.
    ///
    /// Defaults to `ws`
    #[builder(default)]
    #[serde(default)]
    pub reload_ws_protocol: ReloadWSProtocol,
    /// The path of a custom 404 Not Found page to display when statically serving content.
    ///
    /// Defaults to `site_root/404.html`.
    #[builder(default = default_not_found_path())]
    #[serde(default = "default_not_found_path")]
    pub not_found_path: Arc<str>,
    /// The file name of the hash text file generated by cargo-leptos.
    ///
    /// Defaults to `hash.txt`.
    #[builder(default = default_hash_file_name())]
    #[serde(default = "default_hash_file_name")]
    pub hash_file: Arc<str>,
    /// If `true`, hashes will be generated for all files
    /// in the `site_root` and added to their file names.
    ///
    /// Defaults to `false`.
    #[builder(default = default_hash_files())]
    #[serde(default = "default_hash_files")]
    pub hash_files: bool,
    /// The default prefix to use for server functions when generating API routes. Can be
    /// overridden for individual functions using `#[server(prefix = "...")]` as usual.
    ///
    /// This is useful to override the default prefix (`/api`) for all server functions without
    /// needing to manually specify via `#[server(prefix = "...")]` on every server function.
    #[builder(default, setter(strip_option))]
    #[serde(default)]
    pub server_fn_prefix: Option<String>,
    /// Whether to disable appending the server functions' hashes to the end of their API names.
    ///
    /// This is useful when an app's client side needs a stable server API. For example, shipping
    /// the CSR WASM binary in a Tauri app. Tauri app releases are dependent on each platform's
    /// distribution method (e.g., the Apple App Store or the Google Play Store), which typically
    /// are much slower than the frequency at which a website can be updated. In addition, it's
    /// common for users to not have the latest app version installed. In these cases, the CSR WASM
    /// app would need to be able to continue calling the backend server function API, so the API
    /// path needs to be consistent and not have a hash appended.
    ///
    /// Note that the hash suffixes is intended as a way to ensure duplicate API routes are created.
    /// Without the hash, server functions will need to have unique names to avoid creating
    /// duplicate routes. Axum will throw an error if a duplicate route is added to the router, but
    /// Actix will not.
    #[builder(default)]
    #[serde(default)]
    pub disable_server_fn_hash: bool,
    /// Include the module path of the server function in the API route. This is an alternative
    /// strategy to prevent duplicate server function API routes (the default strategy is to add
    /// a hash to the end of the route). Each element of the module path will be separated by a `/`.
    /// For example, a server function with a fully qualified name of `parent::child::server_fn`
    /// would have an API route of `/api/parent/child/server_fn` (possibly with a
    /// different prefix and a hash suffix depending on the values of the other server fn configs).
    #[builder(default)]
    #[serde(default)]
    pub server_fn_mod_path: bool,
}

impl LeptosOptions {
    /// Returns the path on disk to the generated CSS file.  This is done by joining `site_root`,
    /// `site_pkg_dir`, and the `output_name` with `.css` appended to it.
    ///
    /// # Example
    ///
    /// ```
    /// use leptos_config::LeptosOptions;
    ///
    /// let options = LeptosOptions::builder().output_name("test").build();
    /// let path = options.css_file_path();
    /// let mut ancestors = path.ancestors();
    ///
    /// assert_eq!(path.file_name().unwrap(), "test.css");
    /// ```
    pub fn css_file_path(&self) -> PathBuf {
        Path::new(&*self.site_root)
            .join(&*self.site_pkg_dir)
            .join(format!("{}.css", self.output_name))
    }

    /// Returns the path portion of the uri to the generated CSS file.  This is done by combining
    /// `site_pkg_dir` and the `output_name` with `.css` appended to it.
    ///
    /// # Example
    ///
    /// ```
    /// use leptos_config::LeptosOptions;
    ///
    /// let options = LeptosOptions::builder().output_name("test").build();
    /// let path = options.css_path();
    /// assert_eq!(path, "/pkg/test.css");
    /// ```
    pub fn css_path(&self) -> String {
        let mut path = self.site_pkg_dir_route_base();
        path.push_str(&self.output_name);
        path.push_str(".css");
        path
    }

    /// Returns the base route to the `site_pkg_dir` with a leading and trailing slash added as necessary.
    pub fn site_pkg_dir_route_base(&self) -> String {
        let mut path = String::new();
        // While it shouldn't start with a '/', but check anyway.
        if !self.site_pkg_dir.starts_with('/') {
            path.push('/');
        }
        path.push_str(&self.site_pkg_dir);
        if !path.ends_with('/') {
            path.push('/');
        }
        path
    }

    fn try_from_env() -> Result<Self, LeptosConfigError> {
        let output_name = env_w_default(
            "LEPTOS_OUTPUT_NAME",
            std::option_env!("LEPTOS_OUTPUT_NAME",).unwrap_or_default(),
        )?;
        if output_name.is_empty() {
            eprintln!(
                "It looks like you're trying to compile Leptos without the \
                 LEPTOS_OUTPUT_NAME environment variable being set. There are \
                 two options\n 1. cargo-leptos is not being used, but \
                 get_configuration() is being passed None. This needs to be \
                 changed to Some(\"Cargo.toml\")\n 2. You are compiling \
                 Leptos without LEPTOS_OUTPUT_NAME being set with \
                 cargo-leptos. This shouldn't be possible!"
            );
        }
        Ok(LeptosOptions {
            output_name: output_name.into(),
            site_root: env_w_default("LEPTOS_SITE_ROOT", "target/site")?.into(),
            site_pkg_dir: env_w_default("LEPTOS_SITE_PKG_DIR", "pkg")?.into(),
            env: env_from_str(env_w_default("LEPTOS_ENV", "DEV")?.as_str())?,
            site_addr: env_w_default("LEPTOS_SITE_ADDR", "127.0.0.1:3000")?
                .parse()?,
            reload_port: env_w_default("LEPTOS_RELOAD_PORT", "3001")?
                .parse()?,
            reload_external_port: match env_wo_default(
                "LEPTOS_RELOAD_EXTERNAL_PORT",
            )? {
                Some(val) => Some(val.parse()?),
                None => None,
            },
            reload_ws_protocol: ws_from_str(
                env_w_default("LEPTOS_RELOAD_WS_PROTOCOL", "ws")?.as_str(),
            )?,
            not_found_path: env_w_default("LEPTOS_NOT_FOUND_PATH", "/404")?
                .into(),
            hash_file: env_w_default("LEPTOS_HASH_FILE_NAME", "hash.txt")?
                .into(),
            hash_files: env_w_default("LEPTOS_HASH_FILES", "false")?.parse()?,
            server_fn_prefix: env_wo_default("SERVER_FN_PREFIX")?,
            disable_server_fn_hash: env_wo_default("DISABLE_SERVER_FN_HASH")?
                .is_some(),
            server_fn_mod_path: env_wo_default("SERVER_FN_MOD_PATH")?.is_some(),
        })
    }
}

fn default_site_root() -> Arc<str> {
    ".".into()
}

fn default_site_pkg_dir() -> Arc<str> {
    "pkg".into()
}

fn default_env() -> Env {
    Env::DEV
}

fn default_site_addr() -> SocketAddr {
    SocketAddr::from(([127, 0, 0, 1], 3000))
}

fn default_reload_port() -> u32 {
    3001
}

fn default_not_found_path() -> Arc<str> {
    "/404".into()
}

fn default_hash_file_name() -> Arc<str> {
    "hash.txt".into()
}

fn default_hash_files() -> bool {
    false
}

fn env_wo_default(key: &str) -> Result<Option<String>, LeptosConfigError> {
    match std::env::var(key) {
        Ok(val) => Ok(Some(val)),
        Err(VarError::NotPresent) => Ok(None),
        Err(e) => Err(LeptosConfigError::EnvVarError(format!("{key}: {e}"))),
    }
}
fn env_w_default(
    key: &str,
    default: &str,
) -> Result<String, LeptosConfigError> {
    match std::env::var(key) {
        Ok(val) => Ok(val),
        Err(VarError::NotPresent) => Ok(default.to_string()),
        Err(e) => Err(LeptosConfigError::EnvVarError(format!("{key}: {e}"))),
    }
}

pub const ENV_DEV_KEY_SHORT: &str = "dev";
pub const ENV_DEV_KEY_LONG: &str = "development";
pub const ENV_PROD_KEY_SHORT: &str = "prod";
pub const ENV_PROD_KEY_LONG: &str = "production";

/// An enum that can be used to define the environment Leptos is running in.
/// Setting this to the `PROD` variant will not include the WebSocket code for `cargo-leptos` watch mode.
/// Defaults to `DEV`.
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq, Default)]
pub enum Env {
    PROD,
    #[default]
    DEV,
}

fn env_from_str(input: &str) -> Result<Env, LeptosConfigError> {
    let sanitized = input.to_lowercase();
    match sanitized.as_ref() {
        ENV_DEV_KEY_SHORT | ENV_DEV_KEY_LONG => Ok(Env::DEV),
        ENV_PROD_KEY_SHORT | ENV_PROD_KEY_LONG => Ok(Env::PROD),
        _ => Err(LeptosConfigError::EnvVarError(format!(
            "{input} is not a supported environment. Use either \
             `{ENV_DEV_KEY_SHORT}`, `{ENV_DEV_KEY_LONG}`, \
             `{ENV_PROD_KEY_SHORT}`, or `{ENV_PROD_KEY_LONG}`.",
        ))),
    }
}

impl FromStr for Env {
    type Err = ();
    fn from_str(input: &str) -> Result<Self, Self::Err> {
        env_from_str(input).or_else(|_| Ok(Self::default()))
    }
}

impl From<&str> for Env {
    fn from(str: &str) -> Self {
        env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
    }
}

impl From<&Result<String, VarError>> for Env {
    fn from(input: &Result<String, VarError>) -> Self {
        match input {
            Ok(str) => {
                env_from_str(str).unwrap_or_else(|err| panic!("{}", err))
            }
            Err(_) => Self::default(),
        }
    }
}

impl TryFrom<String> for Env {
    type Error = LeptosConfigError;

    fn try_from(s: String) -> Result<Self, Self::Error> {
        env_from_str(s.as_str())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
struct EnvVisitor;

impl<'de> serde::de::Visitor<'de> for EnvVisitor {
    type Value = Env;

    fn expecting(
        &self,
        formatter: &mut std::fmt::Formatter,
    ) -> std::fmt::Result {
        write!(
            formatter,
            "a case-insensitive string of either `{ENV_DEV_KEY_SHORT}`, \
             `{ENV_DEV_KEY_LONG}`, `{ENV_PROD_KEY_SHORT}`, or \
             `{ENV_PROD_KEY_LONG}`"
        )
    }

    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
    where
        E: serde::de::Error,
    {
        env_from_str(v).map_err(|err| E::custom(err.to_string()))
    }
}

impl<'de> serde::Deserialize<'de> for Env {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        deserializer.deserialize_any(EnvVisitor)
    }
}

/// An enum that can be used to define the websocket protocol Leptos uses for hotreloading
/// Defaults to `ws`.
#[derive(
    Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default,
)]
pub enum ReloadWSProtocol {
    #[default]
    WS,
    WSS,
}

fn ws_from_str(input: &str) -> Result<ReloadWSProtocol, LeptosConfigError> {
    let sanitized = input.to_lowercase();
    match sanitized.as_ref() {
        "ws" | "WS" => Ok(ReloadWSProtocol::WS),
        "wss" | "WSS" => Ok(ReloadWSProtocol::WSS),
        _ => Err(LeptosConfigError::EnvVarError(format!(
            "{input} is not a supported websocket protocol. Use only `ws` or \
             `wss`.",
        ))),
    }
}

impl FromStr for ReloadWSProtocol {
    type Err = ();
    fn from_str(input: &str) -> Result<Self, Self::Err> {
        ws_from_str(input).or_else(|_| Ok(Self::default()))
    }
}

impl From<&str> for ReloadWSProtocol {
    fn from(str: &str) -> Self {
        ws_from_str(str).unwrap_or_else(|err| panic!("{}", err))
    }
}

impl From<&Result<String, VarError>> for ReloadWSProtocol {
    fn from(input: &Result<String, VarError>) -> Self {
        match input {
            Ok(str) => ws_from_str(str).unwrap_or_else(|err| panic!("{}", err)),
            Err(_) => Self::default(),
        }
    }
}

impl TryFrom<String> for ReloadWSProtocol {
    type Error = LeptosConfigError;

    fn try_from(s: String) -> Result<Self, Self::Error> {
        ws_from_str(s.as_str())
    }
}

/// Returns the byte index of the line starting with `prefix`.
fn find_line_starting_with(text: &str, prefix: &str) -> Option<usize> {
    text.split_inclusive('\n')
        .find(|line| line.starts_with(prefix))
        .map(|line| line.as_ptr() as usize - text.as_ptr() as usize)
}

/// Loads [`LeptosOptions`] from a Cargo.toml text content with layered overrides.
/// If an env var is specified, like `LEPTOS_ENV`, it will override a setting in the file.
///
/// # Errors
///
/// This function will return an error if either:
/// - config section was not found;
/// - failed to deserialize the config section.
pub fn get_config_from_str(
    text: &str,
) -> Result<LeptosOptions, LeptosConfigError> {
    let (metadata_name, start) =
        ["[package.metadata.leptos]", "[[workspace.metadata.leptos]]"]
            .iter()
            .find_map(|section| {
                find_line_starting_with(text, section)
                    .map(|start| (section, start))
            })
            .ok_or(LeptosConfigError::ConfigSectionNotFound)?;

    // so that serde error messages have right line number
    let newlines = text[..start].matches('\n').count();
    let input = "\n".repeat(newlines) + &text[start..];
    // so the settings will be interpreted as root level settings
    let toml = input.replace(metadata_name, "");
    let settings = Config::builder()
        // Read the "default" configuration file
        .add_source(File::from_str(&toml, FileFormat::Toml))
        // Layer on the environment-specific values.
        // Add in settings from environment variables (with a prefix of LEPTOS)
        // E.g. `LEPTOS_RELOAD_PORT=5001 would set `LeptosOptions.reload_port`
        .add_source(
            config::Environment::with_prefix("LEPTOS")
                .convert_case(Case::Kebab),
        )
        .build()?;

    settings
        .try_deserialize()
        .map_err(|e| LeptosConfigError::ConfigError(e.to_string()))
}

/// Loads [`LeptosOptions`] from a Cargo.toml with layered overrides. If an env var is specified, like `LEPTOS_ENV`,
/// it will override a setting in the file. It takes in an optional path to a Cargo.toml file. If None is provided,
/// you'll need to set the options as environment variables or rely on the defaults. This is the preferred
/// approach for cargo-leptos. If Some("./Cargo.toml") is provided, Leptos will read in the settings itself. This
/// option currently does not allow dashes in file or folder names, as all dashes become underscores
///
/// # Errors
///
/// See [`get_config_from_file`] & [`get_config_from_env`]
pub fn get_configuration(
    path: Option<&str>,
) -> Result<ConfFile, LeptosConfigError> {
    if let Some(path) = path {
        get_config_from_file(path)
    } else {
        get_config_from_env()
    }
}

/// Loads [`LeptosOptions`] from a `Cargo.toml` with layered overrides.
/// Leptos will read in the settings itself.
///
/// This option currently does not allow dashes in file or folder names, as all dashes become underscores.
///
/// # Errors
///
/// This function will return an error if either:
/// - path was not found, or other I/O error has occurred;
/// - [`get_config_from_str`] returned an error.
pub fn get_config_from_file<P: AsRef<Path>>(
    path: P,
) -> Result<ConfFile, LeptosConfigError> {
    let text = fs::read_to_string(path)
        .map_err(|_| LeptosConfigError::ConfigNotFound)?;
    let leptos_options = get_config_from_str(&text)?;
    Ok(ConfFile { leptos_options })
}

/// Loads [`LeptosOptions`] from environment variables or rely on the defaults.
///
/// # Errors
///
/// This function will return an error if it failed to load or parse a relevant environment variable.
pub fn get_config_from_env() -> Result<ConfFile, LeptosConfigError> {
    Ok(ConfFile {
        leptos_options: LeptosOptions::try_from_env()?,
    })
}

#[path = "tests.rs"]
#[cfg(test)]
mod tests;
