/// HTTP utilities for the Mad Hatter framework.

use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::Engine as _;
use serde::Serialize;
use std::sync::Arc;
use crate::critical::{FatalToken, TxScope, ConceptMapToken};

use crate::sealed::Sealed;

// ── MIME Type Service ──

/// Returns the Content-Type header value for a given file extension.
///
/// Supports common web file types. Returns `application/octet-stream` for unknown extensions.
pub fn content_type_for_extension(ext: &str) -> &'static str {
    match ext {
        "html" | "htm" => "text/html; charset=utf-8",
        "css" => "text/css; charset=utf-8",
        "js" => "application/javascript; charset=utf-8",
        "json" => "application/json; charset=utf-8",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "gif" => "image/gif",
        "svg" => "image/svg+xml",
        "ico" => "image/x-icon",
        "txt" | "md" => "text/plain; charset=utf-8",
        "pdf" => "application/pdf",
        "zip" => "application/zip",
        "wasm" => "application/wasm",
        "mp3" => "audio/mpeg",
        "mp4" => "video/mp4",
        "webp" => "image/webp",
        "webm" => "video/webm",
        _ => "application/octet-stream",
    }
}

// ── Embedded HTML Response ──

/// Build an HTML response with `Content-Type: text/html` and `Cache-Control: no-cache`.
///
/// Useful for serving HTML content embedded in the binary via `include_str!`.
pub fn embedded_html_response(content: &'static str) -> Response {
    use axum::http::header;
    (
        [
            (header::CONTENT_TYPE, "text/html; charset=utf-8"),
            (header::CACHE_CONTROL, "no-cache"),
        ],
        content,
    )
        .into_response()
}

// ── Cookie Management ──

/// Build a Set-Cookie header value.
///
/// - `name`: cookie name
/// - `value`: cookie value
/// - `path`: cookie path (e.g. "/")
/// - `http_only`: whether to set HttpOnly flag
/// - `max_age_secs`: optional Max-Age in seconds (None omits Max-Age)
pub fn build_cookie(
    name: &str,
    value: &str,
    path: &str,
    http_only: bool,
    max_age_secs: Option<i64>,
) -> String {
    let mut cookie = format!("{}={}; Path={}; SameSite=Strict", name, value, path);
    if http_only {
        cookie.push_str("; HttpOnly");
    }
    if let Some(max_age) = max_age_secs {
        cookie.push_str(&format!("; Max-Age={}", max_age));
    }
    cookie
}

/// Build a Set-Cookie header value that clears (expires) a cookie.
pub fn build_clear_cookie(name: &str, path: &str) -> String {
    format!(
        "{}=; Path={}; SameSite=Strict; HttpOnly; Max-Age=0",
        name, path
    )
}

/// Build a `Cookie` request header value for proxy forwarding.
///
/// Returns `"name=value"` format suitable for the `Cookie` header
/// when forwarding requests through a proxy.
pub fn build_cookie_value(name: &str, value: &str) -> String {
    format!("{}={}", name, value)
}

/// Extract a named cookie value from request headers.
///
/// Parses the `Cookie` header and returns the value of the first cookie matching `name`.
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
    let cookie_header = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
    for part in cookie_header.split(';') {
        let trimmed = part.trim();
        if let Some(rest) = trimmed.strip_prefix(name) {
            if let Some(value) = rest.strip_prefix('=') {
                return Some(value.to_string());
            }
        }
    }
    None
}

// ── Reverse Proxy Utilities ──

/// Check if a request header is a hop-by-hop header that should not be forwarded.
pub fn is_hop_by_hop_header(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "host"
            | "connection"
            | "keep-alive"
            | "transfer-encoding"
            | "te"
            | "trailer"
            | "upgrade"
    )
}

/// Check if a response header is a hop-by-hop header that should be stripped.
pub fn is_response_hop_by_hop(name: &str) -> bool {
    matches!(
        name.to_ascii_lowercase().as_str(),
        "connection" | "keep-alive" | "transfer-encoding" | "trailer" | "upgrade"
    )
}

/// Filter hop-by-hop headers from a request, returning only forwardable headers.
pub fn forward_request_headers(original: &HeaderMap) -> HeaderMap {
    let mut forwarded = HeaderMap::new();
    for (name, value) in original.iter() {
        if !is_hop_by_hop_header(name.as_str()) {
            forwarded.insert(name.clone(), value.clone());
        }
    }
    forwarded
}

/// Construct a proxy target URL from base, path, and optional query string.
///
/// Example: `build_proxy_url("http://backend:8080", "api/data", Some("page=1"))` →
/// `"http://backend:8080/api/data?page=1"`
pub fn build_proxy_url(target_base: &str, path: &str, query: Option<&str>) -> String {
    let base = target_base.trim_end_matches('/');
    let path = path.trim_start_matches('/');
    match query {
        Some(q) if !q.is_empty() => format!("{}/{}?{}", base, path, q),
        _ => format!("{}/{}", base, path),
    }
}

// ── JSON Error Responses ──

#[derive(Serialize)]
struct ErrorBody {
    error: String,
}

/// Build a JSON error response with the given status code and message.
///
/// Response body: `{"error": "<message>"}`
pub fn json_error_response(status: StatusCode, message: impl std::fmt::Display) -> Response {
    (status, axum::Json(ErrorBody { error: message.to_string() })).into_response()
}

/// Shorthand for a 404 Not Found JSON error response.
pub fn not_found(message: impl std::fmt::Display) -> Response {
    json_error_response(StatusCode::NOT_FOUND, message)
}

/// Shorthand for a 400 Bad Request JSON error response.
pub fn bad_request(message: impl std::fmt::Display) -> Response {
    json_error_response(StatusCode::BAD_REQUEST, message)
}

/// Shorthand for a 401 Unauthorized JSON error response.
pub fn unauthorized(message: impl std::fmt::Display) -> Response {
    json_error_response(StatusCode::UNAUTHORIZED, message)
}

/// Shorthand for a 500 Internal Server Error JSON error response.
pub fn internal_error(message: impl std::fmt::Display) -> Response {
    json_error_response(StatusCode::INTERNAL_SERVER_ERROR, message)
}

// ── Proxy Response Processing ──

/// Rewrite a Set-Cookie header's path to include the proxy prefix.
///
/// If the cookie has `Path=/something` and it doesn't already start with `proxy_prefix`,
/// the path is rewritten to `Path={proxy_prefix}/something`.
#[allow(clippy::string_slice)]
pub fn rewrite_cookie_path(cookie: &str, proxy_prefix: &str) -> String {
    let lower = cookie.to_lowercase();
    if let Some(idx) = lower.find("path=/") {
        let path_start = idx + 5; // position of '/' in 'path=/'
        let path_end = cookie[path_start..]
            .find(';')
            .map(|i| path_start + i)
            .unwrap_or(cookie.len());
        let original_path = &cookie[path_start..path_end];
        if !original_path.starts_with(proxy_prefix) {
            let new_path = format!("{}{}", proxy_prefix, original_path);
            return format!(
                "{}{}{}",
                &cookie[..path_start],
                new_path,
                &cookie[path_end..]
            );
        }
    }
    cookie.to_string()
}

/// Process a proxy response header for forwarding.
///
/// - `location` headers: rewrite absolute paths to include proxy prefix
/// - `set-cookie` headers: rewrite cookie path to include proxy prefix
/// - hop-by-hop headers: return None (should be stripped)
/// - all other headers: return as-is
pub fn process_proxy_response_header(
    name: &str,
    value: &str,
    proxy_prefix: &str,
) -> Option<String> {
    let name_lower = name.to_lowercase();
    match name_lower.as_str() {
        "location" => {
            let rewritten = if value.starts_with('/') && !value.starts_with(proxy_prefix) {
                format!("{}{}", proxy_prefix, value)
            } else {
                value.to_string()
            };
            Some(rewritten)
        }
        "set-cookie" => Some(rewrite_cookie_path(value, proxy_prefix)),
        n if is_response_hop_by_hop(n) => None,
        _ => Some(value.to_string()),
    }
}

// ── Static File Serving ──

/// Serve a file from a base directory with path traversal protection.
///
/// Resolves `rel_path` relative to `base_dir`, validates the resolved path stays within
/// `base_dir` (preventing path traversal attacks), detects content type from extension,
/// and returns the file contents as an HTTP response.
#[allow(clippy::string_slice)]
pub async fn serve_directory_file(
    base_dir: &std::path::Path,
    rel_path: &str,
) -> Response {
    if !base_dir.exists() {
        return not_found("Directory not found");
    }

    let target = match base_dir.join(rel_path).canonicalize() {
        Ok(p) => p,
        Err(_) => return not_found("File not found"),
    };

    let base_canonical = match base_dir.canonicalize() {
        Ok(p) => p,
        Err(_) => return internal_error("Cannot resolve base directory"),
    };

    // Path traversal protection: resolved path must be within base directory
    if !target.starts_with(&base_canonical) || !target.is_file() {
        return not_found("File not found");
    }

    let ext = target
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_lowercase();
    let ct = content_type_for_extension(&ext);
    match tokio::fs::read(&target).await {
        Ok(data) => {
            let mut headers = HeaderMap::new();
            headers.insert(
                axum::http::header::CONTENT_TYPE,
                ct.parse().unwrap(),
            );
            (headers, data).into_response()
        }
        Err(_) => internal_error("Failed to read file"),
    }
}

// ── Multipart Upload ──

/// Result of a single uploaded file.
pub struct UploadedFile {
    /// Actual filename after deduplication (e.g. `photo_1.jpg` if `photo.jpg` existed).
    pub name: String,
    /// File size in bytes.
    pub size: usize,
}

/// Resolve a unique filename in `dir` by appending `_1`, `_2`, etc. if the name already exists.
#[allow(clippy::string_slice)]
fn dedup_filename(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
    let (stem, ext) = match name.rfind('.') {
        Some(pos) => (&name[..pos], &name[pos..]),
        None => (name, ""),
    };
    let mut target = dir.join(name);
    let mut counter = 1u32;
    while target.exists() {
        target = dir.join(format!("{stem}_{counter}{ext}"));
        counter += 1;
    }
    target
}

/// Process a multipart upload, writing files to `dest_dir` with automatic deduplication.
///
/// Creates `dest_dir` if it doesn't exist. Returns a list of successfully uploaded files.
/// On error, returns an HTTP error response.
pub async fn process_multipart_upload(
    multipart: &mut axum::extract::Multipart,
    dest_dir: &std::path::Path,
) -> Result<Vec<UploadedFile>, Response> {
    std::fs::create_dir_all(dest_dir)
        .map_err(|e| internal_error(&format!("Failed to create upload directory: {}", e)))?;

    let mut uploaded = Vec::new();
    while let Ok(Some(field)) = multipart.next_field().await {
        let file_name = match field.file_name() {
            Some(name) => name.to_string(),
            None => continue,
        };
        let data = match field.bytes().await {
            Ok(bytes) => bytes,
            Err(e) => return Err(bad_request(&format!("Failed to read field: {}", e))),
        };

        let target = dedup_filename(dest_dir, &file_name);
        let actual_name = target.file_name().unwrap().to_string_lossy().to_string();

        std::fs::write(&target, &data)
            .map_err(|e| internal_error(&format!("Failed to write file: {}", e)))?;

        uploaded.push(UploadedFile {
            name: actual_name,
            size: data.len(),
        });
    }
    Ok(uploaded)
}

// ── Image Utilities ──

const BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;

/// Detects the image MIME type from a URL's file extension or an HTTP Content-Type header.
///
/// Priority: URL extension > Content-Type header > default (image/png).
#[allow(clippy::string_slice)]
pub fn image_media_type(url: &str, content_type: Option<&str>) -> &'static str {
    // Extract extension from URL (strip query string first)
    let path = url.split('?').next().unwrap_or(url);
    if let Some(dot_pos) = path.rfind('.') {
        let ext = &path[dot_pos + 1..];
        match ext.to_lowercase().as_str() {
            "jpg" | "jpeg" => return "image/jpeg",
            "png" => return "image/png",
            "gif" => return "image/gif",
            "webp" => return "image/webp",
            _ => {}
        }
    }

    // Fallback to Content-Type header
    if let Some(ct) = content_type {
        let ct_lower = ct.to_lowercase();
        if ct_lower.contains("image/jpeg") {
            return "image/jpeg";
        } else if ct_lower.contains("image/png") {
            return "image/png";
        } else if ct_lower.contains("image/gif") {
            return "image/gif";
        } else if ct_lower.contains("image/webp") {
            return "image/webp";
        }
    }

    "image/png"
}

/// Downloads an image from a URL and returns it as a `data:` URI.
///
/// If the URL is already a `data:` URI, it is returned as-is.
/// Otherwise, the image is downloaded, its MIME type is detected from the URL extension
/// or HTTP Content-Type header, and the bytes are base64-encoded into a data URI.
pub async fn download_as_data_uri(
    client: &reqwest::Client,
    url: &str,
) -> Result<String, String> {
    if url.starts_with("data:") {
        return Ok(url.to_string());
    }

    let response = client
        .get(url)
        .send()
        .await
        .map_err(|e| format!("Failed to download image: {e}"))?;

    if !response.status().is_success() {
        return Err(format!(
            "Image download failed with status {}",
            response.status().as_u16()
        ));
    }

    let header_ct = response
        .headers()
        .get("content-type")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    let mime = image_media_type(url, header_ct.as_deref());

    let bytes = response
        .bytes()
        .await
        .map_err(|e| format!("Failed to read image bytes: {e}"))?;

    let encoded = BASE64_ENGINE.encode(&bytes);
    Ok(format!("data:{mime};base64,{encoded}"))
}

/// Reads a local file and returns it as a `data:` URI.
///
/// Detects the image MIME type from the file extension using [`image_media_type`],
/// then base64-encodes the file contents.
pub fn file_to_data_uri(path: &std::path::Path) -> Result<String, String> {
    let path_str = path.to_string_lossy();
    let bytes = std::fs::read(path)
        .map_err(|e| format!("Failed to read file '{}': {e}", path_str))?;
    let mime = image_media_type(&path_str, None);
    let encoded = BASE64_ENGINE.encode(&bytes);
    Ok(format!("data:{mime};base64,{encoded}"))
}

/// Resolves an image URL or file path into a `data:` URI.
///
/// Three resolution paths:
/// - `data:` URI → returned as-is (passthrough)
/// - `http://` or `https://` URL → downloaded via [`download_as_data_uri`]
/// - Anything else → treated as a file path. Relative paths are joined with `base_dir`
///   if provided; absolute paths are used directly. Then read via [`file_to_data_uri`].
pub async fn resolve_image_to_data_uri(
    client: &reqwest::Client,
    url: &str,
    base_dir: Option<&std::path::Path>,
) -> Result<String, String> {
    if url.starts_with("data:") {
        return Ok(url.to_string());
    }

    if url.starts_with("http://") || url.starts_with("https://") {
        return download_as_data_uri(client, url).await;
    }

    // File path resolution
    let path = std::path::Path::new(url);
    let resolved = if path.is_absolute() {
        path.to_path_buf()
    } else if let Some(base) = base_dir {
        base.join(path)
    } else {
        path.to_path_buf()
    };

    file_to_data_uri(&resolved)
}

/// Resolve a host/address value to a full HTTP URL.
///
/// Handles common patterns in endpoint resolution:
/// - Already has scheme (`http://` or `https://`) → use as-is
/// - No scheme → prepend `http://`
/// - `None` / empty → fall back to `http://localhost:{default_port}`
///
/// # Examples
/// ```ignore
/// resolve_http_endpoint(Some("http://example.com"), 8080) // → "http://example.com"
/// resolve_http_endpoint(Some("192.168.1.1:3000"), 8080)   // → "http://192.168.1.1:3000"
/// resolve_http_endpoint(None, 8080)                         // → "http://localhost:8080"
/// ```
pub fn resolve_http_endpoint(host: Option<&str>, default_port: u16) -> String {
    match host {
        Some(h) if !h.is_empty() => {
            if h.starts_with("http://") || h.starts_with("https://") {
                h.to_owned()
            } else {
                format!("http://{}", h)
            }
        }
        _ => format!("http://localhost:{}", default_port),
    }
}


// ── Transaction Guard ──

/// Trait for converting a fatal abort into an HTTP error response.
///
/// Implement this trait on your Fatal enum to define how fatal errors
/// map to HTTP responses. Called with the token instance that caused
/// the abort, preserving exhaustive match on the Fatal enum.
///
/// # Example
/// ```ignore
/// impl AbortResponse for MyFatal {
///     fn abort_response(&self, message: Sealed<String>) -> Response {
///         (StatusCode::INTERNAL_SERVER_ERROR, message.to_string()).into_response()
///     }
/// }
/// ```
pub trait AbortResponse: FatalToken {
    fn abort_response(&self, message: Sealed<String>) -> Response;
}

/// Transaction guard middleware for HTTP handlers.
///
/// Creates a `TxScope`, injects it into request extensions as `Arc<TxScope<F>>`,
/// and catches panics from downstream handlers. Fatal panics (from `propagate()`)
/// are converted to HTTP responses via `AbortResponse`.
///
/// Non-fatal panics (not from `propagate()`) return 500 Internal Server Error.
///
/// Used by `bind_http!` macro — not intended for direct use.
pub async fn tx_guard<T>(
    mut req: axum::extract::Request,
    next: axum::middleware::Next,
) -> Response
where
    T: crate::critical::Transaction,
    T::Fatal: AbortResponse,
{
    let scope = Arc::new(TxScope::<T>::new());
    req.extensions_mut().insert(scope.clone());

    // Check pre-auth hook (injected by auth_middleware)
    if let Some(hook) = req.extensions_mut().remove::<crate::auth::PreAuthHook>() {
        let auth_fs = crate::auth::AuthScopedFs::new(scope.raw_trace_id(), T::name());
        if let Some(resp) = hook.check(&auth_fs, &req) {
            return resp;
        }
    }

    match crate::critical::run_tx_scope(&scope, next.run(req)).await {
        crate::critical::TxOutcome::Completed(resp) => resp,
        crate::critical::TxOutcome::Aborted(token, msg) => {
            tracing::error!("[TX-GUARD] Fatal abort: {} — {}", token.operation_name(), msg);
            token.abort_response(msg)
        }
        crate::critical::TxOutcome::UnexpectedPanic => {
            tracing::error!("[TX-GUARD] Unexpected panic in handler (not from propagate)");
            (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
        }
    }
}




// ---------------------------------------------------------------------------
// __spawn_http_server — internal HTTP server launcher for bind_server! macro
// ---------------------------------------------------------------------------

/// Spawn an HTTP server as a managed loop task.
///
/// **Do not call directly** — the `bind_server!` macro generates a `.serve()` method
/// that delegates to this function.

/// Transaction guard middleware with database binding.
///
/// Like `tx_guard`, but creates a `TxScope` bound to the given database.
/// The framework calls `auto_commit()` on success; on panic the transaction
/// is rolled back automatically when the `Tx` is dropped.
///
/// Used by `bind_http!` macro when `@ DB` binding is declared.
#[doc(hidden)]
pub async fn tx_guard_with_db<T>(
    mut req: axum::extract::Request,
    next: axum::middleware::Next,
    db: crate::db::Db,
) -> Response
where
    T: crate::critical::Transaction,
    T::Fatal: AbortResponse,
{
    let scope = Arc::new(TxScope::<T>::new_with_db(db));
    req.extensions_mut().insert(scope.clone());

    // Check pre-auth hook (injected by auth_middleware)
    if let Some(hook) = req.extensions_mut().remove::<crate::auth::PreAuthHook>() {
        let auth_fs = crate::auth::AuthScopedFs::new(scope.raw_trace_id(), T::name());
        if let Some(resp) = hook.check(&auth_fs, &req) {
            return resp;
        }
    }

    match crate::critical::run_tx_scope(&scope, next.run(req)).await {
        crate::critical::TxOutcome::Completed(resp) => resp,
        crate::critical::TxOutcome::Aborted(token, msg) => {
            tracing::error!("[TX-GUARD] Fatal abort: {} — {}", token.operation_name(), msg);
            token.abort_response(msg)
        }
        crate::critical::TxOutcome::UnexpectedPanic => {
            tracing::error!("[TX-GUARD] Unexpected panic in handler (not from propagate)");
            (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
        }
    }
}


#[doc(hidden)]
pub fn __spawn_http_server(
    name: impl Into<String>,
    router: axum::Router<()>,
    addr: std::net::SocketAddr,
) -> crate::state::LoopHandle {
    crate::state::spawn_loop(name, move |cancel| async move {
        let listener = match tokio::net::TcpListener::bind(addr).await {
            Ok(l) => l,
            Err(e) => {
                eprintln!("[HTTP] Failed to bind {}: {}", addr, e);
                return;
            }
        };
        let server = axum::serve(listener, router)
            .with_graceful_shutdown(async move {
                cancel.cancelled().await;
            });
        if let Err(e) = server.await {
            eprintln!("[HTTP] Server error: {}", e);
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::http::{header, HeaderValue};

    // ── MIME tests ──

    #[test]
    fn test_html_extensions() {
        assert_eq!(content_type_for_extension("html"), "text/html; charset=utf-8");
        assert_eq!(content_type_for_extension("htm"), "text/html; charset=utf-8");
    }

    #[test]
    fn test_css_js_json() {
        assert_eq!(content_type_for_extension("css"), "text/css; charset=utf-8");
        assert_eq!(content_type_for_extension("js"), "application/javascript; charset=utf-8");
        assert_eq!(content_type_for_extension("json"), "application/json; charset=utf-8");
    }

    #[test]
    fn test_image_types() {
        assert_eq!(content_type_for_extension("png"), "image/png");
        assert_eq!(content_type_for_extension("jpg"), "image/jpeg");
        assert_eq!(content_type_for_extension("jpeg"), "image/jpeg");
        assert_eq!(content_type_for_extension("gif"), "image/gif");
        assert_eq!(content_type_for_extension("svg"), "image/svg+xml");
        assert_eq!(content_type_for_extension("ico"), "image/x-icon");
        assert_eq!(content_type_for_extension("webp"), "image/webp");
    }

    #[test]
    fn test_text_types() {
        assert_eq!(content_type_for_extension("txt"), "text/plain; charset=utf-8");
        assert_eq!(content_type_for_extension("md"), "text/plain; charset=utf-8");
    }

    #[test]
    fn test_binary_types() {
        assert_eq!(content_type_for_extension("pdf"), "application/pdf");
        assert_eq!(content_type_for_extension("zip"), "application/zip");
        assert_eq!(content_type_for_extension("wasm"), "application/wasm");
    }

    #[test]
    fn test_media_types() {
        assert_eq!(content_type_for_extension("mp3"), "audio/mpeg");
        assert_eq!(content_type_for_extension("mp4"), "video/mp4");
        assert_eq!(content_type_for_extension("webm"), "video/webm");
    }

    #[test]
    fn test_unknown_extension() {
        assert_eq!(content_type_for_extension("xyz"), "application/octet-stream");
        assert_eq!(content_type_for_extension(""), "application/octet-stream");
        assert_eq!(content_type_for_extension("exe"), "application/octet-stream");
    }

    // ── Cookie tests ──

    #[test]
    fn test_build_cookie_basic() {
        let cookie = build_cookie("session", "abc123", "/", true, Some(604800));
        assert!(cookie.contains("session=abc123"));
        assert!(cookie.contains("Path=/"));
        assert!(cookie.contains("HttpOnly"));
        assert!(cookie.contains("SameSite=Strict"));
        assert!(cookie.contains("Max-Age=604800"));
    }

    #[test]
    fn test_build_cookie_no_http_only() {
        let cookie = build_cookie("theme", "dark", "/", false, None);
        assert!(cookie.contains("theme=dark"));
        assert!(cookie.contains("Path=/"));
        assert!(!cookie.contains("HttpOnly"));
        assert!(!cookie.contains("Max-Age"));
    }

    #[test]
    fn test_build_clear_cookie() {
        let cookie = build_clear_cookie("session", "/");
        assert!(cookie.contains("session="));
        assert!(cookie.contains("Max-Age=0"));
        assert!(cookie.contains("HttpOnly"));
    }

    #[test]
    fn test_extract_cookie_value_single() {
        let mut headers = HeaderMap::new();
        headers.insert(header::COOKIE, HeaderValue::from_static("session=abc123"));
        assert_eq!(extract_cookie_value(&headers, "session"), Some("abc123".to_string()));
    }

    #[test]
    fn test_extract_cookie_value_multiple() {
        let mut headers = HeaderMap::new();
        headers.insert(
            header::COOKIE,
            HeaderValue::from_static("theme=dark; session=xyz789; lang=en"),
        );
        assert_eq!(extract_cookie_value(&headers, "session"), Some("xyz789".to_string()));
        assert_eq!(extract_cookie_value(&headers, "theme"), Some("dark".to_string()));
        assert_eq!(extract_cookie_value(&headers, "lang"), Some("en".to_string()));
    }

    #[test]
    fn test_extract_cookie_value_missing() {
        let mut headers = HeaderMap::new();
        headers.insert(header::COOKIE, HeaderValue::from_static("theme=dark"));
        assert_eq!(extract_cookie_value(&headers, "session"), None);
    }

    #[test]
    fn test_extract_cookie_no_header() {
        let headers = HeaderMap::new();
        assert_eq!(extract_cookie_value(&headers, "session"), None);
    }

    #[test]
    fn test_extract_cookie_prefix_mismatch() {
        let mut headers = HeaderMap::new();
        headers.insert(
            header::COOKIE,
            HeaderValue::from_static("my_session=abc; session_old=def"),
        );
        // "session" should not match "my_session" or "session_old"
        assert_eq!(extract_cookie_value(&headers, "session"), None);
    }

    // ── Proxy utility tests ──

    #[test]
    fn test_hop_by_hop_request() {
        assert!(is_hop_by_hop_header("host"));
        assert!(is_hop_by_hop_header("Host"));
        assert!(is_hop_by_hop_header("connection"));
        assert!(is_hop_by_hop_header("transfer-encoding"));
        assert!(!is_hop_by_hop_header("content-type"));
        assert!(!is_hop_by_hop_header("authorization"));
    }

    #[test]
    fn test_hop_by_hop_response() {
        assert!(is_response_hop_by_hop("connection"));
        assert!(is_response_hop_by_hop("Connection"));
        assert!(is_response_hop_by_hop("transfer-encoding"));
        assert!(!is_response_hop_by_hop("host"));
        assert!(!is_response_hop_by_hop("content-type"));
    }

    #[test]
    fn test_forward_request_headers() {
        let mut original = HeaderMap::new();
        original.insert("content-type", HeaderValue::from_static("application/json"));
        original.insert("authorization", HeaderValue::from_static("Bearer token"));
        original.insert("host", HeaderValue::from_static("localhost"));
        original.insert("connection", HeaderValue::from_static("keep-alive"));

        let forwarded = forward_request_headers(&original);
        assert!(forwarded.contains_key("content-type"));
        assert!(forwarded.contains_key("authorization"));
        assert!(!forwarded.contains_key("host"));
        assert!(!forwarded.contains_key("connection"));
    }

    #[test]
    fn test_build_proxy_url_with_query() {
        let url = build_proxy_url("http://backend:8080", "api/data", Some("page=1"));
        assert_eq!(url, "http://backend:8080/api/data?page=1");
    }

    #[test]
    fn test_build_proxy_url_no_query() {
        let url = build_proxy_url("http://backend:8080", "api/data", None);
        assert_eq!(url, "http://backend:8080/api/data");
    }

    #[test]
    fn test_build_proxy_url_trailing_slash() {
        let url = build_proxy_url("http://backend:8080/", "/api/data", Some("x=1"));
        assert_eq!(url, "http://backend:8080/api/data?x=1");
    }

    #[test]
    fn test_build_proxy_url_empty_query() {
        let url = build_proxy_url("http://backend:8080", "path", Some(""));
        assert_eq!(url, "http://backend:8080/path");
    }

    // ── JSON error response tests ──

    #[tokio::test]
    async fn test_json_error_response_status() {
        let resp = json_error_response(StatusCode::NOT_FOUND, "not found");
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_json_error_response_body() {
        let resp = json_error_response(StatusCode::BAD_REQUEST, "invalid input");
        let body = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(json["error"], "invalid input");
    }

    #[tokio::test]
    async fn test_not_found() {
        let resp = not_found("resource missing");
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }

    #[tokio::test]
    async fn test_bad_request() {
        let resp = bad_request("bad params");
        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn test_unauthorized() {
        let resp = unauthorized("login required");
        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn test_internal_error() {
        let resp = internal_error("server broke");
        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
    }

    // ── Image Utilities tests ──

    #[test]
    fn test_image_media_type_from_extension() {
        assert_eq!(image_media_type("https://example.com/photo.jpg", None), "image/jpeg");
        assert_eq!(image_media_type("https://example.com/photo.jpeg", None), "image/jpeg");
        assert_eq!(image_media_type("https://example.com/photo.png", None), "image/png");
        assert_eq!(image_media_type("https://example.com/photo.gif", None), "image/gif");
        assert_eq!(image_media_type("https://example.com/photo.webp", None), "image/webp");
    }

    #[test]
    fn test_image_media_type_with_query_string() {
        assert_eq!(
            image_media_type("https://example.com/photo.jpg?w=100&h=100", None),
            "image/jpeg"
        );
    }

    #[test]
    fn test_image_media_type_case_insensitive() {
        assert_eq!(image_media_type("https://example.com/photo.JPG", None), "image/jpeg");
        assert_eq!(image_media_type("https://example.com/photo.PNG", None), "image/png");
    }

    #[test]
    fn test_image_media_type_from_content_type_header() {
        assert_eq!(image_media_type("https://example.com/image", Some("image/jpeg")), "image/jpeg");
        assert_eq!(image_media_type("https://example.com/image", Some("image/png")), "image/png");
        assert_eq!(image_media_type("https://example.com/image", Some("image/gif")), "image/gif");
        assert_eq!(image_media_type("https://example.com/image", Some("image/webp; charset=utf-8")), "image/webp");
    }

    #[test]
    fn test_image_media_type_extension_over_header() {
        assert_eq!(
            image_media_type("https://example.com/photo.jpg", Some("image/png")),
            "image/jpeg"
        );
    }

    #[test]
    fn test_image_media_type_default() {
        assert_eq!(image_media_type("https://example.com/image", None), "image/png");
        assert_eq!(image_media_type("https://example.com/file.bmp", None), "image/png");
    }

    #[test]
    fn test_image_media_type_no_path() {
        assert_eq!(image_media_type("https://example.com/", None), "image/png");
        assert_eq!(image_media_type("", None), "image/png");
    }

    #[tokio::test]
    async fn test_download_as_data_uri_passthrough() {
        let client = reqwest::Client::new();
        let data_uri = "data:image/png;base64,iVBORw0KGgo=";
        let result = download_as_data_uri(&client, data_uri).await.unwrap();
        assert_eq!(result, data_uri);
    }

    #[tokio::test]
    async fn test_download_as_data_uri_invalid_url() {
        let client = reqwest::Client::new();
        let result = download_as_data_uri(&client, "http://0.0.0.0:1/nonexistent.png").await;
        assert!(result.is_err());
    }

    // ── File / Resolve Image tests ──

    #[test]
    fn test_file_to_data_uri_success() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("test.png");
        std::fs::write(&file_path, b"\x89PNG\r\n\x1a\n").unwrap();
        let result = file_to_data_uri(&file_path).unwrap();
        assert!(result.starts_with("data:image/png;base64,"));
    }

    #[test]
    fn test_file_to_data_uri_not_found() {
        let result = file_to_data_uri(std::path::Path::new("/nonexistent/image.png"));
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to read file"));
    }

    #[test]
    fn test_file_to_data_uri_jpeg_extension() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("photo.jpg");
        std::fs::write(&file_path, b"fake jpeg").unwrap();
        let result = file_to_data_uri(&file_path).unwrap();
        assert!(result.starts_with("data:image/jpeg;base64,"));
    }

    #[tokio::test]
    async fn test_resolve_data_uri_passthrough() {
        let client = reqwest::Client::new();
        let data_uri = "data:image/png;base64,iVBORw0KGgo=";
        let result = resolve_image_to_data_uri(&client, data_uri, None).await.unwrap();
        assert_eq!(result, data_uri);
    }

    #[tokio::test]
    async fn test_resolve_http_url_invalid() {
        let client = reqwest::Client::new();
        let result = resolve_image_to_data_uri(&client, "http://0.0.0.0:1/img.png", None).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn test_resolve_relative_path_with_base_dir() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("uploads/photo.png");
        std::fs::create_dir_all(dir.path().join("uploads")).unwrap();
        std::fs::write(&file_path, b"\x89PNG").unwrap();
        let result = resolve_image_to_data_uri(
            &reqwest::Client::new(),
            "uploads/photo.png",
            Some(dir.path()),
        ).await.unwrap();
        assert!(result.starts_with("data:image/png;base64,"));
    }

    #[tokio::test]
    async fn test_resolve_absolute_path() {
        let dir = tempfile::tempdir().unwrap();
        let file_path = dir.path().join("abs.gif");
        std::fs::write(&file_path, b"fake gif").unwrap();
        let result = resolve_image_to_data_uri(
            &reqwest::Client::new(),
            file_path.to_str().unwrap(),
            None,
        ).await.unwrap();
        assert!(result.starts_with("data:image/gif;base64,"));
    }

    #[tokio::test]
    async fn test_resolve_file_not_found() {
        let result = resolve_image_to_data_uri(
            &reqwest::Client::new(),
            "nonexistent.png",
            Some(std::path::Path::new("/tmp")),
        ).await;
        assert!(result.is_err());
    }

    // ── Cookie value & endpoint resolution tests ──

    #[test]
    fn test_build_cookie_value() {
        let cookie = build_cookie_value("session_token", "abc123");
        assert_eq!(cookie, "session_token=abc123");
    }

    #[test]
    fn test_build_cookie_value_empty() {
        let cookie = build_cookie_value("name", "");
        assert_eq!(cookie, "name=");
    }

    #[test]
    fn test_resolve_http_endpoint_with_scheme() {
        assert_eq!(resolve_http_endpoint(Some("http://example.com"), 8080), "http://example.com");
        assert_eq!(resolve_http_endpoint(Some("https://secure.com"), 8080), "https://secure.com");
    }

    #[test]
    fn test_resolve_http_endpoint_without_scheme() {
        assert_eq!(resolve_http_endpoint(Some("192.168.1.1:3000"), 8080), "http://192.168.1.1:3000");
        assert_eq!(resolve_http_endpoint(Some("myhost"), 8080), "http://myhost");
    }

    #[test]
    fn test_resolve_http_endpoint_none() {
        assert_eq!(resolve_http_endpoint(None, 8080), "http://localhost:8080");
    }

    #[test]
    fn test_resolve_http_endpoint_empty() {
        assert_eq!(resolve_http_endpoint(Some(""), 9090), "http://localhost:9090");
    }

    // ── Embedded HTML response test ──

    #[tokio::test]
    async fn test_embedded_html_response() {
        let resp = embedded_html_response("<h1>Hello</h1>");
        assert_eq!(resp.status(), StatusCode::OK);
        let ct = resp.headers().get(header::CONTENT_TYPE).unwrap();
        assert_eq!(ct, "text/html; charset=utf-8");
        let cc = resp.headers().get(header::CACHE_CONTROL).unwrap();
        assert_eq!(cc, "no-cache");
    }
}