//! HTTP Configuration loaders.
//!
//! Utilities for loading and serializing `scanclient` configuration from TOML.

use std::{collections::HashMap, fs, io, path::Path, path::PathBuf};

use serde::{Deserialize, Serialize};
use guise_profiles::default_profile_user_agent;
use thiserror::Error;

pub(crate) use guise_profiles::DEFAULT_STEALTH_PROFILE as DEFAULT_HTTP_PROFILE;

/// Errors that can occur when loading, parsing, or serializing HTTP configuration.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HttpConfigError {
    /// I/O failure while reading or writing config files.
    #[error(
        "could not read config `{path}`: {source}. Fix: verify the file exists, is readable, and points to a TOML file with top-level `timeout_secs`, `proxy`, or `custom_headers` settings."
    )]
    Io {
        /// Path of the file that could not be read.
        path: PathBuf,
        /// Underlying OS error.
        #[source]
        source: io::Error,
    },
    /// TOML parsing failed.
    #[error(
        "invalid HTTP config TOML: {0}. Fix: keep settings at the top level, for example `timeout_secs = 10` and `[custom_headers] X-Test = \"1\"`."
    )]
    Parse(#[from] toml::de::Error),
    /// TOML serialization failed.
    #[error(
        "could not serialize HTTP config as TOML: {0}. Fix: remove unsupported values from `custom_headers` and retry serialization."
    )]
    Serialize(#[from] toml::ser::Error),
}

/// Configuration result type for crate-level config helpers.
pub type Result<T> = std::result::Result<T, HttpConfigError>;

/// Configuration blueprint for `ScanClient` instances.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
#[serde(deny_unknown_fields)]
#[allow(clippy::struct_excessive_bools)]
pub struct HttpConfig {
    /// Request timeout in seconds.
    pub timeout_secs: u64,
    /// Maximum number of response body bytes to buffer in memory.
    pub max_body_size: usize,
    /// Maximum number of automatic retries for transient faults.
    pub max_retries: u32,
    /// Base delay in milliseconds before exponential backoff retries.
    pub retry_delay_ms: u64,
    /// Maximum number of HTTP redirections to follow.
    pub max_redirects: usize,
    /// Upstream proxy URL override.
    pub proxy: Option<String>,
    /// Optional proxy username used for `Proxy-Authorization`.
    pub proxy_user: Option<String>,
    /// Optional proxy password used for `Proxy-Authorization`.
    pub proxy_password: Option<String>,
    /// Global User-Agent string to broadcast.
    pub user_agent: String,
    /// Global headers to attach to every outgoing request.
    pub custom_headers: HashMap<String, String>,
    /// Optional absolute hard-limit on requests per second per client instance.
    pub rate_limit_per_sec: Option<u32>,
    /// Whether retries are allowed for non-idempotent methods such as POST/PUT/PATCH/DELETE.
    pub retry_non_idempotent_methods: bool,
    /// Whether or not to perform strict TLS certificate validation.
    pub tls_verify: bool,
    /// Explicitly allow invalid TLS certificates.
    pub tls_accept_invalid_certs: bool,
    /// Explicitly allow invalid TLS hostnames.
    pub tls_accept_invalid_hostnames: bool,
    /// Maximum connection handshake timeout in seconds.
    pub connect_timeout_secs: u64,
}

impl Default for HttpConfig {
    fn default() -> Self {
        Self {
            timeout_secs: 10,
            max_body_size: 10 * 1024 * 1024,
            max_retries: 3,
            retry_delay_ms: 1_000,
            max_redirects: 5,
            proxy: None,
            proxy_user: None,
            proxy_password: None,
            user_agent: default_user_agent(),
            custom_headers: HashMap::new(),
            rate_limit_per_sec: None,
            retry_non_idempotent_methods: false,
            tls_verify: true,
            tls_accept_invalid_certs: false,
            tls_accept_invalid_hostnames: false,
            connect_timeout_secs: 5,
        }
    }
}

pub(crate) fn default_user_agent() -> String {
    default_profile_user_agent().to_string()
}

impl HttpConfig {
    /// Load config from a TOML file path.
    ///
    /// Example:
    /// ```rust
    /// use scanclient::HttpConfig;
    ///
    /// let dir = tempfile::tempdir().unwrap();
    /// let path = dir.path().join("http.toml");
    /// std::fs::write(&path, "timeout_secs = 20\n").unwrap();
    /// let config = HttpConfig::load(&path).unwrap();
    /// assert_eq!(config.timeout_secs, 20);
    /// ```
    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let content = fs::read_to_string(path).map_err(|source| HttpConfigError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        Self::from_toml(&content)
    }

    /// Parse config from a TOML string.
    ///
    /// Example:
    /// ```rust
    /// use scanclient::HttpConfig;
    ///
    /// let config = HttpConfig::from_toml("max_retries = 5").unwrap();
    /// assert_eq!(config.max_retries, 5);
    /// ```
    pub fn from_toml(toml_str: &str) -> Result<Self> {
        Ok(toml::from_str(toml_str)?)
    }

    /// Serialize config to a TOML string.
    ///
    /// Example:
    /// ```rust
    /// use scanclient::HttpConfig;
    ///
    /// let toml = HttpConfig::default().to_toml().unwrap();
    /// assert!(toml.contains("timeout_secs"));
    /// ```
    pub fn to_toml(&self) -> Result<String> {
        Ok(toml::to_string_pretty(self)?)
    }

    /// Create a builder for [`HttpConfig`].
    ///
    /// Example:
    /// ```rust
    /// use scanclient::HttpConfig;
    ///
    /// let config = HttpConfig::builder().timeout_secs(30).build();
    /// assert_eq!(config.timeout_secs, 30);
    /// ```
    pub fn builder() -> HttpConfigBuilder {
        HttpConfigBuilder::default()
    }
}

/// Builder module for HttpConfig.
pub mod builder;
pub use builder::HttpConfigBuilder;

#[cfg(test)]
mod tests {
    #![allow(clippy::uninlined_format_args)]

    use super::{HttpConfig, HttpConfigError, default_user_agent};
    use guise_profiles::default_profile_user_agent;

    #[test]
    fn defaults_match_contract() {
        let config = HttpConfig::default();

        assert_eq!(config.timeout_secs, 10);
        assert_eq!(config.max_body_size, 10 * 1024 * 1024);
        assert_eq!(config.max_retries, 3);
        assert_eq!(config.retry_delay_ms, 1_000);
        assert_eq!(config.max_redirects, 5);
        assert_eq!(config.proxy, None);
        assert_eq!(config.proxy_user, None);
        assert_eq!(config.proxy_password, None);
        assert_eq!(config.user_agent, default_user_agent());
        assert_eq!(
            config.user_agent,
            default_profile_user_agent(),
            "scanclient's default User-Agent must stay anchored to stealth-profiles"
        );
        assert!(config.custom_headers.is_empty());
        assert_eq!(config.rate_limit_per_sec, None);
        assert!(config.tls_verify);
        assert_eq!(config.connect_timeout_secs, 5);
    }

    #[test]
    fn parses_partial_toml_with_defaults() {
        let raw = r#"
timeout_secs = 30
max_body_size = 4096
max_retries = 5
proxy = "http://127.0.0.1:8080"
proxy_user = "alice"
proxy_password = "secret"
rate_limit_per_sec = 8
tls_verify = false

[custom_headers]
X-Scanner = "karyx"
"#;

        let config: HttpConfig = toml::from_str(raw).expect("config should parse");

        assert_eq!(config.timeout_secs, 30);
        assert_eq!(config.max_body_size, 4096);
        assert_eq!(config.max_retries, 5);
        assert_eq!(config.retry_delay_ms, 1_000);
        assert_eq!(config.max_redirects, 5);
        assert_eq!(config.proxy.as_deref(), Some("http://127.0.0.1:8080"));
        assert_eq!(config.proxy_user.as_deref(), Some("alice"));
        assert_eq!(config.proxy_password.as_deref(), Some("secret"));
        assert_eq!(config.rate_limit_per_sec, Some(8));
        assert!(!config.tls_verify);
        assert_eq!(
            config.custom_headers.get("X-Scanner").map(String::as_str),
            Some("karyx")
        );
    }

    #[test]
    fn from_toml_convenience() {
        let config = HttpConfig::from_toml("timeout_secs = 42").unwrap();
        assert_eq!(config.timeout_secs, 42);
        assert_eq!(config.max_retries, 3); // default preserved
    }

    #[test]
    fn to_toml_round_trip() {
        let original = HttpConfig::default();
        let toml_str = original.to_toml().unwrap();
        let parsed = HttpConfig::from_toml(&toml_str).unwrap();
        assert_eq!(original, parsed);
    }

    #[test]
    fn empty_toml_uses_all_defaults() {
        let config = HttpConfig::from_toml("").unwrap();
        assert_eq!(config, HttpConfig::default());
    }

    #[test]
    fn invalid_toml_returns_error() {
        assert!(HttpConfig::from_toml("{{invalid").is_err());
    }

    #[test]
    fn load_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("config.toml");
        std::fs::write(&path, "timeout_secs = 99\n").unwrap();
        let config = HttpConfig::load(&path).unwrap();
        assert_eq!(config.timeout_secs, 99);
    }

    #[test]
    fn load_missing_file_errors() {
        let result = HttpConfig::load(std::path::Path::new("/nonexistent/config.toml"));
        assert!(result.is_err());
    }

    #[test]
    fn test_builder_pattern() {
        let mut headers = std::collections::HashMap::new();
        headers.insert("X-Test".to_string(), "Builder".to_string());

        let config = HttpConfig::builder()
            .timeout_secs(45)
            .max_body_size(2048)
            .max_retries(10)
            .retry_delay_ms(500)
            .max_redirects(2)
            .proxy("http://localhost:8888")
            .proxy_user("operator")
            .proxy_password("secret")
            .user_agent("CustomAgent")
            .custom_headers(headers.clone())
            .rate_limit_per_sec(Some(100))
            .tls_verify(false)
            .connect_timeout_secs(15)
            .build();

        assert_eq!(config.timeout_secs, 45);
        assert_eq!(config.max_body_size, 2048);
        assert_eq!(config.max_retries, 10);
        assert_eq!(config.retry_delay_ms, 500);
        assert_eq!(config.max_redirects, 2);
        assert_eq!(config.proxy.as_deref(), Some("http://localhost:8888"));
        assert_eq!(config.proxy_user.as_deref(), Some("operator"));
        assert_eq!(config.proxy_password.as_deref(), Some("secret"));
        assert_eq!(config.user_agent, "CustomAgent");
        assert_eq!(config.custom_headers, headers);
        assert_eq!(config.rate_limit_per_sec, Some(100));
        assert!(!config.tls_verify);
        assert_eq!(config.connect_timeout_secs, 15);
    }

    #[test]
    fn test_error_display() {
        let io_err = HttpConfigError::Io {
            path: std::path::PathBuf::from("/fake/path.toml"),
            source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
        };
        let io_str = format!("{}", io_err);
        assert!(io_str.contains("/fake/path.toml"));
        assert!(io_str.contains("file not found"));
        assert!(io_str.contains("Fix:"));

        let parse_err = HttpConfig::from_toml("invalid = \n").unwrap_err();
        let parse_str = format!("{}", parse_err);
        assert!(parse_str.contains("invalid HTTP config TOML:"));
        assert!(parse_str.contains("Fix: keep settings at the top level"));
    }

    #[test]
    fn load_with_empty_file_uses_defaults() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("empty.toml");
        std::fs::write(&path, "").unwrap();
        let config = HttpConfig::load(&path).unwrap();
        assert_eq!(config, HttpConfig::default());
    }

    #[test]
    fn load_with_unicode_path_and_unicode_values() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("設定.toml");
        let raw = r#"
user_agent = "scanner/日本語"
[custom_headers]
X-World = "Привет世界"
"#;
        std::fs::write(&path, raw).unwrap();
        let config = HttpConfig::load(&path).unwrap();
        assert_eq!(config.user_agent, "scanner/日本語");
        assert_eq!(
            config.custom_headers.get("X-World").map(String::as_str),
            Some("Привет世界")
        );
    }

    #[test]
    fn from_toml_rejects_null_byte() {
        let result = HttpConfig::from_toml("user_agent = \"a\0b\"");
        assert!(result.is_err());
    }

    #[test]
    fn from_toml_with_huge_input_fails_gracefully() {
        use std::fmt::Write as _;

        let mut huge = String::from("timeout_secs = 1\n");
        huge.push_str("[custom_headers]\n");
        for i in 0..5_000 {
            writeln!(&mut huge, "X{i} = \"{}\"", "A".repeat(50)).unwrap();
        }
        let result = HttpConfig::from_toml(&huge);
        assert!(result.is_ok());
        let parsed = result.unwrap();
        assert_eq!(parsed.timeout_secs, 1);
        assert!(parsed.custom_headers.len() >= 5_000);
    }

    #[test]
    fn to_toml_preserves_unicode_and_empty_values() {
        let mut config = HttpConfig {
            user_agent: "agent/🌐".to_string(),
            ..HttpConfig::default()
        };
        config
            .custom_headers
            .insert("X-Empty".to_string(), String::new());
        config
            .custom_headers
            .insert("X-Emoji".to_string(), "🎉".to_string());
        let toml = config.to_toml().unwrap();
        let parsed = HttpConfig::from_toml(&toml).unwrap();
        assert_eq!(parsed.user_agent, "agent/🌐");
        assert_eq!(
            parsed.custom_headers.get("X-Empty").map(String::as_str),
            Some("")
        );
        assert_eq!(
            parsed.custom_headers.get("X-Emoji").map(String::as_str),
            Some("🎉")
        );
    }

    #[test]
    fn builder_supports_empty_proxy_when_set_explicitly() {
        let config = HttpConfig::builder().proxy("").build();
        assert_eq!(config.proxy.as_deref(), Some(""));
    }

    #[test]
    fn builder_sets_retry_non_idempotent_methods_flag() {
        let config = HttpConfig::builder()
            .retry_non_idempotent_methods(true)
            .build();
        assert!(config.retry_non_idempotent_methods);
    }

    #[test]
    fn builder_sets_tls_exception_flags() {
        let config = HttpConfig::builder()
            .tls_verify(false)
            .tls_accept_invalid_certs(true)
            .tls_accept_invalid_hostnames(true)
            .build();
        assert!(!config.tls_verify);
        assert!(config.tls_accept_invalid_certs);
        assert!(config.tls_accept_invalid_hostnames);
    }

    #[test]
    fn builder_sets_rate_limit_zero_value() {
        let config = HttpConfig::builder().rate_limit_per_sec(Some(0)).build();
        assert_eq!(config.rate_limit_per_sec, Some(0));
    }

    #[test]
    fn from_toml_with_empty_header_key_round_trips() {
        let raw = r#"
[custom_headers]
"" = "bad"
"#;
        let result = HttpConfig::from_toml(raw);
        assert!(result.is_ok());
        let parsed = result.unwrap();
        assert_eq!(
            parsed.custom_headers.get("").map(String::as_str),
            Some("bad")
        );
    }
}
