//! `fallow coverage upload-source-maps` - upload build source maps to fallow cloud.
//!
//! This is a CI-side helper for bundled runtime coverage. The beacon reports
//! coverage against deployed bundle paths; source maps uploaded here let the
//! cloud resolver map those positions back to original source files.

use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::time::Duration;

use colored::Colorize as _;
use fallow_core::git_env::clear_ambient_git_env;
use globset::{Glob, GlobSet, GlobSetBuilder};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};

use crate::api::{ResponseBodyReader, api_agent_with_timeout, api_url};

const LOG_PREFIX: &str = "fallow coverage upload-source-maps";
const DEFAULT_ENDPOINT: &str = "https://api.fallow.cloud";
const CONNECT_TIMEOUT_SECS: u64 = 5;
const TOTAL_TIMEOUT_SECS: u64 = 60;
const MAX_ATTEMPTS: u8 = 3;
const WARN_MAP_BYTES: u64 = 10 * 1024 * 1024;
const MAX_MAP_BYTES: u64 = 100 * 1024 * 1024;

#[derive(Debug, Clone)]
pub struct UploadSourceMapsArgs {
    pub dir: PathBuf,
    pub include: String,
    pub exclude: Vec<String>,
    pub repo: Option<String>,
    pub git_sha: Option<String>,
    pub endpoint: Option<String>,
    pub strip_path: bool,
    pub dry_run: bool,
    pub concurrency: usize,
    pub fail_fast: bool,
}

#[derive(Debug)]
enum UploadSourceMapsError {
    Validation(String),
    Partial(Vec<MapOutcome>),
}

impl UploadSourceMapsError {
    fn into_exit(self) -> ExitCode {
        match self {
            Self::Validation(message) => {
                eprintln!("{LOG_PREFIX}: {}: {message}", "error".red().bold());
                ExitCode::from(2)
            }
            Self::Partial(outcomes) => {
                print_failure_summary(&outcomes);
                ExitCode::from(1)
            }
        }
    }
}

pub fn run(args: &UploadSourceMapsArgs, root: &Path) -> ExitCode {
    match run_inner(args, root) {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => err.into_exit(),
    }
}

fn run_inner(args: &UploadSourceMapsArgs, root: &Path) -> Result<(), UploadSourceMapsError> {
    let build_dir = resolve_build_dir(root, &args.dir);
    if !build_dir.is_dir() {
        return Err(UploadSourceMapsError::Validation(format!(
            "directory not found: {}",
            build_dir.display()
        )));
    }

    let include_patterns = vec![args.include.clone()];
    let include = compile_glob_set(&include_patterns, "--include")?;
    let exclude = compile_glob_set(&args.exclude, "--exclude")?;
    let repo = resolve_repo_name(args.repo.as_deref(), root)?;
    let git_sha = resolve_git_sha(args.git_sha.as_deref(), root)?;
    let maps = collect_source_maps(&build_dir, &include, &exclude, args.strip_path)?;

    if maps.is_empty() {
        return Err(UploadSourceMapsError::Validation(format!(
            "no .map files found in {} (did the build step run?)",
            build_dir.display()
        )));
    }

    if args.dry_run {
        print_dry_run(&repo, &git_sha, args.endpoint.as_deref(), &maps);
        return Ok(());
    }

    let api_key = resolve_api_key()?;
    upload_maps(args, &repo, &git_sha, &api_key, &maps)
}

fn resolve_build_dir(root: &Path, dir: &Path) -> PathBuf {
    if dir.is_absolute() {
        dir.to_path_buf()
    } else {
        root.join(dir)
    }
}

fn compile_glob_set(patterns: &[String], flag: &str) -> Result<GlobSet, UploadSourceMapsError> {
    let mut builder = GlobSetBuilder::new();
    for pattern in patterns {
        let glob = Glob::new(pattern).map_err(|err| {
            UploadSourceMapsError::Validation(format!("invalid {flag} '{pattern}': {err}"))
        })?;
        builder.add(glob);
        if let Some(without_prefix) = pattern.strip_prefix("**/") {
            builder.add(Glob::new(without_prefix).map_err(|err| {
                UploadSourceMapsError::Validation(format!("invalid {flag} '{pattern}': {err}"))
            })?);
        }
    }
    builder.build().map_err(|err| {
        UploadSourceMapsError::Validation(format!("failed to compile {flag}: {err}"))
    })
}

fn resolve_repo_name(explicit: Option<&str>, root: &Path) -> Result<String, UploadSourceMapsError> {
    if let Some(repo) = explicit {
        return validate_repo_name(repo.trim()).map(str::to_owned);
    }
    if let Some(repo) = package_json_repository_name(root) {
        return validate_repo_name(&repo).map(str::to_owned);
    }
    if let Some(repo) = git_origin_repo_name(root) {
        return validate_repo_name(&repo).map(str::to_owned);
    }
    Err(UploadSourceMapsError::Validation(
        "unable to determine repo name; pass --repo".to_owned(),
    ))
}

fn package_json_repository_name(root: &Path) -> Option<String> {
    let package_json = std::fs::read_to_string(root.join("package.json")).ok()?;
    let value: serde_json::Value = serde_json::from_str(&package_json).ok()?;
    let repository = value.get("repository")?;
    let url = match repository {
        serde_json::Value::String(url) => url.as_str(),
        serde_json::Value::Object(map) => map.get("url")?.as_str()?,
        _ => return None,
    };
    parse_repo_name_from_url(url)
}

fn git_origin_repo_name(root: &Path) -> Option<String> {
    let mut command = Command::new("git");
    command
        .args(["remote", "get-url", "origin"])
        .current_dir(root);
    clear_ambient_git_env(&mut command);
    let output = command.output().ok()?;
    if !output.status.success() {
        return None;
    }
    parse_repo_name_from_url(String::from_utf8_lossy(&output.stdout).trim())
}

fn parse_repo_name_from_url(url: &str) -> Option<String> {
    let trimmed = url.trim().trim_end_matches(".git").trim_end_matches('/');
    let last = trimmed
        .rsplit(['/', ':'])
        .find(|segment| !segment.trim().is_empty())?;
    let repo = last.trim();
    if repo.is_empty() {
        None
    } else {
        Some(repo.to_owned())
    }
}

fn validate_repo_name(repo: &str) -> Result<&str, UploadSourceMapsError> {
    if repo.is_empty() {
        return Err(UploadSourceMapsError::Validation(
            "unable to determine repo name; pass --repo".to_owned(),
        ));
    }
    if repo.contains("..") || repo.contains('\\') {
        return Err(UploadSourceMapsError::Validation(
            "repo name must not contain '..' or backslashes".to_owned(),
        ));
    }
    Ok(repo)
}

fn resolve_git_sha(explicit: Option<&str>, root: &Path) -> Result<String, UploadSourceMapsError> {
    let sha = if let Some(sha) = explicit {
        sha.trim().to_owned()
    } else if let Some(sha) = env_non_empty("GITHUB_SHA")
        .or_else(|| env_non_empty("CI_COMMIT_SHA"))
        .or_else(|| env_non_empty("COMMIT_SHA"))
    {
        sha
    } else {
        let mut command = Command::new("git");
        command.args(["rev-parse", "HEAD"]).current_dir(root);
        clear_ambient_git_env(&mut command);
        let output = command.output().map_err(|_| {
            UploadSourceMapsError::Validation(
                "unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
            )
        })?;
        if !output.status.success() {
            return Err(UploadSourceMapsError::Validation(
                "unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
            ));
        }
        String::from_utf8_lossy(&output.stdout).trim().to_owned()
    };
    validate_git_sha(&sha)?;
    Ok(sha)
}

fn env_non_empty(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .map(|value| value.trim().to_owned())
        .filter(|value| !value.is_empty())
}

fn validate_git_sha(sha: &str) -> Result<(), UploadSourceMapsError> {
    if !(7..=40).contains(&sha.len()) || !sha.chars().all(|ch| ch.is_ascii_hexdigit()) {
        return Err(UploadSourceMapsError::Validation(
            "unable to determine git SHA; pass --git-sha or set $GITHUB_SHA".to_owned(),
        ));
    }
    Ok(())
}

#[derive(Debug, Clone)]
struct SourceMapCandidate {
    path: PathBuf,
    rel_path: PathBuf,
    file_name: String,
    bytes: u64,
}

fn collect_source_maps(
    dir: &Path,
    include: &GlobSet,
    exclude: &GlobSet,
    strip_path: bool,
) -> Result<Vec<SourceMapCandidate>, UploadSourceMapsError> {
    let mut maps = Vec::new();
    collect_source_maps_inner(dir, dir, include, exclude, strip_path, &mut maps)?;
    maps.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
    Ok(maps)
}

fn collect_source_maps_inner(
    root: &Path,
    dir: &Path,
    include: &GlobSet,
    exclude: &GlobSet,
    strip_path: bool,
    maps: &mut Vec<SourceMapCandidate>,
) -> Result<(), UploadSourceMapsError> {
    let entries = std::fs::read_dir(dir).map_err(|err| {
        UploadSourceMapsError::Validation(format!("failed to read {}: {err}", dir.display()))
    })?;
    for entry in entries {
        let entry = entry.map_err(|err| {
            UploadSourceMapsError::Validation(format!("failed to read {}: {err}", dir.display()))
        })?;
        let path = entry.path();
        let file_type = entry.file_type().map_err(|err| {
            UploadSourceMapsError::Validation(format!("failed to stat {}: {err}", path.display()))
        })?;
        let rel_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
        if exclude.is_match(&rel_path) {
            continue;
        }
        if file_type.is_dir() {
            collect_source_maps_inner(root, &path, include, exclude, strip_path, maps)?;
            continue;
        }
        if !include.is_match(&rel_path) || !path.is_file() {
            continue;
        }
        let bytes = entry.metadata().map_or(0, |metadata| metadata.len());
        let file_name = map_file_name(&rel_path, strip_path)?;
        maps.push(SourceMapCandidate {
            path,
            rel_path,
            file_name,
            bytes,
        });
    }
    Ok(())
}

fn map_file_name(rel_path: &Path, strip_path: bool) -> Result<String, UploadSourceMapsError> {
    let value = if strip_path {
        rel_path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or_default()
            .to_owned()
    } else {
        to_posix_string(rel_path)
    };
    validate_file_name(&value)?;
    Ok(value)
}

fn validate_file_name(file_name: &str) -> Result<(), UploadSourceMapsError> {
    if file_name.is_empty()
        || file_name.len() > 255
        || file_name.starts_with('/')
        || file_name.contains('\\')
        || file_name
            .split('/')
            .any(|segment| segment.is_empty() || segment == "." || segment == "..")
    {
        return Err(UploadSourceMapsError::Validation(format!(
            "invalid source map fileName '{file_name}'"
        )));
    }
    Ok(())
}

fn to_posix_string(path: &Path) -> String {
    path.to_string_lossy().replace('\\', "/")
}

fn resolve_api_key() -> Result<String, UploadSourceMapsError> {
    env_non_empty("FALLOW_API_KEY")
        .ok_or_else(|| UploadSourceMapsError::Validation("FALLOW_API_KEY is required".to_owned()))
}

#[derive(Debug, Clone)]
struct PreparedSourceMap {
    candidate: SourceMapCandidate,
    source_map: serde_json::Value,
}

fn prepare_source_map(candidate: &SourceMapCandidate) -> MapOutcome {
    if candidate.bytes > MAX_MAP_BYTES {
        return MapOutcome::failed(
            candidate,
            format!(
                "source map is too large ({}); maximum is {}",
                format_bytes(candidate.bytes),
                format_bytes(MAX_MAP_BYTES)
            ),
        );
    }
    match std::fs::read_to_string(&candidate.path) {
        Ok(content) => match serde_json::from_str::<serde_json::Value>(&content) {
            Ok(source_map) => MapOutcome::Ready(PreparedSourceMap {
                candidate: candidate.clone(),
                source_map,
            }),
            Err(err) => MapOutcome::failed(candidate, format!("not valid JSON ({err}); skipping")),
        },
        Err(err) => MapOutcome::failed(candidate, format!("read failed: {err}")),
    }
}

fn upload_maps(
    args: &UploadSourceMapsArgs,
    repo: &str,
    git_sha: &str,
    api_key: &str,
    maps: &[SourceMapCandidate],
) -> Result<(), UploadSourceMapsError> {
    let mut outcomes = Vec::with_capacity(maps.len());
    let mut ready = Vec::new();
    for candidate in maps {
        if candidate.bytes > WARN_MAP_BYTES && candidate.bytes <= MAX_MAP_BYTES {
            eprintln!(
                "{LOG_PREFIX}: {}: {} is large ({})",
                "warning".yellow().bold(),
                candidate.rel_path.display(),
                format_bytes(candidate.bytes),
            );
        }
        match prepare_source_map(candidate) {
            MapOutcome::Ready(prepared) => ready.push(prepared),
            failed => {
                outcomes.push(failed);
                if args.fail_fast {
                    return Err(UploadSourceMapsError::Partial(outcomes));
                }
            }
        }
    }

    if ready.is_empty() {
        return Err(UploadSourceMapsError::Partial(outcomes));
    }

    println!("{LOG_PREFIX}: repo={repo} sha={git_sha}");
    println!(
        "{LOG_PREFIX}: found {} maps ({})",
        maps.len(),
        format_bytes(maps.iter().map(|map| map.bytes).sum())
    );
    println!(
        "{LOG_PREFIX}: uploading to {}",
        display_endpoint_url(args.endpoint.as_deref(), repo)
    );

    let concurrency = args.concurrency.max(1);
    let pool = rayon::ThreadPoolBuilder::new()
        .num_threads(concurrency)
        .build()
        .map_err(|err| {
            UploadSourceMapsError::Validation(format!("invalid --concurrency: {err}"))
        })?;
    let mut uploaded = if args.fail_fast {
        let mut uploaded = Vec::new();
        for map in &ready {
            let outcome = upload_one(args.endpoint.as_deref(), repo, git_sha, api_key, map);
            let failed = matches!(outcome, MapOutcome::Failed { .. });
            uploaded.push(outcome);
            if failed {
                break;
            }
        }
        uploaded
    } else {
        pool.install(|| {
            ready
                .par_iter()
                .map(|map| upload_one(args.endpoint.as_deref(), repo, git_sha, api_key, map))
                .collect::<Vec<_>>()
        })
    };
    outcomes.append(&mut uploaded);

    let success_count = outcomes
        .iter()
        .filter(|outcome| outcome.is_success())
        .count();
    let failure_count = outcomes.len().saturating_sub(success_count);
    if failure_count > 0 {
        return Err(UploadSourceMapsError::Partial(outcomes));
    }

    println!(
        "{LOG_PREFIX}: {}/{} uploaded",
        success_count,
        outcomes.len()
    );
    Ok(())
}

fn upload_one(
    endpoint_override: Option<&str>,
    repo: &str,
    git_sha: &str,
    api_key: &str,
    map: &PreparedSourceMap,
) -> MapOutcome {
    for attempt in 1..=MAX_ATTEMPTS {
        match send_source_map(endpoint_override, repo, git_sha, api_key, map) {
            Ok(response) => {
                println!(
                    "  {} {} ({})",
                    "ok".green(),
                    map.candidate.file_name,
                    format_bytes(response.data.file_size),
                );
                return MapOutcome::Success;
            }
            Err(err) if err.retryable && attempt < MAX_ATTEMPTS => {
                std::thread::sleep(Duration::from_millis(100 * u64::from(attempt)));
            }
            Err(err) => {
                return MapOutcome::failed(&map.candidate, err.message);
            }
        }
    }
    MapOutcome::failed(&map.candidate, "upload failed after retries".to_owned())
}

#[derive(Debug, Serialize)]
struct SourceMapRequest<'a> {
    #[serde(rename = "gitSha")]
    git_sha: &'a str,
    #[serde(rename = "fileName")]
    file_name: &'a str,
    #[serde(rename = "sourceMap")]
    source_map: &'a serde_json::Value,
}

#[derive(Debug, Deserialize)]
struct SourceMapUploadEnvelope {
    data: SourceMapUploadData,
}

#[derive(Debug, Deserialize)]
struct SourceMapUploadData {
    #[serde(rename = "fileSize")]
    file_size: u64,
}

#[derive(Debug)]
struct UploadAttemptError {
    message: String,
    retryable: bool,
}

fn send_source_map(
    endpoint_override: Option<&str>,
    repo: &str,
    git_sha: &str,
    api_key: &str,
    map: &PreparedSourceMap,
) -> Result<SourceMapUploadEnvelope, UploadAttemptError> {
    let url = endpoint_url(endpoint_override, repo);
    let payload = SourceMapRequest {
        git_sha,
        file_name: &map.candidate.file_name,
        source_map: &map.source_map,
    };
    let mut response = api_agent_with_timeout(CONNECT_TIMEOUT_SECS, TOTAL_TIMEOUT_SECS)
        .post(&url)
        .header("Authorization", &format!("Bearer {api_key}"))
        .send_json(&payload)
        .map_err(|err| UploadAttemptError {
            message: format!("network error: {err}"),
            retryable: true,
        })?;

    let status = response.status().as_u16();
    if matches!(status, 200 | 201) {
        return response
            .read_json::<SourceMapUploadEnvelope>()
            .map_err(|err| UploadAttemptError {
                message: format!("malformed response body: {err}"),
                retryable: false,
            });
    }

    let body = response.body_mut().read_to_string().unwrap_or_default();
    Err(UploadAttemptError {
        message: classify_http_error(status, &body),
        retryable: matches!(status, 429 | 500..=599),
    })
}

fn endpoint_url(override_endpoint: Option<&str>, repo: &str) -> String {
    let path = format!("/v1/coverage/{}/source-maps", url_encode_path_segment(repo));
    match override_endpoint {
        Some(base) => format!("{}{path}", base.trim().trim_end_matches('/')),
        None => api_url(&path),
    }
}

fn display_endpoint_url(override_endpoint: Option<&str>, repo: &str) -> String {
    let base = override_endpoint.map_or_else(
        || {
            std::env::var("FALLOW_API_URL")
                .ok()
                .filter(|value| !value.trim().is_empty())
                .map_or_else(
                    || DEFAULT_ENDPOINT.to_owned(),
                    |value| value.trim().trim_end_matches('/').to_owned(),
                )
        },
        |value| value.trim().trim_end_matches('/').to_owned(),
    );
    format!(
        "{base}/v1/coverage/{}/source-maps",
        url_encode_path_segment(repo)
    )
}

fn url_encode_path_segment(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for byte in value.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
                out.push(byte as char);
            }
            _ => {
                write!(out, "%{byte:02X}").expect("writing to String never fails");
            }
        }
    }
    out
}

fn classify_http_error(status: u16, body: &str) -> String {
    let envelope: Option<ErrorEnvelope> = serde_json::from_str(body).ok();
    match status {
        401 | 403 => "authentication failed: invalid or expired API key".to_owned(),
        429 => "rate limited; retry with fewer concurrent uploads via --concurrency".to_owned(),
        500..=599 => {
            let suffix = response_message_suffix(body, envelope.as_ref());
            format!("server error: {status}{suffix}")
        }
        _ => {
            let suffix = response_message_suffix(body, envelope.as_ref());
            format!("server rejected: HTTP {status}{suffix}")
        }
    }
}

#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
    #[serde(default)]
    message: Option<String>,
}

fn response_message_suffix(body: &str, envelope: Option<&ErrorEnvelope>) -> String {
    if let Some(message) = envelope.and_then(|envelope| envelope.message.as_deref())
        && !message.trim().is_empty()
    {
        return format!(" {}", message.trim());
    }
    if !body.trim().is_empty() {
        return format!(" {}", body.trim());
    }
    String::new()
}

#[derive(Debug, Clone)]
enum MapOutcome {
    Ready(PreparedSourceMap),
    Success,
    Failed { file_name: String, reason: String },
}

impl MapOutcome {
    fn failed(candidate: &SourceMapCandidate, reason: String) -> Self {
        Self::Failed {
            file_name: candidate.file_name.clone(),
            reason,
        }
    }

    const fn is_success(&self) -> bool {
        matches!(self, Self::Success)
    }
}

fn print_failure_summary(outcomes: &[MapOutcome]) {
    let total = outcomes.len();
    let success_count = outcomes
        .iter()
        .filter(|outcome| outcome.is_success())
        .count();
    eprintln!("{LOG_PREFIX}: {success_count}/{total} uploaded");
    eprintln!("{LOG_PREFIX}: failed:");
    for outcome in outcomes {
        if let MapOutcome::Failed { file_name, reason } = outcome {
            eprintln!("  {} {file_name} ({reason})", "x".red());
        }
    }
    eprintln!("{LOG_PREFIX}: re-run to retry failed uploads");
}

fn print_dry_run(
    repo: &str,
    git_sha: &str,
    endpoint_override: Option<&str>,
    maps: &[SourceMapCandidate],
) {
    let total_bytes: u64 = maps.iter().map(|map| map.bytes).sum();
    println!("{LOG_PREFIX}: repo={repo} sha={git_sha}");
    println!(
        "{LOG_PREFIX}: would upload {} maps ({}) to {}",
        maps.len(),
        format_bytes(total_bytes),
        display_endpoint_url(endpoint_override, repo)
    );
    for map in maps.iter().take(20) {
        println!(
            "  - {} ({}) -> fileName={}",
            map.rel_path.display(),
            format_bytes(map.bytes),
            map.file_name
        );
    }
    if maps.len() > 20 {
        println!("  ... and {} more", maps.len() - 20);
    }
    println!("{LOG_PREFIX}: dry run, no uploads performed");
}

#[expect(
    clippy::cast_precision_loss,
    reason = "source map byte sizes are well under f64 precision loss range"
)]
fn format_bytes(bytes: u64) -> String {
    const KIB: u64 = 1024;
    const MIB: u64 = KIB * 1024;
    const GIB: u64 = MIB * 1024;
    if bytes >= GIB {
        format!("{:.1} GiB", bytes as f64 / GIB as f64)
    } else if bytes >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB as f64)
    } else if bytes >= KIB {
        format!("{:.0} KiB", bytes as f64 / KIB as f64)
    } else {
        format!("{bytes} B")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn parses_repo_name_from_common_urls() {
        assert_eq!(
            parse_repo_name_from_url("https://github.com/acme/widgets.git"),
            Some("widgets".to_owned())
        );
        assert_eq!(
            parse_repo_name_from_url("git@github.com:acme/widgets.git"),
            Some("widgets".to_owned())
        );
        assert_eq!(
            parse_repo_name_from_url("ssh://git@gitlab.com/acme/team/widgets"),
            Some("widgets".to_owned())
        );
    }

    #[test]
    fn validates_git_sha_like_server_schema() {
        assert!(validate_git_sha("abcdef1").is_ok());
        assert!(validate_git_sha("abcdef1234567890abcdef1234567890abcdef12").is_ok());
        assert!(validate_git_sha("abc").is_err());
        assert!(validate_git_sha("xyz1234").is_err());
        assert!(validate_git_sha("abcdef1234567890abcdef1234567890abcdef123").is_err());
    }

    #[test]
    fn map_file_name_strips_path_by_default() {
        assert_eq!(
            map_file_name(Path::new("assets/bundle-a1b2.js.map"), true).unwrap(),
            "bundle-a1b2.js.map"
        );
    }

    #[test]
    fn map_file_name_keeps_relative_path_when_requested() {
        assert_eq!(
            map_file_name(Path::new("assets/bundle.js.map"), false).unwrap(),
            "assets/bundle.js.map"
        );
    }

    #[test]
    fn file_name_rejects_traversal_and_backslashes() {
        assert!(validate_file_name("../bundle.js.map").is_err());
        assert!(validate_file_name("assets/../bundle.js.map").is_err());
        assert!(validate_file_name("assets\\bundle.js.map").is_err());
    }

    #[test]
    fn collect_source_maps_applies_include_exclude_and_file_name_mode() {
        let dir = tempdir().expect("tempdir");
        std::fs::create_dir_all(dir.path().join("assets")).expect("assets dir");
        std::fs::create_dir_all(dir.path().join("node_modules/pkg")).expect("node_modules dir");
        std::fs::write(dir.path().join("root.js.map"), "{}").expect("root map");
        std::fs::write(dir.path().join("assets/app.js.map"), "{}").expect("asset map");
        std::fs::write(dir.path().join("node_modules/pkg/vendor.js.map"), "{}")
            .expect("vendor map");

        let include = compile_glob_set(&["**/*.map".to_owned()], "--include").unwrap();
        let exclude = compile_glob_set(&["**/node_modules/**".to_owned()], "--exclude").unwrap();
        let maps = collect_source_maps(dir.path(), &include, &exclude, false).unwrap();

        let file_names: Vec<&str> = maps.iter().map(|map| map.file_name.as_str()).collect();
        assert_eq!(file_names, vec!["assets/app.js.map", "root.js.map"]);
    }

    #[test]
    fn endpoint_url_encodes_repo_as_one_segment() {
        assert_eq!(
            endpoint_url(Some("http://localhost:3000"), "owner/repo"),
            "http://localhost:3000/v1/coverage/owner%2Frepo/source-maps"
        );
    }

    #[test]
    fn classify_http_errors_matches_spec_messages() {
        assert_eq!(
            classify_http_error(401, ""),
            "authentication failed: invalid or expired API key"
        );
        assert_eq!(
            classify_http_error(429, ""),
            "rate limited; retry with fewer concurrent uploads via --concurrency"
        );
        assert!(classify_http_error(500, "oops").starts_with("server error: 500"));
    }
}
