//! External FBX texture reference discovery and path normalization.

use std::collections::BTreeSet;
use std::fmt;
use std::path::{Component, Path};

/// One safe external texture reference declared by an FBX texture element.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FbxExternalTextureReference {
    pub texture_index: usize,
    pub element_id: u32,
    pub relative_path: String,
}

/// A texture reference that cannot be resolved inside the FBX asset source.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FbxTexturePathError {
    pub texture_index: usize,
    pub raw_path: String,
    pub reason: String,
}

/// Deterministic external texture discovery result, including safe and rejected references.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FbxExternalTexturePathReport {
    pub paths: Vec<String>,
    pub errors: Vec<FbxTexturePathError>,
}

impl fmt::Display for FbxTexturePathError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "texture {} path `{}` is unsafe: {}",
            self.texture_index, self.raw_path, self.reason
        )
    }
}

/// Returns the normalized external path for one texture, or `None` for embedded/no-file textures.
pub fn external_texture_reference(
    texture_index: usize,
    texture: &ufbx::Texture,
) -> Result<Option<FbxExternalTextureReference>, FbxTexturePathError> {
    if !texture.content.is_empty() {
        return Ok(None);
    }

    let Some(raw_path) = texture_path_candidate(texture) else {
        return Ok(None);
    };
    let relative_path = normalize_external_texture_path(raw_path).map_err(|reason| {
        FbxTexturePathError {
            texture_index,
            raw_path: raw_path.to_string(),
            reason,
        }
    })?;
    Ok(Some(FbxExternalTextureReference {
        texture_index,
        element_id: texture.element.element_id,
        relative_path,
    }))
}

/// Returns every distinct safe external texture path in deterministic order.
pub fn external_texture_paths(
    scene: &ufbx::Scene,
) -> Result<Vec<String>, Vec<FbxTexturePathError>> {
    let report = external_texture_path_report(scene);
    if report.errors.is_empty() {
        Ok(report.paths)
    } else {
        Err(report.errors)
    }
}

/// Discovers safe and rejected references without discarding either side of the result.
pub fn external_texture_path_report(scene: &ufbx::Scene) -> FbxExternalTexturePathReport {
    let mut paths = BTreeSet::new();
    let mut errors = Vec::new();
    for (texture_index, texture) in scene.textures.as_ref().iter().enumerate() {
        match external_texture_reference(texture_index, texture) {
            Ok(Some(reference)) => {
                paths.insert(reference.relative_path);
            }
            Ok(None) => {}
            Err(error) => errors.push(error),
        }
    }
    errors.sort_by(|left, right| {
        (left.texture_index, &left.raw_path).cmp(&(right.texture_index, &right.raw_path))
    });
    errors.dedup();
    FbxExternalTexturePathReport {
        paths: paths.into_iter().collect(),
        errors,
    }
}

fn texture_path_candidate(texture: &ufbx::Texture) -> Option<&str> {
    if !texture.relative_filename.is_empty() {
        return Some(texture.relative_filename.as_ref());
    }
    if !texture.filename.is_empty() {
        return Some(texture.filename.as_ref());
    }
    if !texture.absolute_filename.is_empty() {
        return Some(texture.absolute_filename.as_ref());
    }
    None
}

fn normalize_external_texture_path(raw_path: &str) -> Result<String, String> {
    let normalized = raw_path.trim().replace('\\', "/");
    if normalized.is_empty() {
        return Err("path is empty".into());
    }
    if normalized.contains('\0') {
        return Err("path contains a NUL byte".into());
    }
    if normalized.contains('#') || normalized.contains('?') || normalized.contains("://") {
        return Err("path contains asset-source, label, or query syntax".into());
    }

    let normalized = if is_absolute_like(&normalized) {
        extract_fbm_suffix(&normalized).ok_or_else(|| {
            "absolute paths are rejected unless they identify a portable .fbm sidecar".to_string()
        })?
    } else {
        normalized
    };

    let mut components = Vec::new();
    for component in Path::new(&normalized).components() {
        match component {
            Component::Normal(segment) => {
                let segment = segment.to_str().ok_or_else(|| "path is not UTF-8".to_string())?;
                if segment.contains(':') {
                    return Err("path contains a platform or asset-source prefix".into());
                }
                components.push(segment);
            }
            Component::CurDir => {}
            Component::ParentDir => return Err("parent traversal is not allowed".into()),
            Component::RootDir | Component::Prefix(_) => {
                return Err("absolute paths are not allowed".into());
            }
        }
    }
    if components.is_empty() {
        return Err("path does not identify a file".into());
    }
    Ok(components.join("/"))
}

fn is_absolute_like(path: &str) -> bool {
    path.starts_with('/')
        || path.starts_with("//")
        || path.as_bytes().get(1).is_some_and(|byte| *byte == b':')
}

fn extract_fbm_suffix(path: &str) -> Option<String> {
    let lower = path.to_ascii_lowercase();
    let fbm_end = lower.rfind(".fbm/")? + ".fbm/".len();
    let fbm_start = path[..fbm_end - 1]
        .rfind('/')
        .map(|index| index + 1)
        .unwrap_or(0);
    Some(path[fbm_start..].to_string())
}

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

    #[test]
    fn sibling_texture_folder_is_preserved() {
        assert_eq!(
            normalize_external_texture_path("textures\\chair_diff.jpg").unwrap(),
            "textures/chair_diff.jpg"
        );
    }

    #[test]
    fn absolute_fbm_path_is_portably_rebased() {
        assert_eq!(
            normalize_external_texture_path("C:\\Build\\Chair.fbm\\chair_diff.jpg").unwrap(),
            "Chair.fbm/chair_diff.jpg"
        );
    }

    #[test]
    fn absolute_non_sidecar_path_is_rejected() {
        let error = normalize_external_texture_path("C:\\Build\\textures\\chair_diff.jpg")
            .unwrap_err();
        assert!(error.contains("absolute paths are rejected"));
    }

    #[test]
    fn parent_traversal_is_rejected() {
        let error = normalize_external_texture_path("../shared/chair_diff.jpg").unwrap_err();
        assert!(error.contains("parent traversal"));
    }

    #[test]
    fn asset_path_syntax_is_rejected() {
        assert!(normalize_external_texture_path("remote://chair_diff.jpg").is_err());
        assert!(normalize_external_texture_path("textures/chair.jpg#Image0").is_err());
    }
}
