// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025–present Iron Curtain contributors

use super::*;

/// Resolve the archive entry's output name and optional validated path.
///
/// Real filenames from `--names` are preserved when they are safe.  Unsafe or
/// malformed names fall back to a deterministic CRC-based filename so the
/// extraction still succeeds without trusting hostile path text.  The returned
/// `StrictPath` is only present when an untrusted metadata path survives.
pub(super) fn resolve_output_name(
    out_boundary: &PathBoundary,
    resolved_name: Option<&str>,
    crc: MixCrc,
    file_data: Option<&[u8]>,
) -> Result<(Option<StrictPath>, String, Option<String>), String> {
    if let Some(name) = resolved_name {
        match validate_candidate_path(out_boundary, name) {
            Ok(path) => return Ok((Some(path), name.to_string(), None)),
            Err(reason) => {
                let fallback = fallback_filename(crc, None, file_data);
                let warning = format!(
                    "refusing unsafe resolved name `{name}` for 0x{:08X}: {reason}; using `{fallback}`",
                    crc.to_raw()
                );
                return Ok((None, fallback, Some(warning)));
            }
        }
    }

    let fallback = fallback_filename(crc, None, file_data);
    Ok((None, fallback, None))
}

/// Validate one candidate relative path under the extraction boundary.
///
/// The path must stay inside `out_boundary` and must resolve to a file-like
/// path rather than the boundary root or a directory.
pub(super) fn validate_candidate_path(
    out_boundary: &PathBoundary,
    candidate: &str,
) -> Result<StrictPath, String> {
    let path = out_boundary
        .strict_join(candidate)
        .map_err(|e| e.to_string())?;

    if path.strictpath_file_name().is_none() {
        return Err("path resolves to the extraction root or a directory".to_string());
    }

    Ok(path)
}

/// Build a flat fallback output path under the extraction boundary.
///
/// The fallback filename is generated by this crate (`CRC.ext`), so it is not
/// treated as untrusted metadata.  Keep this path flat on purpose: if a future
/// change ever adds separators, fail loudly instead of silently widening the
/// trust boundary.
pub(super) fn generated_flat_output_path(
    out_boundary: &PathBoundary,
    generated_name: &str,
) -> Result<PathBuf, String> {
    if generated_name.contains('/') || generated_name.contains('\\') {
        return Err("generated fallback name must be a single flat filename".to_string());
    }

    Ok(PathBuf::from(out_boundary.interop_path()).join(generated_name))
}

pub(super) fn create_strict_output_file(path: &StrictPath) -> std::io::Result<std::fs::File> {
    path.create_parent_dir_all()?;
    std::fs::File::create(path.interop_path())
}

pub(super) fn create_generated_output_file(path: &Path) -> std::io::Result<std::fs::File> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::File::create(path)
}

/// Deterministic fallback filename for entries without a safe resolved path.
///
/// If `file_data` is provided, attempts format detection by content
/// inspection to assign a meaningful extension instead of `.bin`.
pub(super) fn fallback_filename(
    crc: MixCrc,
    resolved_name: Option<&str>,
    file_data: Option<&[u8]>,
) -> String {
    let ext = resolved_name
        .and_then(|name| Path::new(name).extension())
        .and_then(|ext| ext.to_str())
        .filter(|ext| !ext.is_empty())
        .map(|ext| ext.to_ascii_lowercase())
        .unwrap_or_else(|| {
            file_data
                .and_then(cnc_formats::sniff::sniff_format)
                .unwrap_or("bin")
                .to_string()
        });
    format!("{:08X}.{ext}", crc.to_raw())
}

/// Ensure one extraction run never writes two entries to the same path.
///
/// The preferred archive-derived name is used when available. If that name has
/// already been claimed by an earlier entry, the extractor falls back to a
/// deterministic flat filename and, if needed, appends a numeric suffix.
pub(super) fn make_output_name_unique(
    strict_path: Option<StrictPath>,
    relative_name: String,
    fallback_name: &str,
    used_names: &mut HashSet<String>,
) -> (Option<StrictPath>, String, Option<String>) {
    let key = relative_name.to_ascii_lowercase();
    if used_names.insert(key) {
        return (strict_path, relative_name, None);
    }

    let mut suffix = 2usize;
    let mut candidate = if relative_name != fallback_name {
        fallback_name.to_string()
    } else {
        suffixed_flat_name(fallback_name, suffix)
    };
    while !used_names.insert(candidate.to_ascii_lowercase()) {
        candidate = suffixed_flat_name(fallback_name, suffix);
        suffix = suffix.saturating_add(1);
    }

    let warning = format!("duplicate output name `{relative_name}`; using `{candidate}` instead");
    (None, candidate, Some(warning))
}

/// Insert a numeric suffix before the extension of a generated flat filename.
pub(super) fn suffixed_flat_name(base: &str, suffix: usize) -> String {
    let path = Path::new(base);
    let stem = path
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| base.to_string());
    let ext = path.extension().map(|e| e.to_string_lossy().into_owned());

    match ext {
        Some(ext) if !ext.is_empty() => format!("{stem}__{suffix}.{ext}"),
        _ => format!("{stem}__{suffix}"),
    }
}

/// Keep duplicate metadata-derived paths distinct without flattening them.
///
/// Archive formats with stored filenames (BIG, MEG) should preserve those
/// relative paths when safe. If an archive repeats the same logical path,
/// append a numeric suffix before the extension instead of overwriting the
/// earlier payload.
pub(super) fn make_stored_name_unique(
    relative_name: &str,
    used_names: &mut HashSet<String>,
) -> (String, Option<String>) {
    let key = relative_name.to_ascii_lowercase();
    if used_names.insert(key) {
        return (relative_name.to_string(), None);
    }

    let mut suffix = 2usize;
    let mut candidate = suffixed_relative_name(relative_name, suffix);
    while !used_names.insert(candidate.to_ascii_lowercase()) {
        suffix = suffix.saturating_add(1);
        candidate = suffixed_relative_name(relative_name, suffix);
    }

    let warning = format!("duplicate output name `{relative_name}`; using `{candidate}` instead");
    (candidate, Some(warning))
}

/// Insert a numeric suffix before the last path component's extension while
/// preserving parent directories.
fn suffixed_relative_name(base: &str, suffix: usize) -> String {
    let path = Path::new(base);
    let file_name = path
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| base.to_string());
    let suffixed = suffixed_flat_name(&file_name, suffix);

    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => {
            format!("{}/{}", parent.to_string_lossy(), suffixed)
        }
        _ => suffixed,
    }
}
