use axum::{
    body::Body,
    extract::{OriginalUri, State},
    http::{HeaderMap, StatusCode},
    response::Response,
    routing::{any, post},
    Router,
};
use http_body_util::BodyExt;
use hyper_util::client::legacy::Client;
use myrr_core::engine::{MyrrEngine, MyrrRequest};
use myrr_core::rate_limit::SlidingWindowRateLimiter;
use myrr_core::routes::RouteTable;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tracing::info;

const MAX_BODY_BYTES: u64 = 100 * 1024 * 1024;
const MAX_DECOMPRESSED_BYTES: u64 = 500 * 1024 * 1024;
const MAX_FILE_COUNT: usize = 10_000;
const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const DEPLOY_RATE_LIMIT: usize = 10;
const DEPLOY_RATE_WINDOW: Duration = Duration::from_secs(60);
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];

#[derive(Clone)]
pub struct ServerConfig {
    pub port: u16,
    pub upstream: Option<String>,
    pub myrr_dir: PathBuf,
    pub api_key: Option<String>,
    pub require_auth: bool,
    pub cors_origin: Option<String>,
}

#[derive(Clone)]
pub struct AppState {
    pub config: ServerConfig,
    pub deploy_limiter: Arc<SlidingWindowRateLimiter>,
    pub request_limiter: Arc<SlidingWindowRateLimiter>,
    pub route_table: Option<Arc<RouteTable>>,
}

pub async fn start(config: ServerConfig) {
    tracing_subscriber::fmt()
        .with_env_filter("myrr=info")
        .init();

    if config.require_auth && config.api_key.as_ref().map_or(true, |k| k.is_empty()) {
        panic!("myrr-server requires --api-key in production mode. Use --no-auth for development.");
    }

    let api_key = config.api_key.clone().unwrap_or_default();
    let secret = api_key.clone();
    let max_port_tries = 10;

    let (listener, port) = {
        let mut port = config.port;
        loop {
            let addr = format!("0.0.0.0:{port}");
            match tokio::net::TcpListener::bind(&addr).await {
                Ok(l) => break (l, port),
                Err(_) if port < config.port + max_port_tries => {
                    tracing::warn!("port {} in use, trying {}", port, port + 1);
                    port += 1;
                }
                Err(e) => panic!(
                    "could not bind to any port from {} to {}: {}",
                    config.port,
                    config.port + max_port_tries - 1,
                    e
                ),
            }
        }
    };

    info!(
        "myrr server started on port {} (myrr_dir: {}, deploy: {})",
        port,
        config.myrr_dir.display(),
        if secret.is_empty() { "disabled" } else { "enabled" }
    );

    let deploy_limiter = Arc::new(SlidingWindowRateLimiter::with_limit(DEPLOY_RATE_LIMIT, DEPLOY_RATE_WINDOW));
    let request_limiter = Arc::new(SlidingWindowRateLimiter::new());

    let routes_path = config.myrr_dir.join("routes.json");
    let route_table = RouteTable::load(&routes_path).map(Arc::new);
    if route_table.is_some() {
        info!("loaded {} routes from routes.json", route_table.as_ref().unwrap().len());
    }

    let state = AppState {
        config: ServerConfig {
            port,
            myrr_dir: config.myrr_dir.clone(),
            ..config
        },
        deploy_limiter: deploy_limiter.clone(),
        request_limiter: request_limiter.clone(),
        route_table,
    };

    let myrr_dir = state.config.myrr_dir.clone();

    let app = Router::new()
        .route("/", any(handle_request))
        .route("/*path", any(handle_request))
        .route("/api/v1/deploy", post(move |headers, body| async move {
            handle_deploy_limited(headers, body, secret.clone(), myrr_dir.clone(), deploy_limiter.clone()).await
        }))
        .route("/_myrr/health", any(|| async { "ok" }))
        .with_state(state);

    info!("listening on 0.0.0.0:{}", port);

    axum::serve(listener, app).await.unwrap();
}

async fn handle_request(
    State(state): State<AppState>,
    method: http::Method,
    uri: OriginalUri,
    headers: HeaderMap,
) -> Response {
    match tokio::time::timeout(REQUEST_TIMEOUT, async {
        handle_request_inner(state, method, uri, headers).await
    }).await {
        Ok(resp) => resp,
        Err(_) => Response::builder()
            .status(504)
            .body(Body::from("request timed out"))
            .unwrap(),
    }
}

async fn handle_request_inner(
    state: AppState,
    method: http::Method,
    uri: OriginalUri,
    headers: HeaderMap,
) -> Response {
    let path = uri.0.path().to_string();

    if path.starts_with("/_myrr/") {
        return Response::builder()
            .status(404)
            .body(Body::from("not found"))
            .unwrap();
    }

    let client_ip = "127.0.0.1".to_string();

    // Reject paths with CR/LF characters to prevent header injection in proxy
    if path.contains('\r') || path.contains('\n') {
        return Response::builder()
            .status(400)
            .body(Body::from("invalid path"))
            .unwrap();
    }

    let engine = MyrrEngine::new(state.config.myrr_dir.to_str().unwrap_or("."))
        .with_cors_origin(state.config.cors_origin.clone());
    let flat_headers = flatten_headers(&headers);
    let req = MyrrRequest {
        method: method.to_string(),
        path: path.clone(),
        headers: flat_headers,
        client_ip,
    };

    let (resp, resp_headers) = engine.handle_request(&req);

    match resp {
        myrr_core::engine::MyrrResponse::MyrrFile(body) => {
            let mut response = Response::builder()
                .status(StatusCode::OK)
                .header("content-type", "text/markdown; charset=utf-8")
                .body(Body::from(body))
                .unwrap();
            for (k, v) in &resp_headers {
                if let (Ok(name), Ok(val)) = (
                    http::HeaderName::from_bytes(k.as_bytes()),
                    v.parse::<http::HeaderValue>(),
                ) {
                    response.headers_mut().insert(name, val);
                }
            }
            response
        }
        myrr_core::engine::MyrrResponse::NotFound | myrr_core::engine::MyrrResponse::NotAnAgent => {
            proxy_to_upstream(&state, &method, &path, &headers).await
        }
        myrr_core::engine::MyrrResponse::RateLimited { .. } => {
            let mut response = Response::new(Body::from("rate limited"));
            *response.status_mut() = StatusCode::TOO_MANY_REQUESTS;
            for (k, v) in &resp_headers {
                if let (Ok(name), Ok(val)) = (
                    http::HeaderName::from_bytes(k.as_bytes()),
                    v.parse::<http::HeaderValue>(),
                ) {
                    response.headers_mut().insert(name, val);
                }
            }
            response
        }
    }
}

async fn handle_deploy_limited(
    headers: HeaderMap,
    body: Body,
    api_key: String,
    myrr_dir: PathBuf,
    deploy_limiter: Arc<SlidingWindowRateLimiter>,
) -> Response {
    let client_ip = headers
        .get("x-forwarded-for")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("unknown");

    let limit = deploy_limiter.check(client_ip);
    if !limit.allowed {
        return Response::builder()
            .status(429)
            .body(Body::from("too many deploys"))
            .unwrap();
    }

    handle_deploy(headers, body, api_key, myrr_dir).await
}

async fn handle_deploy(
    headers: HeaderMap,
    body: Body,
    api_key: String,
    myrr_dir: PathBuf,
) -> Response {
    if !api_key.is_empty() {
        let provided = headers
            .get("x-myrr-api-key")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("");
        if provided != api_key {
            return Response::builder()
                .status(401)
                .body(Body::from("invalid api key"))
                .unwrap();
        }
    } else {
        return Response::builder()
            .status(401)
            .body(Body::from("deploy requires authentication"))
            .unwrap();
    }

    let collected = match body.collect().await {
        Ok(c) => c,
        Err(_) => {
            return Response::builder()
                .status(400)
                .body(Body::from("failed to read body"))
                .unwrap();
        }
    };

    let bytes = collected.to_bytes();

    // Verify gzip magic bytes to reject non-gzip payloads early
    if bytes.len() < 2 || bytes[0] != GZIP_MAGIC[0] || bytes[1] != GZIP_MAGIC[1] {
        return Response::builder()
            .status(400)
            .body(Body::from("invalid archive format"))
            .unwrap();
    }
    if bytes.len() as u64 > MAX_BODY_BYTES {
        return Response::builder()
            .status(413)
            .body(Body::from("payload too large"))
            .unwrap();
    }

    if let Err(e) = validate_deploy_payload(&bytes) {
        return Response::builder()
            .status(413)
            .body(Body::from(e))
            .unwrap();
    }

    let tmp_dir = std::env::temp_dir().join(format!("myrr-deploy-{}", uuid::Uuid::new_v4()));
    std::fs::create_dir_all(&tmp_dir).unwrap();

    if let Err(e) = extract_archive_with_limits(&bytes, &tmp_dir) {
        let _ = std::fs::remove_dir_all(&tmp_dir);
        return Response::builder()
            .status(413)
            .body(Body::from(e))
            .unwrap();
    }

    let extracted_myrr = if tmp_dir.join("myrr").exists() {
        tmp_dir.join("myrr")
    } else {
        tmp_dir.clone()
    };

    if !extracted_myrr.exists() {
        let _ = std::fs::remove_dir_all(&tmp_dir);
        return Response::builder()
            .status(400)
            .body(Body::from("no .md files found in archive"))
            .unwrap();
    }

    let _ = std::fs::remove_dir_all(&myrr_dir);
    std::fs::create_dir_all(&myrr_dir).unwrap();

    match copy_dir_recursive(&extracted_myrr, &myrr_dir) {
        Ok(count) => {
            let _ = std::fs::remove_dir_all(&tmp_dir);
            info!("deployed {} files", count);
            let body = serde_json::json!({
                "status": "ok",
                "files_deployed": count
            });
            Response::builder()
                .status(200)
                .header("content-type", "application/json")
                .body(Body::from(serde_json::to_string(&body).unwrap()))
                .unwrap()
        }
        Err(e) => {
            let _ = std::fs::remove_dir_all(&tmp_dir);
            Response::builder()
                .status(500)
                .body(Body::from(format!("deploy failed: {}", e)))
                .unwrap()
        }
    }
}

fn validate_deploy_payload(bytes: &[u8]) -> Result<(), String> {
    if bytes.len() as u64 > MAX_BODY_BYTES {
        return Err("payload too large".to_string());
    }

    let decoder = flate2::read::GzDecoder::new(bytes);
    let mut archive = tar::Archive::new(decoder);
    let mut total_decompressed: u64 = 0;
    let mut file_count: usize = 0;

    for entry in archive.entries().map_err(|_| "invalid archive".to_string())? {
        let entry = entry.map_err(|_| "invalid archive entry".to_string())?;
        file_count += 1;

        if file_count > MAX_FILE_COUNT {
            return Err("too many files in archive".to_string());
        }

        let size = entry.header().size().map_err(|e| e.to_string())?;
        if size > MAX_FILE_BYTES {
            return Err(format!("file too large: {} bytes", size));
        }

        total_decompressed += size;
        if total_decompressed > MAX_DECOMPRESSED_BYTES {
            return Err("decompressed payload too large".to_string());
        }

        let path = entry.path().map_err(|_| "invalid path in archive".to_string())?;
        if path.components().any(|c| c.as_os_str() == "..") {
            return Err("path traversal detected".to_string());
        }
    }

    Ok(())
}

fn extract_archive_with_limits(bytes: &[u8], dst: &Path) -> Result<(), String> {
    let decoder = flate2::read::GzDecoder::new(bytes);
    let mut archive = tar::Archive::new(decoder);
    let mut total_decompressed: u64 = 0;

    for entry in archive.entries().map_err(|_| "invalid archive".to_string())? {
        let mut entry = entry.map_err(|_| "invalid archive entry".to_string())?;
        let size = entry.header().size().map_err(|e| e.to_string())?;
        total_decompressed += size;

        if size > MAX_FILE_BYTES {
            return Err(format!("file too large: {} bytes", size));
        }
        if total_decompressed > MAX_DECOMPRESSED_BYTES {
            return Err("decompressed payload too large".to_string());
        }

        let path = entry.path().map_err(|_| "invalid path".to_string())?.to_path_buf();
        if path.components().any(|c| c.as_os_str() == "..") {
            return Err("path traversal detected".to_string());
        }

        // Check link_name for symlink/hardlink traversal
        if let Ok(link_name) = entry.link_name() {
            if let Some(target) = link_name {
                let target_str = target.to_string_lossy();
                if target_str.contains("..") {
                    return Err("link target traversal detected".to_string());
                }
            }
        }

        let dest = dst.join(&path);
        if entry.header().entry_type().is_dir() {
            std::fs::create_dir_all(&dest).map_err(|_| "failed to create directory".to_string())?;
        } else if entry.header().entry_type().is_symlink() || entry.header().entry_type().is_hard_link() {
            // Symlinks and hard links are rendered as empty regular files
            // to prevent symlink escape attacks via copy_dir_recursive
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent).map_err(|_| "failed to create parent".to_string())?;
            }
            std::fs::write(&dest, &[]).map_err(|_| "failed to write file".to_string())?;
        } else {
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent).map_err(|_| "failed to create parent".to_string())?;
            }
            let mut buf = Vec::with_capacity(size as usize);
            entry.read_to_end(&mut buf).map_err(|_| "failed to read entry".to_string())?;
            std::fs::write(&dest, &buf).map_err(|_| "failed to write file".to_string())?;
        }
    }

    Ok(())
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<usize, std::io::Error> {
    let mut count = 0;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let path = entry.path();
        let rel = path.strip_prefix(src).unwrap();
        let dest = dst.join(rel);

        // Reject symlinks in the source directory to prevent symlink escape
        if path.is_symlink() {
            std::fs::remove_file(&path)?;
            continue;
        }

        if path.is_dir() {
            std::fs::create_dir_all(&dest)?;
            count += copy_dir_recursive(&path, &dest)?;
        } else if path.extension().is_some_and(|e| e == "md") {
            std::fs::copy(&path, &dest)?;
            count += 1;
        }
    }
    Ok(count)
}

async fn proxy_to_upstream(
    state: &AppState,
    method: &http::Method,
    path: &str,
    _headers: &HeaderMap,
) -> Response {
    let upstream = match &state.config.upstream {
        Some(u) => u.clone(),
        None => {
            return Response::builder()
                .status(502)
                .body(Body::from("no upstream configured"))
                .unwrap();
        }
    };

    // Sanitize path to prevent CRLF injection and SSRF
    let safe_path = path
        .split(&['\r', '\n'][..])
        .next()
        .unwrap_or("/");

    let url = format!("{}{}", upstream.trim_end_matches('/'), safe_path);

    // Validate URL scheme to prevent SSRF to local files
    if !url.starts_with("http://") && !url.starts_with("https://") {
        return Response::builder()
            .status(502)
            .body(Body::from("invalid upstream URL"))
            .unwrap();
    }

    let client = Client::builder(hyper_util::rt::TokioExecutor::new())
        .build(hyper_util::client::legacy::connect::HttpConnector::new());

    let req = http::Request::builder()
        .method(method)
        .uri(&url)
        .body(Body::empty())
        .unwrap();

    match tokio::time::timeout(REQUEST_TIMEOUT, client.request(req)).await {
        Ok(Ok(resp)) => {
            let (parts, body) = resp.into_parts();
            let bytes = body.collect().await.unwrap_or_default().to_bytes();
            let mut response = Response::new(Body::from(bytes));
            *response.status_mut() = parts.status;
            response
        }
        Ok(Err(e)) => {
            tracing::warn!("upstream request failed: {}", e);
            Response::builder()
                .status(502)
                .body(Body::from(format!("upstream error: {}", e)))
                .unwrap()
        }
        Err(_) => {
            Response::builder()
                .status(504)
                .body(Body::from("upstream request timed out"))
                .unwrap()
        }
    }
}

fn flatten_headers(headers: &HeaderMap) -> HashMap<String, String> {
    let mut flat = HashMap::new();
    for (key, value) in headers.iter() {
        if let Ok(v) = value.to_str() {
            flat.insert(key.to_string(), v.to_string());
        }
    }
    flat
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    fn create_tar_gz<F>(builder: F) -> Vec<u8>
    where
        F: FnOnce(&mut tar::Builder<Vec<u8>>),
    {
        let tar_bytes = {
            let mut tar_builder = tar::Builder::new(Vec::new());
            builder(&mut tar_builder);
            tar_builder.into_inner().unwrap()
        };

        let mut compressed = Vec::new();
        {
            let mut encoder = flate2::write::GzEncoder::new(&mut compressed, flate2::Compression::default());
            encoder.write_all(&tar_bytes).unwrap();
            encoder.finish().unwrap();
        }
        compressed
    }

    fn make_file(contents: &str) -> Vec<u8> {
        create_tar_gz(|tar| {
            let mut header = tar::Header::new_gnu();
            header.set_path("test.md").unwrap();
            header.set_size(contents.len() as u64);
            header.set_cksum();
            tar.append(&header, contents.as_bytes()).unwrap();
        })
    }

    // ── Basic validation tests ────────────────────────────────

    #[test]
    fn rejects_empty_payload() {
        let result = validate_deploy_payload(&[]);
        assert!(result.is_err());
    }

    #[test]
    fn accepts_valid_small_archive() {
        let data = make_file("# Hello\n\nWorld");
        let result = validate_deploy_payload(&data);
        assert!(result.is_ok(), "expected ok, got: {:?}", result);
    }

    #[test]
    fn accepts_multiple_md_files() {
        let data = create_tar_gz(|tar| {
            for i in 0..100 {
                let contents = format!("# Page {}\n\nContent", i);
                let mut header = tar::Header::new_gnu();
                header.set_path(format!("page{}.md", i)).unwrap();
                header.set_size(contents.len() as u64);
                header.set_cksum();
                tar.append(&header, contents.as_bytes()).unwrap();
            }
        });
        let result = validate_deploy_payload(&data);
        assert!(result.is_ok());
    }

    #[test]
    fn rejects_oversized_individual_file() {
        let contents = vec![b'X'; (MAX_FILE_BYTES + 1) as usize];
        let data = create_tar_gz(|tar| {
            let mut header = tar::Header::new_gnu();
            header.set_path("huge.md").unwrap();
            header.set_size(contents.len() as u64);
            header.set_cksum();
            tar.append(&header, &contents[..]).unwrap();
        });
        let result = validate_deploy_payload(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("file too large"));
    }

    #[test]
    fn rejects_too_many_files() {
        let data = create_tar_gz(|tar| {
            for i in 0..(MAX_FILE_COUNT + 1) {
                let contents = "x";
                let mut header = tar::Header::new_gnu();
                header.set_path(format!("f{}.md", i)).unwrap();
                header.set_size(contents.len() as u64);
                header.set_cksum();
                tar.append(&header, contents.as_bytes()).unwrap();
            }
        });
        let result = validate_deploy_payload(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("too many files"));
    }

    #[test]
    fn rejects_decompressed_payload_over_limit() {
        let file_size = MAX_DECOMPRESSED_BYTES / 2 + 1;
        let contents = vec![b'X'; file_size as usize];
        let data = create_tar_gz(|tar| {
            let mut h1 = tar::Header::new_gnu();
            h1.set_path("a.md").unwrap();
            h1.set_size(contents.len() as u64);
            h1.set_cksum();
            tar.append(&h1, &contents[..]).unwrap();

            let mut h2 = tar::Header::new_gnu();
            h2.set_path("b.md").unwrap();
            h2.set_size(contents.len() as u64);
            h2.set_cksum();
            tar.append(&h2, &contents[..]).unwrap();
        });
        let result = validate_deploy_payload(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("too large"));
    }

    #[test]
    fn rejects_body_over_max_size() {
        let data = vec![0u8; (MAX_BODY_BYTES + 1) as usize];
        let result = validate_deploy_payload(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("too large"));
    }

    #[test]
    fn rejects_path_traversal_in_path_check() {
        let result = validate_deploy_payload(&make_file("test"));
        assert!(result.is_ok());
        let bad_path = std::path::Path::new("../etc/passwd");
        assert!(
            bad_path.components().any(|c| c.as_os_str() == ".."),
            "path component check should detect .."
        );
    }

    // ── Extraction tests ─────────────────────────────────────

    #[test]
    fn extract_rejects_oversized_file() {
        let dir = std::env::temp_dir().join("myrr_test_extract_oversized");
        let _ = std::fs::remove_dir_all(&dir);

        let contents = vec![b'Y'; (MAX_FILE_BYTES + 1) as usize];
        let data = create_tar_gz(|tar| {
            let mut header = tar::Header::new_gnu();
            header.set_path("huge.md").unwrap();
            header.set_size(contents.len() as u64);
            header.set_cksum();
            tar.append(&header, &contents[..]).unwrap();
        });
        let result = extract_archive_with_limits(&data, &dir);
        assert!(result.is_err());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn extract_writes_valid_files() {
        let dir = std::env::temp_dir().join("myrr_test_extract_valid");
        let _ = std::fs::remove_dir_all(&dir);

        let data = create_tar_gz(|tar| {
            let mut header = tar::Header::new_gnu();
            header.set_path("hello.md").unwrap();
            header.set_size(13);
            header.set_cksum();
            tar.append(&header, &b"# Hello World"[..]).unwrap();
        });
        let result = extract_archive_with_limits(&data, &dir);
        assert!(result.is_ok());

        let extracted = std::fs::read_to_string(dir.join("hello.md")).unwrap();
        assert_eq!(extracted, "# Hello World");
        let _ = std::fs::remove_dir_all(&dir);
    }

    // ── Symlink and traversal tests ──────────────────────────

    #[test]
    fn extract_renders_symlink_as_empty_file() {
        let dir = std::env::temp_dir().join("myrr_test_symlink_extract");
        let _ = std::fs::remove_dir_all(&dir);

        let data = create_tar_gz(|tar| {
            let mut header = tar::Header::new_gnu();
            header.set_path("link.md").unwrap();
            header.set_entry_type(tar::EntryType::Symlink);
            header.set_size(0);
            header.set_cksum();
            tar.append(&header, &[][..]).unwrap();
        });
        let result = extract_archive_with_limits(&data, &dir);
        assert!(result.is_ok());

        let content = std::fs::read_to_string(dir.join("link.md")).unwrap_or_default();
        assert_eq!(content, "", "symlink should be extracted as empty file");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn copy_dir_recursive_rejects_symlink_escape() {
        let src = std::env::temp_dir().join("myrr_test_copy_src");
        let dst = std::env::temp_dir().join("myrr_test_copy_dst");
        let _ = std::fs::remove_dir_all(&src);
        let _ = std::fs::remove_dir_all(&dst);
        std::fs::create_dir_all(&src).unwrap();

        let outside = std::env::temp_dir().join("myrr_secret.txt");
        std::fs::write(&outside, "secret data").unwrap();

        let link = src.join("leak.md");
        std::os::unix::fs::symlink(&outside, &link).unwrap();

        let result = copy_dir_recursive(&src, &dst);
        assert!(result.is_ok());

        let copied = dst.join("leak.md");
        assert!(
            !copied.exists(),
            "symlink target should not be copied outside base dir"
        );

        let _ = std::fs::remove_dir_all(&src);
        let _ = std::fs::remove_dir_all(&dst);
        let _ = std::fs::remove_file(&outside);
    }

    #[test]
    fn copy_dir_recursive_removes_symlink_after_detect() {
        let src = std::env::temp_dir().join("myrr_test_copy_remove_sym");
        let _ = std::fs::remove_dir_all(&src);
        std::fs::create_dir_all(&src).unwrap();

        let target = src.join("real.md");
        std::fs::write(&target, "real").unwrap();

        let link = src.join("link.md");
        std::os::unix::fs::symlink(&target, &link).unwrap();

        assert!(link.exists(), "symlink should exist before copy");

        let dst = std::env::temp_dir().join("myrr_test_copy_remove_dst");
        let _ = std::fs::remove_dir_all(&dst);

        let _ = copy_dir_recursive(&src, &dst);

        // Symlink should be removed after detection
        assert!(!link.exists(), "symlink should be removed after copy");

        let _ = std::fs::remove_dir_all(&src);
        let _ = std::fs::remove_dir_all(&dst);
    }

    // ── Gzip attack tests ────────────────────────────────────

    #[test]
    fn multiple_gzip_members_only_first_decoded() {
        let member1 = create_tar_gz(|tar| {
            let mut h = tar::Header::new_gnu();
            h.set_path("safe.md").unwrap();
            h.set_size(4);
            h.set_cksum();
            tar.append(&h, &b"data"[..]).unwrap();
        });

        let member2 = create_tar_gz(|tar| {
            let mut h = tar::Header::new_gnu();
            h.set_path("hidden.md").unwrap();
            h.set_size(500);
            h.set_cksum();
            tar.append(&h, &[b'X'; 500][..]).unwrap();
        });

        let mut combined = member1;
        combined.extend_from_slice(&member2);

        let result = validate_deploy_payload(&combined);
        assert!(result.is_ok(), "should only see member1: {:?}", result);
    }

    // ── Boundary tests ───────────────────────────────────────

    #[test]
    fn exact_limit_boundary_passes() {
        let data = create_tar_gz(|tar| {
            for i in 0..100 {
                let content = vec![b'A'; 50_000];
                let mut h = tar::Header::new_gnu();
                h.set_path(format!("f{}.md", i)).unwrap();
                h.set_size(content.len() as u64);
                h.set_cksum();
                tar.append(&h, &content[..]).unwrap();
            }
        });
        let result = validate_deploy_payload(&data);
        assert!(result.is_ok());
    }

    // ── Config tests ─────────────────────────────────────────

    #[test]
    fn server_config_default_port() {
        let config = ServerConfig {
            port: 4242,
            upstream: None,
            myrr_dir: PathBuf::from("./myrr"),
            api_key: None,
            require_auth: false,
            cors_origin: None,
        };
        assert_eq!(config.port, 4242);
    }

    #[test]
    fn server_config_default_auth_required() {
        let config = ServerConfig {
            port: 4242,
            upstream: None,
            myrr_dir: PathBuf::from("./myrr"),
            api_key: None,
            require_auth: false,
            cors_origin: None,
        };
        assert!(!config.require_auth);
    }

    #[test]
    fn server_config_custom_port() {
        let config = ServerConfig {
            port: 8081,
            upstream: Some("http://localhost:3000".into()),
            myrr_dir: PathBuf::from("/tmp/myrr"),
            api_key: Some("test-key".into()),
            require_auth: true,
            cors_origin: Some("*".into()),
        };
        assert_eq!(config.port, 8081);
        assert_eq!(config.upstream.unwrap(), "http://localhost:3000");
        assert_eq!(config.cors_origin.unwrap(), "*");
    }

    #[test]
    fn server_config_default_myrr_dir() {
        let config = ServerConfig {
            port: 4242,
            upstream: None,
            myrr_dir: PathBuf::from("./myrr"),
            api_key: None,
            require_auth: false,
            cors_origin: None,
        };
        assert_eq!(config.myrr_dir, PathBuf::from("./myrr"));
    }
}
