pub use memory_fs::MemoryFileSystem;

#[cfg(all(feature = "testing", feature = "os"))]
pub use os::testing::UserConfigDirectoryOverrideGuard;

#[cfg(feature = "os")]
pub use os::OsSystem;

use filetime::FileTime;
use ruff_notebook::{Notebook, NotebookError};
use ruff_python_ast::PySourceType;
use std::error::Error;
use std::fmt;
use std::fmt::Debug;
pub use test::{DbWithTestSystem, DbWithWritableSystem, InMemorySystem, TestSystem};
use walk_directory::WalkDirectoryBuilder;

pub use self::path::{
    DeduplicatedNestedPathsIter, SystemPath, SystemPathBuf, SystemVirtualPath,
    SystemVirtualPathBuf, deduplicate_nested_paths,
};
use crate::file_revision::FileRevision;

mod memory_fs;
#[cfg(feature = "os")]
mod os;
mod path;
mod test;
pub mod walk_directory;

pub type Result<T> = std::io::Result<T>;
pub type WhichResult = std::result::Result<SystemPathBuf, WhichError>;

/// The system on which Ruff runs.
///
/// Ruff supports running on the CLI, in a language server, and in a browser (WASM). Each of these
/// host-systems differ in what system operations they support and how they interact with the file system:
/// * Language server:
///    * Reading a file's content should take into account that it might have unsaved changes because it's open in the editor.
///    * Use structured representations for notebooks, making deserializing a notebook from a string unnecessary.
///    * Use their own file watching infrastructure.
/// * WASM (Browser):
///    * There are ways to emulate a file system in WASM but a native memory-filesystem is more efficient.
///    * Doesn't support a current working directory
///    * File watching isn't supported.
///
/// Abstracting the system also enables tests to use a more efficient in-memory file system.
pub trait System: Debug + Sync + Send {
    /// Reads the metadata of the file or directory at `path`.
    ///
    /// This function will traverse symbolic links to query information about the destination file.
    fn path_metadata(&self, path: &SystemPath) -> Result<Metadata>;

    /// Returns the canonical, absolute form of a path with all intermediate components normalized
    /// and symbolic links resolved.
    ///
    /// # Errors
    /// This function will return an error in the following situations, but is not limited to just these cases:
    /// * `path` does not exist.
    /// * A non-final component in `path` is not a directory.
    /// * the symlink target path is not valid Unicode.
    ///
    /// ## Windows long-paths
    /// Unlike `std::fs::canonicalize`, this function does remove UNC prefixes if possible.
    /// See [dunce::canonicalize] for more information.
    fn canonicalize_path(&self, path: &SystemPath) -> Result<SystemPathBuf>;

    /// Returns `true` if both paths refer to the same file.
    fn is_same_file(&self, first: &SystemPath, second: &SystemPath) -> Result<bool>;

    /// Returns the source type for `path` if known or `None`.
    ///
    /// The default is to always return `None`, assuming the system
    /// has no additional information and that the caller should
    /// rely on the file extension instead.
    ///
    /// This is primarily used for the LSP integration to respect
    /// the chosen language (or the fact that it is a notebook) in
    /// the editor.
    fn source_type(&self, path: &SystemPath) -> Option<PySourceType> {
        let _ = path;
        None
    }

    /// Returns the source type for `path` if known or `None`.
    ///
    /// The default is to always return `None`, assuming the system
    /// has no additional information and that the caller should
    /// rely on the file extension instead.
    ///
    /// This is primarily used for the LSP integration to respect
    /// the chosen language (or the fact that it is a notebook) in
    /// the editor.
    fn virtual_path_source_type(&self, path: &SystemVirtualPath) -> Option<PySourceType> {
        let _ = path;

        None
    }

    /// Find an executable binary's path by name.
    fn which(&self, binary_name: &str) -> WhichResult;

    /// Reads the content of the file at `path` into a [`String`].
    fn read_to_string(&self, path: &SystemPath) -> Result<String>;

    /// Reads the content of the file at `path` as a Notebook.
    ///
    /// This method optimizes for the case where the system holds a structured representation of a [`Notebook`],
    /// allowing to skip the notebook deserialization. Systems that don't use a structured
    /// representation fall-back to deserializing the notebook from a string.
    fn read_to_notebook(&self, path: &SystemPath) -> std::result::Result<Notebook, NotebookError>;

    /// Reads the content of the virtual file at `path` into a [`String`].
    fn read_virtual_path_to_string(&self, path: &SystemVirtualPath) -> Result<String>;

    /// Reads the content of the virtual file at `path` as a [`Notebook`].
    fn read_virtual_path_to_notebook(
        &self,
        path: &SystemVirtualPath,
    ) -> std::result::Result<Notebook, NotebookError>;

    /// Returns `true` if `path` exists.
    fn path_exists(&self, path: &SystemPath) -> bool {
        self.path_metadata(path).is_ok()
    }

    /// Returns `true` if `path` exists and is a directory.
    fn is_directory(&self, path: &SystemPath) -> bool {
        self.path_metadata(path)
            .is_ok_and(|metadata| metadata.file_type.is_directory())
    }

    /// Returns `true` if `path` exists and is a file.
    fn is_file(&self, path: &SystemPath) -> bool {
        self.path_metadata(path)
            .is_ok_and(|metadata| metadata.file_type.is_file())
    }

    /// Returns the current working directory
    fn current_directory(&self) -> &SystemPath;

    /// Returns the directory path where user configurations are stored.
    ///
    /// Returns `None` if no such convention exists for the system.
    fn user_config_directory(&self) -> Option<SystemPathBuf>;

    /// Returns the directory path where cached files are stored.
    ///
    /// Returns `None` if no such convention exists for the system.
    fn cache_dir(&self) -> Option<SystemPathBuf>;

    /// Iterate over the contents of the directory at `path`.
    ///
    /// The returned iterator must have the following properties:
    /// - It only iterates over the top level of the directory,
    ///   i.e., it does not recurse into subdirectories.
    /// - It skips the current and parent directories (`.` and `..`
    ///   respectively).
    /// - The iterator yields `std::io::Result<DirEntry>` instances.
    ///   For each instance, an `Err` variant may signify that the path
    ///   of the entry was not valid UTF8, in which case it should be an
    ///   [`std::io::Error`] with the ErrorKind set to
    ///   [`std::io::ErrorKind::InvalidData`] and the payload set to a
    ///   [`camino::FromPathBufError`]. It may also indicate that
    ///   "some sort of intermittent IO error occurred during iteration"
    ///   (language taken from the [`std::fs::read_dir`] documentation).
    ///
    /// # Errors
    /// Returns an error:
    /// - if `path` does not exist in the system,
    /// - if `path` does not point to a directory,
    /// - if the process does not have sufficient permissions to
    ///   view the contents of the directory at `path`
    /// - May also return an error in some other situations as well.
    fn read_directory<'a>(
        &'a self,
        path: &SystemPath,
    ) -> Result<Box<dyn Iterator<Item = Result<DirectoryEntry>> + 'a>>;

    /// Recursively walks the content of `path`.
    ///
    /// It is allowed to pass a `path` that points to a file. In this case, the walker
    /// yields a single entry for that file.
    fn walk_directory(&self, path: &SystemPath) -> WalkDirectoryBuilder;

    /// Fetches the environment variable `key` from the current process.
    ///
    /// # Errors
    ///
    /// Returns [`std::env::VarError::NotPresent`] if:
    /// - The variable is not set.
    /// - The variable's name contains an equal sign or NUL (`'='` or `'\0'`).
    ///
    /// Returns [`std::env::VarError::NotUnicode`] if the variable's value is not valid
    /// Unicode.
    fn env_var(&self, name: &str) -> std::result::Result<String, std::env::VarError> {
        let _ = name;
        Err(std::env::VarError::NotPresent)
    }

    /// Returns a handle to a [`WritableSystem`] if this system is writable.
    fn as_writable(&self) -> Option<&dyn WritableSystem>;

    fn as_any(&self) -> &dyn std::any::Any;

    fn as_any_mut(&mut self) -> &mut dyn std::any::Any;

    fn dyn_clone(&self) -> Box<dyn System>;
}

/// System trait for non-readonly systems.
pub trait WritableSystem: System {
    /// Creates a file at the given path.
    ///
    /// Returns an error if the file already exists.
    fn create_new_file(&self, path: &SystemPath) -> Result<()>;

    /// Writes the given content to the file at the given path.
    fn write_file(&self, path: &SystemPath, content: &str) -> Result<()> {
        self.write_file_bytes(path, content.as_bytes())
    }

    /// Writes the given content to the file at the given path.
    fn write_file_bytes(&self, path: &SystemPath, content: &[u8]) -> Result<()>;

    /// Creates a directory at `path` as well as any intermediate directories.
    fn create_directory_all(&self, path: &SystemPath) -> Result<()>;

    /// Reads the provided file from the system cache, or creates the file if necessary.
    ///
    /// Returns `Ok(None)` if the system does not expose a suitable cache directory.
    fn get_or_cache(
        &self,
        path: &SystemPath,
        read_contents: &dyn Fn() -> Result<String>,
    ) -> Result<Option<SystemPathBuf>> {
        let Some(cache_dir) = self.cache_dir() else {
            return Ok(None);
        };

        let cache_path = cache_dir.join(path);

        // The file has already been cached.
        if self.is_file(&cache_path) {
            return Ok(Some(cache_path));
        }

        // Read the file contents.
        let contents = read_contents()?;

        // Create the parent directory.
        self.create_directory_all(cache_path.parent().unwrap())?;

        // Create and write to the file on the system.
        //
        // Note that `create_new_file` will fail if the file has already been created. This
        // ensures that only one thread/process ever attempts to write to it to avoid corrupting
        // the cache.
        self.create_new_file(&cache_path)?;
        self.write_file(&cache_path, &contents)?;

        Ok(Some(cache_path))
    }

    fn dyn_clone(&self) -> Box<dyn WritableSystem>;
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Metadata {
    revision: FileRevision,
    permissions: Option<u32>,
    file_type: FileType,
}

impl Metadata {
    pub fn new(revision: FileRevision, permissions: Option<u32>, file_type: FileType) -> Self {
        Self {
            revision,
            permissions,
            file_type,
        }
    }

    pub fn revision(&self) -> FileRevision {
        self.revision
    }

    pub fn permissions(&self) -> Option<u32> {
        self.permissions
    }

    pub fn file_type(&self) -> FileType {
        self.file_type
    }
}

#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, get_size2::GetSize)]
pub enum FileType {
    File,
    Directory,
    Symlink,
}

impl FileType {
    pub const fn is_file(self) -> bool {
        matches!(self, FileType::File)
    }

    pub const fn is_directory(self) -> bool {
        matches!(self, FileType::Directory)
    }

    pub const fn is_symlink(self) -> bool {
        matches!(self, FileType::Symlink)
    }
}

#[derive(Debug, PartialEq, Eq)]
pub struct DirectoryEntry {
    path: SystemPathBuf,
    file_type: FileType,
}

impl DirectoryEntry {
    pub fn new(path: SystemPathBuf, file_type: FileType) -> Self {
        Self { path, file_type }
    }

    pub fn into_path(self) -> SystemPathBuf {
        self.path
    }

    pub fn path(&self) -> &SystemPath {
        &self.path
    }

    pub fn file_type(&self) -> FileType {
        self.file_type
    }
}

#[cfg(not(target_arch = "wasm32"))]
pub fn file_time_now() -> FileTime {
    FileTime::now()
}

#[cfg(target_arch = "wasm32")]
pub fn file_time_now() -> FileTime {
    // Copied from FileTime::from_system_time()
    let time = web_time::SystemTime::now();

    time.duration_since(web_time::UNIX_EPOCH)
        .map(|d| FileTime::from_unix_time(d.as_secs() as i64, d.subsec_nanos()))
        .unwrap_or_else(|e| {
            let until_epoch = e.duration();
            let (sec_offset, nanos) = if until_epoch.subsec_nanos() == 0 {
                (0, 0)
            } else {
                (-1, 1_000_000_000 - until_epoch.subsec_nanos())
            };

            FileTime::from_unix_time(-(until_epoch.as_secs() as i64) + sec_offset, nanos)
        })
}

#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum WhichError {
    /// An executable binary with that name was not found
    CannotFindBinaryPath,

    /// There was nowhere to search and the provided name wasn't an absolute path
    CannotGetCurrentDirAndPathListEmpty,

    /// Failed to canonicalize the path found
    CannotCanonicalize,

    /// The executable exists but its path contains non UTF8 characters.
    NonUtf8Path,
}

impl Error for WhichError {}

impl fmt::Display for WhichError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            WhichError::CannotFindBinaryPath => write!(f, "cannot find binary path"),
            WhichError::CannotGetCurrentDirAndPathListEmpty => write!(
                f,
                "no path to search and provided name is not an absolute path"
            ),
            WhichError::CannotCanonicalize => write!(f, "cannot canonicalize path"),
            WhichError::NonUtf8Path => write!(f, "non UTF-8 path"),
        }
    }
}
