//! Shared helpers for searching the RunMat filesystem search path.
//!
//! The `exist`, `which`, and related REPL-facing builtins all need the same
//! path canonicalisation and package-aware lookup logic. This module hosts
//! reusable routines so each builtin can focus on user-facing semantics while
//! sharing the platform-specific details for locating files, class folders,
//! and packages.

use runmat_filesystem as vfs;
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};

use super::fs::expand_user_path;
use super::path_state::current_path_segments;

/// File extensions that identify compiled MEX binaries.
pub const MEX_EXTENSIONS: &[&str] = &[
    ".mexw64",
    ".mexmaci64",
    ".mexa64",
    ".mexglx",
    ".mexw32",
    ".mexmaci",
    ".mex",
];

/// File extensions that identify MATLAB P-code artefacts.
pub const PCODE_EXTENSIONS: &[&str] = &[".p", ".pp"];

/// File extensions that identify Simulink models.
pub const SIMULINK_EXTENSIONS: &[&str] = &[".slx", ".mdl"];

/// File extensions that identify thunk libraries generated by MATLAB.
pub const THUNK_EXTENSIONS: &[&str] = &[".thunk"];

/// File extensions that identify native libraries.
pub const LIB_EXTENSIONS: &[&str] = &[".dll", ".so", ".dylib", ".lib", ".a"];

/// File extensions that should be considered when searching for class
/// definitions implemented in MATLAB source files.
pub const CLASS_M_FILE_EXTENSIONS: &[&str] = &[".m"];

/// General-purpose extensions searched by MATLAB/RunMat when resolving
/// scripts and functions on the search path.
pub const GENERAL_FILE_EXTENSIONS: &[&str] = &[
    ".m",
    ".mlx",
    ".mlapp",
    ".mltbx",
    ".mlappinstall",
    ".mat",
    ".fig",
    ".txt",
    ".csv",
    ".json",
    ".xml",
    ".dat",
    ".bin",
    ".h",
    ".hpp",
    ".c",
    ".cc",
    ".cpp",
    ".cxx",
    ".py",
    ".sh",
    ".bat",
    "",
];

/// Known file extensions used to heuristically decide whether a symbol is
/// likely a file path instead of a bare function name.
pub const KNOWN_FILE_EXTENSIONS: &[&str] = &[
    "m",
    "mlx",
    "mlapp",
    "mat",
    "mex",
    "mexw64",
    "mexmaci64",
    "mexa64",
    "mexglx",
    "mexw32",
    "mexmaci",
    "p",
    "pp",
    "slx",
    "mdl",
    "mltbx",
    "mlappinstall",
    "fig",
    "txt",
    "csv",
    "json",
    "xml",
    "dat",
    "bin",
    "dll",
    "so",
    "dylib",
    "lib",
    "a",
    "thunk",
    "h",
    "hpp",
    "c",
    "cc",
    "cpp",
    "cxx",
    "py",
    "sh",
    "bat",
];

/// Return the ordered list of directories searched when resolving MATLAB
/// functions. The list begins with the current working directory followed by
/// entries from `RUNMAT_PATH` and `MATLABPATH`. Duplicates are removed while
/// preserving the first occurrence.
pub fn search_directories(error_prefix: &str) -> Result<Vec<PathBuf>, String> {
    let mut dirs = Vec::new();
    let mut seen = HashSet::new();

    if let Ok(cwd) = vfs::current_dir() {
        push_unique_dir(&mut dirs, &mut seen, cwd);
    } else {
        return Err(format!(
            "{error_prefix}: unable to determine current directory"
        ));
    }

    for entry in current_path_segments() {
        let expanded = expand_user_path(&entry, error_prefix)?;
        push_unique_dir(&mut dirs, &mut seen, PathBuf::from(expanded));
    }

    Ok(dirs)
}

/// Split a potentially package-qualified name into the package components and
/// the base symbol name.
pub fn split_package_components(name: &str) -> (Vec<String>, String) {
    if name.is_empty() {
        return (Vec::new(), String::new());
    }
    let mut parts: Vec<&str> = name.split('.').collect();
    if parts.len() == 1 {
        return (Vec::new(), parts[0].to_string());
    }
    let base = parts.pop().unwrap_or_default().to_string();
    let packages = parts.into_iter().map(|p| p.to_string()).collect();
    (packages, base)
}

/// Convert package components into the MATLAB directory structure (`+pkg`).
pub fn packages_to_path(packages: &[String]) -> PathBuf {
    let mut path = PathBuf::new();
    for pkg in packages {
        path.push(format!("+{}", pkg));
    }
    path
}

/// Return `true` when the supplied name should be treated as a filesystem path
/// instead of a bare MATLAB symbol.
pub fn should_treat_as_path(name: &str) -> bool {
    if name.starts_with('~')
        || name.starts_with('@')
        || name.starts_with('+')
        || name.contains('/')
        || name.contains('\\')
    {
        return true;
    }
    if cfg!(windows) && has_windows_drive_prefix(name) {
        return true;
    }
    is_probable_filename(name)
}

fn has_windows_drive_prefix(name: &str) -> bool {
    let bytes = name.as_bytes();
    if bytes.len() < 2 {
        return false;
    }
    bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}

fn is_probable_filename(name: &str) -> bool {
    if let Some(dot) = name.rfind('.') {
        let ext = &name[dot + 1..];
        let lowered = ext.to_ascii_lowercase();
        KNOWN_FILE_EXTENSIONS.contains(&lowered.as_str())
    } else {
        false
    }
}

/// Return candidate paths for the provided name using the supplied list of
/// file extensions. The candidates are not filtered for existence.
pub fn file_candidates(
    name: &str,
    extensions: &[&str],
    error_prefix: &str,
) -> Result<Vec<PathBuf>, String> {
    if should_treat_as_path(name) {
        collect_direct_file_candidates(name, extensions, error_prefix)
    } else {
        collect_package_file_candidates(name, extensions, error_prefix)
    }
}

fn collect_direct_file_candidates(
    name: &str,
    extensions: &[&str],
    error_prefix: &str,
) -> Result<Vec<PathBuf>, String> {
    let expanded = expand_user_path(name, error_prefix)?;
    let base = PathBuf::from(&expanded);
    let mut candidates = vec![base.clone()];
    if base.extension().is_none() {
        for &ext in extensions {
            if ext.is_empty() {
                continue;
            }
            candidates.push(append_extension(&base, ext));
        }
    }
    Ok(candidates)
}

fn collect_package_file_candidates(
    name: &str,
    extensions: &[&str],
    error_prefix: &str,
) -> Result<Vec<PathBuf>, String> {
    let (packages, base_name) = split_package_components(name);
    let prefix = packages_to_path(&packages);
    let mut candidates = Vec::new();

    for dir in search_directories(error_prefix)? {
        let mut root = dir.clone();
        if !prefix.as_os_str().is_empty() {
            root.push(&prefix);
        }
        let base_path = root.join(&base_name);
        push_unique(&mut candidates, base_path.clone());
        for &ext in extensions {
            if ext.is_empty() {
                continue;
            }
            push_unique(&mut candidates, append_extension(&base_path, ext));
        }
    }

    Ok(candidates)
}

fn append_extension(path: &Path, ext: &str) -> PathBuf {
    if ext.is_empty() {
        return path.to_path_buf();
    }
    let mut os: OsString = path.as_os_str().to_os_string();
    os.push(ext);
    PathBuf::from(os)
}

/// Return the first existing file for a symbol given the list of candidate
/// extensions.
pub async fn find_file_with_extensions(
    name: &str,
    extensions: &[&str],
    error_prefix: &str,
) -> Result<Option<PathBuf>, String> {
    let candidates = file_candidates(name, extensions, error_prefix)?;
    for path in candidates {
        if path_is_file(&path).await {
            return Ok(Some(path));
        }
    }
    Ok(None)
}

/// Return all existing files for a symbol given the list of candidate
/// extensions. Duplicates are removed while preserving discovery order.
pub async fn find_all_files_with_extensions(
    name: &str,
    extensions: &[&str],
    error_prefix: &str,
) -> Result<Vec<PathBuf>, String> {
    let mut matches = Vec::new();
    let mut seen = HashSet::new();
    for path in file_candidates(name, extensions, error_prefix)? {
        if path_is_file(&path).await && seen.insert(path.clone()) {
            matches.push(path);
        }
    }
    Ok(matches)
}

/// Return the list of candidate directories that could correspond to `name`.
pub fn directory_candidates(name: &str, error_prefix: &str) -> Result<Vec<PathBuf>, String> {
    if should_treat_as_path(name) {
        let expanded = expand_user_path(name, error_prefix)?;
        return Ok(vec![PathBuf::from(expanded)]);
    }
    let (packages, base) = split_package_components(name);
    let prefix = packages_to_path(&packages);
    let mut candidates = Vec::new();
    for dir in search_directories(error_prefix)? {
        let mut path = dir.clone();
        if !prefix.as_os_str().is_empty() {
            path.push(&prefix);
        }
        path.push(&base);
        push_unique(&mut candidates, path);
    }
    Ok(candidates)
}

/// Return candidate class folders (`@ClassName`) for the provided symbol.
pub fn class_folder_candidates(name: &str, error_prefix: &str) -> Result<Vec<PathBuf>, String> {
    if should_treat_as_path(name) {
        let expanded = expand_user_path(name, error_prefix)?;
        return Ok(vec![PathBuf::from(expanded)]);
    }
    let (packages, class_name) = split_package_components(name);
    let prefix = packages_to_path(&packages);
    let mut candidates = Vec::new();
    for dir in search_directories(error_prefix)? {
        let mut path = dir.clone();
        if !prefix.as_os_str().is_empty() {
            path.push(&prefix);
        }
        path.push(format!("@{}", class_name));
        push_unique(&mut candidates, path);
    }
    Ok(candidates)
}

/// Return `true` when a MATLAB class definition exists in a file and contains
/// the specified keyword (typically `classdef`).
pub async fn class_file_exists(
    name: &str,
    class_extensions: &[&str],
    keyword: &str,
    error_prefix: &str,
) -> Result<bool, String> {
    if let Some(path) = find_file_with_extensions(name, class_extensions, error_prefix).await? {
        if file_contains_keyword(&path, keyword).await {
            return Ok(true);
        }
    }
    Ok(false)
}

/// Return every class definition file that contains the required keyword.
pub async fn class_file_paths(
    name: &str,
    class_extensions: &[&str],
    keyword: &str,
    error_prefix: &str,
) -> Result<Vec<PathBuf>, String> {
    let mut matches = Vec::new();
    for path in find_all_files_with_extensions(name, class_extensions, error_prefix).await? {
        if file_contains_keyword(&path, keyword).await {
            matches.push(path);
        }
    }
    Ok(matches)
}

async fn file_contains_keyword(path: &Path, keyword: &str) -> bool {
    const MAX_BYTES: usize = 64 * 1024;
    match vfs::read_async(path).await {
        Ok(mut buffer) => {
            if buffer.len() > MAX_BYTES {
                buffer.truncate(MAX_BYTES);
            }
            let text = String::from_utf8_lossy(&buffer);
            text.to_ascii_lowercase()
                .contains(&keyword.to_ascii_lowercase())
        }
        Err(_) => false,
    }
}

fn push_unique<T: Eq + std::hash::Hash + Clone>(vec: &mut Vec<T>, value: T) {
    if !vec.contains(&value) {
        vec.push(value);
    }
}

fn push_unique_dir(vec: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>, value: PathBuf) {
    if seen.insert(value.clone()) {
        vec.push(value);
    }
}

pub async fn path_is_file(path: &Path) -> bool {
    vfs::metadata_async(path)
        .await
        .map(|meta| meta.is_file())
        .unwrap_or(false)
}

pub async fn path_is_directory(path: &Path) -> bool {
    vfs::metadata_async(path)
        .await
        .map(|meta| meta.is_dir())
        .unwrap_or(false)
}
