use crate::chained_reader::{ChainedReader, ReaderType};
use crate::error::Error::RequestError;
use crate::error::Result;
use crate::path_placeholders::apply_path_placeholders;
use crate::proto::ensure_proto;
use bytes::Bytes;
use log::warn;
use std::collections::BTreeMap;
use std::pin::Pin;
use std::time::Duration;
use tokio::io::AsyncRead;
use yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map};
use yaak_models::models::HttpRequest;
use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};

pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";

pub enum SendableBody {
    Bytes(Bytes),
    Stream {
        data: Pin<Box<dyn AsyncRead + Send + 'static>>,
        /// Known content length for the stream, if available. This is used by
        /// the sender to set the body size hint so that hyper can set
        /// Content-Length automatically for both HTTP/1.1 and HTTP/2.
        content_length: Option<u64>,
    },
}

enum SendableBodyWithMeta {
    Bytes(Bytes),
    Stream {
        data: Pin<Box<dyn AsyncRead + Send + 'static>>,
        content_length: Option<usize>,
    },
}

impl From<SendableBodyWithMeta> for SendableBody {
    fn from(value: SendableBodyWithMeta) -> Self {
        match value {
            SendableBodyWithMeta::Bytes(b) => SendableBody::Bytes(b),
            SendableBodyWithMeta::Stream { data, content_length } => {
                SendableBody::Stream { data, content_length: content_length.map(|l| l as u64) }
            }
        }
    }
}

#[derive(Default)]
pub struct SendableHttpRequest {
    pub url: String,
    pub method: String,
    pub headers: Vec<(String, String)>,
    pub body: Option<SendableBody>,
    pub options: SendableHttpRequestOptions,
}

#[derive(Default, Clone)]
pub struct SendableHttpRequestOptions {
    pub timeout: Option<Duration>,
    pub follow_redirects: bool,
}

impl SendableHttpRequest {
    pub async fn from_http_request(
        r: &HttpRequest,
        options: SendableHttpRequestOptions,
    ) -> Result<Self> {
        let initial_headers = build_headers(r);
        let (body, headers) = build_body(&r.method, &r.body_type, &r.body, initial_headers).await?;

        Ok(Self {
            url: build_url(r),
            method: r.method.to_uppercase(),
            headers,
            body: body.into(),
            options,
        })
    }

    pub fn insert_header(&mut self, header: (String, String)) {
        if header.0.eq_ignore_ascii_case("cookie") {
            if let Some(existing) =
                self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
            {
                existing.1 = format!("{}; {}", existing.1, header.1);
            } else {
                self.headers.push(header);
            }
            return;
        }

        if let Some(existing) =
            self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0))
        {
            existing.1 = header.1;
        } else {
            self.headers.push(header);
        }
    }
}

pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String {
    let url_string = url.to_string();
    if params.is_empty() {
        return url.to_string();
    }

    // Build query string
    let query_string = params
        .iter()
        .map(|(name, value)| {
            format!("{}={}", urlencoding::encode(name), urlencoding::encode(value))
        })
        .collect::<Vec<_>>()
        .join("&");

    // Split URL into parts: base URL, query, and fragment
    let (base_and_query, fragment) = if let Some(hash_pos) = url_string.find('#') {
        let (before_hash, after_hash) = url_string.split_at(hash_pos);
        (before_hash.to_string(), Some(after_hash.to_string()))
    } else {
        (url_string, None)
    };

    // Now handle query parameters on the base URL (without fragment)
    let mut result = if base_and_query.contains('?') {
        // Check if there's already a query string after the '?'
        let parts: Vec<&str> = base_and_query.splitn(2, '?').collect();
        if parts.len() == 2 && !parts[1].trim().is_empty() {
            // Append with & if there are existing parameters
            format!("{}&{}", base_and_query, query_string)
        } else {
            // Just append the new parameters directly (URL ends with '?')
            format!("{}{}", base_and_query, query_string)
        }
    } else {
        // No existing query parameters, add with '?'
        format!("{}?{}", base_and_query, query_string)
    };

    // Re-append the fragment if it exists
    if let Some(fragment) = fragment {
        result.push_str(&fragment);
    }

    result
}

fn strip_query_params(url: &str, names: &[&str]) -> String {
    // Split off fragment
    let (base_and_query, fragment) = if let Some(hash_pos) = url.find('#') {
        (&url[..hash_pos], Some(&url[hash_pos..]))
    } else {
        (url, None)
    };

    let result = if let Some(q_pos) = base_and_query.find('?') {
        let base = &base_and_query[..q_pos];
        let query = &base_and_query[q_pos + 1..];
        let filtered: Vec<&str> = query
            .split('&')
            .filter(|pair| {
                let key = pair.split('=').next().unwrap_or("");
                let decoded = urlencoding::decode(key).unwrap_or_default();
                !names.contains(&decoded.as_ref())
            })
            .collect();
        if filtered.is_empty() {
            base.to_string()
        } else {
            format!("{}?{}", base, filtered.join("&"))
        }
    } else {
        base_and_query.to_string()
    };

    match fragment {
        Some(f) => format!("{}{}", result, f),
        None => result,
    }
}

fn build_url(r: &HttpRequest) -> String {
    let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);
    let mut url = append_query_params(
        &url_string,
        params
            .iter()
            .filter(|p| p.enabled && !p.name.is_empty())
            .map(|p| (p.name.clone(), p.value.clone()))
            .collect(),
    );

    // GraphQL GET requests encode query/variables as URL query parameters
    if r.method.to_lowercase() == "get" && r.body_type.as_deref() == Some("graphql") {
        url = append_graphql_query_params(&url, &r.body);
    }

    url
}

fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
    let query = get_str_map(body, "query").to_string();
    let variables = strip_json_comments(&get_str_map(body, "variables"));
    let operation_name = get_str_map(body, "operationName").to_string();
    let mut params = vec![("query".to_string(), query)];
    if !variables.trim().is_empty() {
        params.push(("variables".to_string(), variables));
    }
    if !operation_name.trim().is_empty() {
        params.push(("operationName".to_string(), operation_name));
    }
    // Strip existing query/variables params to avoid duplicates
    let url = strip_query_params(url, &["query", "variables", "operationName"]);
    append_query_params(&url, params)
}

fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
    // RFC 6265 allows only one Cookie field, so enabled Cookie rows fold into
    // the first one
    let mut headers: Vec<(String, String)> = Vec::new();
    for h in &r.headers {
        if !h.enabled || h.name.is_empty() {
            continue;
        }
        if h.name.eq_ignore_ascii_case("cookie") {
            if let Some(existing) = headers.iter_mut().find(|e| e.0.eq_ignore_ascii_case("cookie"))
            {
                existing.1 = format!("{}; {}", existing.1, h.value);
                continue;
            }
        }
        headers.push((h.name.clone(), h.value.clone()));
    }
    headers
}

async fn build_body(
    method: &str,
    body_type: &Option<String>,
    body: &BTreeMap<String, serde_json::Value>,
    headers: Vec<(String, String)>,
) -> Result<(Option<SendableBody>, Vec<(String, String)>)> {
    let body_type = match &body_type {
        None => return Ok((None, headers)),
        Some(t) => t,
    };

    let (body, content_type) = match body_type.as_str() {
        "binary" => (build_binary_body(&body).await?, None),
        "graphql" => (build_graphql_body(&method, &body), None),
        "application/x-www-form-urlencoded" => (build_form_body(&body), None),
        "multipart/form-data" => build_multipart_body(&body, &headers).await?,
        _ if body.contains_key("text") => (build_text_body(&body, body_type), None),
        t => {
            warn!("Unsupported body type: {}", t);
            (None, None)
        }
    };

    // Add or update the Content-Type header
    let mut headers = headers;
    if let Some(ct) = content_type {
        if let Some(existing) = headers.iter_mut().find(|h| h.0.to_lowercase() == "content-type") {
            existing.1 = ct;
        } else {
            headers.push(("Content-Type".to_string(), ct));
        }
    }

    // NOTE: Content-Length is NOT set as an explicit header here. Instead, the
    // body's content length is carried via SendableBody::Stream { content_length }
    // and used by the sender to set the body size hint. This lets hyper handle
    // Content-Length automatically for both HTTP/1.1 and HTTP/2, avoiding the
    // duplicate Content-Length that breaks HTTP/2 servers.

    Ok((body.map(|b| b.into()), headers))
}

fn build_form_body(body: &BTreeMap<String, serde_json::Value>) -> Option<SendableBodyWithMeta> {
    let form_params = match body.get("form").map(|f| f.as_array()) {
        Some(Some(f)) => f,
        _ => return None,
    };

    let mut body = String::new();
    for p in form_params {
        let enabled = get_bool(p, "enabled", true);
        let name = get_str(p, "name");
        if !enabled || name.is_empty() {
            continue;
        }
        let value = get_str(p, "value");
        if !body.is_empty() {
            body.push('&');
        }
        body.push_str(&urlencoding::encode(&name));
        body.push('=');
        body.push_str(&urlencoding::encode(&value));
    }

    if body.is_empty() { None } else { Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) }
}

async fn build_binary_body(
    body: &BTreeMap<String, serde_json::Value>,
) -> Result<Option<SendableBodyWithMeta>> {
    let file_path = match body.get("filePath").map(|f| f.as_str()) {
        Some(Some(f)) => f,
        _ => return Ok(None),
    };

    // Open a file for streaming
    let content_length = tokio::fs::metadata(file_path)
        .await
        .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))?
        .len();

    let file = tokio::fs::File::open(file_path)
        .await
        .map_err(|e| RequestError(format!("Failed to open file: {}", e)))?;

    Ok(Some(SendableBodyWithMeta::Stream {
        data: Box::pin(file),
        content_length: Some(content_length as usize),
    }))
}

fn build_text_body(
    body: &BTreeMap<String, serde_json::Value>,
    body_type: &str,
) -> Option<SendableBodyWithMeta> {
    let text = get_str_map(body, "text");
    if text.is_empty() {
        return None;
    }

    let send_comments = get_bool_map(body, "sendJsonComments", false);
    let text = if !send_comments && body_type == "application/json" {
        maybe_strip_json_comments(text)
    } else {
        text.to_string()
    };

    Some(SendableBodyWithMeta::Bytes(Bytes::from(text)))
}

fn build_graphql_body(
    method: &str,
    body: &BTreeMap<String, serde_json::Value>,
) -> Option<SendableBodyWithMeta> {
    let query = get_str_map(body, "query");
    let variables = strip_json_comments(&get_str_map(body, "variables"));
    let operation_name = get_str_map(body, "operationName");

    if method.to_lowercase() == "get" {
        // GraphQL GET requests use query parameters, not a body
        return None;
    }

    let mut body = serde_json::Map::new();
    body.insert("query".to_string(), serde_json::Value::String(query.to_string()));
    if !variables.trim().is_empty() {
        body.insert(
            "variables".to_string(),
            serde_json::from_str(&variables)
                .unwrap_or_else(|_| serde_json::Value::String(variables)),
        );
    }
    if !operation_name.trim().is_empty() {
        body.insert(
            "operationName".to_string(),
            serde_json::Value::String(operation_name.to_string()),
        );
    }

    Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default())))
}

async fn build_multipart_body(
    body: &BTreeMap<String, serde_json::Value>,
    headers: &Vec<(String, String)>,
) -> Result<(Option<SendableBodyWithMeta>, Option<String>)> {
    let boundary = extract_boundary_from_headers(headers);

    let form_params = match body.get("form").map(|f| f.as_array()) {
        Some(Some(f)) => f,
        _ => return Ok((None, None)),
    };

    // Build a list of readers for streaming and calculate total content length
    let mut readers: Vec<ReaderType> = Vec::new();
    let mut has_content = false;
    let mut total_size: usize = 0;

    for p in form_params {
        let enabled = get_bool(p, "enabled", true);
        let name = get_str(p, "name");
        if !enabled || name.is_empty() {
            continue;
        }

        has_content = true;

        // Add boundary delimiter
        let boundary_bytes = format!("--{}\r\n", boundary).into_bytes();
        total_size += boundary_bytes.len();
        readers.push(ReaderType::Bytes(boundary_bytes));

        let file_path = get_str(p, "file");
        let value = get_str(p, "value");
        let content_type = get_str(p, "contentType");

        if file_path.is_empty() {
            // Text field
            let header = if !content_type.is_empty() {
                format!(
                    "Content-Disposition: form-data; name=\"{}\"\r\nContent-Type: {}\r\n\r\n{}",
                    name, content_type, value
                )
            } else {
                format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}", name, value)
            };
            let header_bytes = header.into_bytes();
            total_size += header_bytes.len();
            readers.push(ReaderType::Bytes(header_bytes));
        } else {
            // File field - validate that file exists first
            if !tokio::fs::try_exists(file_path).await.unwrap_or(false) {
                return Err(RequestError(format!("File not found: {}", file_path)));
            }

            // Get file size for content length calculation
            let file_metadata = tokio::fs::metadata(file_path)
                .await
                .map_err(|e| RequestError(format!("Failed to get file metadata: {}", e)))?;
            let file_size = file_metadata.len() as usize;

            let filename = get_str(p, "filename");
            let filename = if filename.is_empty() {
                std::path::Path::new(file_path)
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or("file")
            } else {
                filename
            };

            // Add content type
            let mime_type = if !content_type.is_empty() {
                content_type.to_string()
            } else {
                // Guess mime type from file extension
                mime_guess::from_path(file_path).first_or_octet_stream().to_string()
            };

            let header = format!(
                "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\nContent-Type: {}\r\n\r\n",
                name, filename, mime_type
            );
            let header_bytes = header.into_bytes();
            total_size += header_bytes.len();
            total_size += file_size;
            readers.push(ReaderType::Bytes(header_bytes));

            // Add a file path for streaming
            readers.push(ReaderType::FilePath(file_path.to_string()));
        }

        let line_ending = b"\r\n".to_vec();
        total_size += line_ending.len();
        readers.push(ReaderType::Bytes(line_ending));
    }

    if has_content {
        // Add the final boundary
        let final_boundary = format!("--{}--\r\n", boundary).into_bytes();
        total_size += final_boundary.len();
        readers.push(ReaderType::Bytes(final_boundary));

        let content_type = format!("multipart/form-data; boundary={}", boundary);
        let stream = ChainedReader::new(readers);
        Ok((
            Some(SendableBodyWithMeta::Stream {
                data: Box::pin(stream),
                content_length: Some(total_size),
            }),
            Some(content_type),
        ))
    } else {
        Ok((None, None))
    }
}

fn extract_boundary_from_headers(headers: &Vec<(String, String)>) -> String {
    headers
        .iter()
        .find(|h| h.0.to_lowercase() == "content-type")
        .and_then(|h| {
            // Extract boundary from the Content-Type header (e.g., "multipart/form-data; boundary=xyz")
            h.1.split(';')
                .find(|part| part.trim().starts_with("boundary="))
                .and_then(|boundary_part| boundary_part.split('=').nth(1))
                .map(|b| b.trim().to_string())
        })
        .unwrap_or_else(|| MULTIPART_BOUNDARY.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use bytes::Bytes;
    use serde_json::json;
    use std::collections::BTreeMap;
    use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};

    #[tokio::test]
    async fn test_sendable_request_preserves_independent_cookie_enabled_states() {
        let request = HttpRequest {
            url: "https://example.com/api".to_string(),
            headers: vec![
                HttpRequestHeader {
                    enabled: true,
                    name: "Cookie".to_string(),
                    value: "session=abc".to_string(),
                    id: None,
                },
                HttpRequestHeader {
                    enabled: false,
                    name: "Cookie".to_string(),
                    value: "debug=verbose".to_string(),
                    id: None,
                },
            ],
            ..Default::default()
        };

        let sendable =
            SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
                .await
                .unwrap();

        assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]);
    }

    #[tokio::test]
    async fn test_sendable_request_merges_enabled_cookie_rows_into_one_field() {
        let request = HttpRequest {
            url: "https://example.com/api".to_string(),
            headers: vec![
                HttpRequestHeader {
                    enabled: true,
                    name: "Cookie".to_string(),
                    value: "session=abc".to_string(),
                    id: None,
                },
                HttpRequestHeader {
                    enabled: false,
                    name: "Cookie".to_string(),
                    value: "debug=verbose".to_string(),
                    id: None,
                },
                HttpRequestHeader {
                    enabled: true,
                    name: "cookie".to_string(),
                    value: "theme=dark".to_string(),
                    id: None,
                },
            ],
            ..Default::default()
        };

        let sendable =
            SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
                .await
                .unwrap();

        assert_eq!(
            sendable.headers,
            vec![("Cookie".to_string(), "session=abc; theme=dark".to_string())],
        );
    }

    #[test]
    fn test_insert_header_appends_authentication_cookie() {
        let mut request = SendableHttpRequest {
            headers: vec![
                ("Cookie".to_string(), "session=abc".to_string()),
                ("Cookie".to_string(), "theme=dark".to_string()),
            ],
            ..Default::default()
        };

        request.insert_header(("cookie".to_string(), "api_key=secret".to_string()));

        assert_eq!(
            request.headers,
            vec![
                ("Cookie".to_string(), "session=abc; api_key=secret".to_string()),
                ("Cookie".to_string(), "theme=dark".to_string()),
            ],
        );
    }

    #[tokio::test]
    async fn test_sendable_request_preserves_serialized_path_delimiters() {
        let request = HttpRequest {
            url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2"
                .to_string(),
            ..Default::default()
        };

        let sendable =
            SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
                .await
                .unwrap();

        assert_eq!(
            sendable.url,
            "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2",
        );
    }

    #[test]
    fn test_build_url_no_params() {
        let r = HttpRequest {
            url: "https://example.com/api".to_string(),
            url_parameters: vec![],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api");
    }

    #[test]
    fn test_build_url_with_params() {
        let r = HttpRequest {
            url: "https://example.com/api".to_string(),
            url_parameters: vec![
                HttpUrlParameter {
                    enabled: true,
                    name: "foo".to_string(),
                    value: "bar".to_string(),
                    id: None,
                },
                HttpUrlParameter {
                    enabled: true,
                    name: "baz".to_string(),
                    value: "qux".to_string(),
                    id: None,
                },
            ],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?foo=bar&baz=qux");
    }

    #[test]
    fn test_build_url_replaces_graphql_operation_name_from_body() {
        let mut body = BTreeMap::new();
        body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }"));
        body.insert("operationName".to_string(), json!("Bar"));

        let r = HttpRequest {
            method: "GET".to_string(),
            body_type: Some("graphql".to_string()),
            body,
            url: "https://example.com/graphql".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "operationName".to_string(),
                value: "Foo".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(
            result,
            "https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar",
        );
    }

    #[test]
    fn test_build_url_with_disabled_params() {
        let r = HttpRequest {
            url: "https://example.com/api".to_string(),
            url_parameters: vec![
                HttpUrlParameter {
                    enabled: false,
                    name: "disabled".to_string(),
                    value: "value".to_string(),
                    id: None,
                },
                HttpUrlParameter {
                    enabled: true,
                    name: "enabled".to_string(),
                    value: "value".to_string(),
                    id: None,
                },
            ],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?enabled=value");
    }

    #[test]
    fn test_build_url_with_existing_query() {
        let r = HttpRequest {
            url: "https://example.com/api?existing=param".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "new".to_string(),
                value: "value".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?existing=param&new=value");
    }

    #[test]
    fn test_build_url_with_empty_existing_query() {
        let r = HttpRequest {
            url: "https://example.com/api?".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "new".to_string(),
                value: "value".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?new=value");
    }

    #[test]
    fn test_build_url_with_special_chars() {
        let r = HttpRequest {
            url: "https://example.com/api".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "special chars!@#".to_string(),
                value: "value with spaces & symbols".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(
            result,
            "https://example.com/api?special%20chars%21%40%23=value%20with%20spaces%20%26%20symbols"
        );
    }

    #[test]
    fn test_build_url_adds_protocol() {
        let r = HttpRequest {
            url: "example.com/api".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        // ensure_proto defaults to http:// for regular domains
        assert_eq!(result, "http://example.com/api?foo=bar");
    }

    #[test]
    fn test_build_url_adds_https_for_dev_domain() {
        let r = HttpRequest {
            url: "example.dev/api".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        // .dev domains force https
        assert_eq!(result, "https://example.dev/api?foo=bar");
    }

    #[test]
    fn test_build_url_with_fragment() {
        let r = HttpRequest {
            url: "https://example.com/api#section".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?foo=bar#section");
    }

    #[test]
    fn test_build_url_with_existing_query_and_fragment() {
        let r = HttpRequest {
            url: "https://yaak.app?foo=bar#some-hash".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "baz".to_string(),
                value: "qux".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://yaak.app?foo=bar&baz=qux#some-hash");
    }

    #[test]
    fn test_build_url_with_empty_query_and_fragment() {
        let r = HttpRequest {
            url: "https://example.com/api?#section".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?foo=bar#section");
    }

    #[test]
    fn test_build_url_with_fragment_containing_special_chars() {
        let r = HttpRequest {
            url: "https://example.com#section/with/slashes?and=fake&query".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "real".to_string(),
                value: "param".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com?real=param#section/with/slashes?and=fake&query");
    }

    #[test]
    fn test_build_url_preserves_empty_fragment() {
        let r = HttpRequest {
            url: "https://example.com/api#".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        assert_eq!(result, "https://example.com/api?foo=bar#");
    }

    #[test]
    fn test_build_url_with_multiple_fragments() {
        // Testing edge case where the URL has multiple # characters (though technically invalid)
        let r = HttpRequest {
            url: "https://example.com#section#subsection".to_string(),
            url_parameters: vec![HttpUrlParameter {
                enabled: true,
                name: "foo".to_string(),
                value: "bar".to_string(),
                id: None,
            }],
            ..Default::default()
        };

        let result = build_url(&r);
        // Should treat everything after first # as fragment
        assert_eq!(result, "https://example.com?foo=bar#section#subsection");
    }

    #[tokio::test]
    async fn test_text_body() {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("Hello, World!"));

        let result = build_text_body(&body, "application/json");
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                assert_eq!(bytes, Bytes::from("Hello, World!"))
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_text_body_empty() {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!(""));

        let result = build_text_body(&body, "application/json");
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_text_body_missing() {
        let body = BTreeMap::new();

        let result = build_text_body(&body, "application/json");
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_text_body_strips_json_comments_by_default() {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("{\n  // comment\n  \"foo\": \"bar\"\n}"));

        let result = build_text_body(&body, "application/json");
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                let text = String::from_utf8_lossy(&bytes);
                assert!(!text.contains("// comment"));
                assert!(text.contains("\"foo\": \"bar\""));
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_text_body_send_json_comments_when_opted_in() {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("{\n  // comment\n  \"foo\": \"bar\"\n}"));
        body.insert("sendJsonComments".to_string(), json!(true));

        let result = build_text_body(&body, "application/json");
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                let text = String::from_utf8_lossy(&bytes);
                assert!(text.contains("// comment"));
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_text_body_no_strip_for_non_json() {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("// not json\nsome text"));

        let result = build_text_body(&body, "text/plain");
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                let text = String::from_utf8_lossy(&bytes);
                assert!(text.contains("// not json"));
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_form_urlencoded_body() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert(
            "form".to_string(),
            json!([
                { "enabled": true, "name": "basic", "value": "aaa"},
                { "enabled": true, "name": "fUnkey Stuff!$*#(", "value": "*)%&#$)@ *$#)@&"},
                { "enabled": false, "name": "disabled", "value": "won't show"},
            ]),
        );

        let result = build_form_body(&body);
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                let expected = "basic=aaa&fUnkey%20Stuff%21%24%2A%23%28=%2A%29%25%26%23%24%29%40%20%2A%24%23%29%40%26";
                assert_eq!(bytes, Bytes::from(expected));
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
        Ok(())
    }

    #[tokio::test]
    async fn test_form_urlencoded_body_missing_form() {
        let body = BTreeMap::new();
        let result = build_form_body(&body);
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_binary_body() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert("filePath".to_string(), json!("./tests/test.txt"));

        let result = build_binary_body(&body).await?;
        assert!(matches!(result, Some(SendableBodyWithMeta::Stream { .. })));
        Ok(())
    }

    #[tokio::test]
    async fn test_binary_body_file_not_found() {
        let mut body = BTreeMap::new();
        body.insert("filePath".to_string(), json!("./nonexistent/file.txt"));

        let result = build_binary_body(&body).await;
        assert!(result.is_err());
        if let Err(e) = result {
            assert!(matches!(e, RequestError(_)));
        }
    }

    #[tokio::test]
    async fn test_graphql_body_with_variables() {
        let mut body = BTreeMap::new();
        body.insert("query".to_string(), json!("{ user(id: $id) { name } }"));
        body.insert("variables".to_string(), json!(r#"{"id": "123"}"#));

        let result = build_graphql_body("POST", &body);
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                assert_eq!(
                    serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
                    json!({
                        "query": "{ user(id: $id) { name } }",
                        "variables": { "id": "123" },
                    }),
                );
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_graphql_body_with_operation_name() {
        let mut body = BTreeMap::new();
        body.insert("query".to_string(), json!("query Search { viewer { id } }"));
        body.insert("operationName".to_string(), json!("Search"));

        let result = build_graphql_body("POST", &body);
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                assert_eq!(
                    serde_json::from_slice::<serde_json::Value>(&bytes).unwrap(),
                    json!({
                        "query": "query Search { viewer { id } }",
                        "operationName": "Search",
                    }),
                );
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_graphql_body_without_variables() {
        let mut body = BTreeMap::new();
        body.insert("query".to_string(), json!("{ users { name } }"));
        body.insert("variables".to_string(), json!(""));

        let result = build_graphql_body("POST", &body);
        match result {
            Some(SendableBodyWithMeta::Bytes(bytes)) => {
                let expected = r#"{"query":"{ users { name } }"}"#;
                assert_eq!(bytes, Bytes::from(expected));
            }
            _ => panic!("Expected Some(SendableBody::Bytes)"),
        }
    }

    #[tokio::test]
    async fn test_graphql_body_get_method() {
        let mut body = BTreeMap::new();
        body.insert("query".to_string(), json!("{ users { name } }"));

        let result = build_graphql_body("GET", &body);
        assert!(result.is_none());
    }

    #[tokio::test]
    async fn test_multipart_body_text_fields() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert(
            "form".to_string(),
            json!([
                { "enabled": true, "name": "field1", "value": "value1", "file": "" },
                { "enabled": true, "name": "field2", "value": "value2", "file": "" },
                { "enabled": false, "name": "disabled", "value": "won't show", "file": "" },
            ]),
        );

        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;
        assert!(content_type.is_some());

        match result {
            Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => {
                // Read the entire stream to verify content
                let mut buf = Vec::new();
                use tokio::io::AsyncReadExt;
                stream.read_to_end(&mut buf).await.expect("Failed to read stream");
                let body_str = String::from_utf8_lossy(&buf);
                assert_eq!(
                    body_str,
                    "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field1\"\r\n\r\nvalue1\r\n--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"field2\"\r\n\r\nvalue2\r\n--------YaakFormBoundary--\r\n",
                );
                assert_eq!(content_length, Some(body_str.len()));
            }
            _ => panic!("Expected Some(SendableBody::Stream)"),
        }

        assert_eq!(
            content_type.unwrap(),
            format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY)
        );

        Ok(())
    }

    #[tokio::test]
    async fn test_multipart_body_with_file() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert(
            "form".to_string(),
            json!([
                { "enabled": true, "name": "file_field", "file": "./tests/test.txt", "filename": "custom.txt", "contentType": "text/plain" },
            ]),
        );

        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;
        assert!(content_type.is_some());

        match result {
            Some(SendableBodyWithMeta::Stream { data: mut stream, content_length }) => {
                // Read the entire stream to verify content
                let mut buf = Vec::new();
                use tokio::io::AsyncReadExt;
                stream.read_to_end(&mut buf).await.expect("Failed to read stream");
                let body_str = String::from_utf8_lossy(&buf);
                assert_eq!(
                    body_str,
                    "--------YaakFormBoundary\r\nContent-Disposition: form-data; name=\"file_field\"; filename=\"custom.txt\"\r\nContent-Type: text/plain\r\n\r\nThis is a test file!\n\r\n--------YaakFormBoundary--\r\n"
                );
                assert_eq!(content_length, Some(body_str.len()));
            }
            _ => panic!("Expected Some(SendableBody::Stream)"),
        }

        assert_eq!(
            content_type.unwrap(),
            format!("multipart/form-data; boundary={}", MULTIPART_BOUNDARY)
        );

        Ok(())
    }

    #[tokio::test]
    async fn test_multipart_body_empty() -> Result<()> {
        let body = BTreeMap::new();
        let (result, content_type) = build_multipart_body(&body, &vec![]).await?;
        assert!(result.is_none());
        assert_eq!(content_type, None);
        Ok(())
    }

    #[test]
    fn test_extract_boundary_from_headers_with_custom_boundary() {
        let headers = vec![(
            "Content-Type".to_string(),
            "multipart/form-data; boundary=customBoundary123".to_string(),
        )];
        let boundary = extract_boundary_from_headers(&headers);
        assert_eq!(boundary, "customBoundary123");
    }

    #[test]
    fn test_extract_boundary_from_headers_default() {
        let headers = vec![("Accept".to_string(), "*/*".to_string())];
        let boundary = extract_boundary_from_headers(&headers);
        assert_eq!(boundary, MULTIPART_BOUNDARY);
    }

    #[test]
    fn test_extract_boundary_from_headers_no_boundary_in_content_type() {
        let headers = vec![("Content-Type".to_string(), "multipart/form-data".to_string())];
        let boundary = extract_boundary_from_headers(&headers);
        assert_eq!(boundary, MULTIPART_BOUNDARY);
    }

    #[test]
    fn test_extract_boundary_case_insensitive() {
        let headers = vec![(
            "Content-Type".to_string(),
            "multipart/form-data; boundary=myBoundary".to_string(),
        )];
        let boundary = extract_boundary_from_headers(&headers);
        assert_eq!(boundary, "myBoundary");
    }

    #[tokio::test]
    async fn test_no_content_length_header_added_by_build_body() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("Hello, World!"));

        let headers = vec![];

        let (_, result_headers) =
            build_body("POST", &Some("text/plain".to_string()), &body, headers).await?;

        // Content-Length should NOT be set as an explicit header. Instead, the
        // sender uses the body's size_hint to let hyper set it automatically,
        // which works correctly for both HTTP/1.1 and HTTP/2.
        let has_content_length =
            result_headers.iter().any(|h| h.0.to_lowercase() == "content-length");
        assert!(!has_content_length, "Content-Length should not be set as an explicit header");

        Ok(())
    }

    #[tokio::test]
    async fn test_chunked_encoding_header_preserved() -> Result<()> {
        let mut body = BTreeMap::new();
        body.insert("text".to_string(), json!("Hello, World!"));

        // Headers with Transfer-Encoding: chunked
        let headers = vec![("Transfer-Encoding".to_string(), "chunked".to_string())];

        let (_, result_headers) =
            build_body("POST", &Some("text/plain".to_string()), &body, headers).await?;

        // Verify that the Transfer-Encoding header is still present
        let has_chunked = result_headers.iter().any(|h| {
            h.0.to_lowercase() == "transfer-encoding" && h.1.to_lowercase().contains("chunked")
        });
        assert!(has_chunked, "Transfer-Encoding: chunked should be preserved");

        Ok(())
    }
}
