//! The classes responsible for autoloading functions and completions.

use crate::common::{escape, ScopeGuard};
use crate::env::Environment;
use crate::io::IoChain;
use crate::parser::Parser;
#[cfg(test)]
use crate::tests::prelude::*;
use crate::wchar::{wstr, WString, L};
use crate::wutil::{file_id_for_path, FileId, INVALID_FILE_ID};
use lru::LruCache;
use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
use std::time;

/// autoload_t is a class that knows how to autoload .fish files from a list of directories. This
/// is used by autoloading functions and completions. It maintains a file cache, which is
/// responsible for potentially cached accesses of files, and then a list of files that have
/// actually been autoloaded. A client may request a file to autoload given a command name, and may
/// be returned a path which it is expected to source.
/// autoload_t does not have any internal locks; it is the responsibility of the caller to lock
/// it.
#[derive(Default)]
pub struct Autoload {
    /// The environment variable whose paths we observe.
    env_var_name: &'static wstr,

    /// A map from command to the files we have autoloaded.
    autoloaded_files: HashMap<WString, FileId>,

    /// The list of commands that we are currently autoloading.
    current_autoloading: HashSet<WString>,

    /// The autoload cache.
    /// This is a unique_ptr because want to change it if the value of our environment variable
    /// changes. This is never null (but it may be a cache with no paths).
    cache: Box<AutoloadFileCache>,
}

impl Autoload {
    /// Construct an autoloader that loads from the paths given by `env_var_name`.
    pub fn new(env_var_name: &'static wstr) -> Self {
        Self {
            env_var_name,
            ..Default::default()
        }
    }

    /// Given a command, get a path to autoload.
    /// For example, if the environment variable is 'fish_function_path' and the command is 'foo',
    /// this will look for a file 'foo.fish' in one of the directories given by fish_function_path.
    /// If there is no such file, OR if the file has been previously resolved and is now unchanged,
    /// this will return none. But if the file is either new or changed, this will return the path.
    /// After returning a path, the command is marked in-progress until the caller calls
    /// mark_autoload_finished() with the same command. Note this does not actually execute any
    /// code; it is the caller's responsibility to load the file.
    pub fn resolve_command(&mut self, cmd: &wstr, env: &dyn Environment) -> Option<WString> {
        if let Some(var) = env.get(self.env_var_name) {
            self.resolve_command_impl(cmd, var.as_list())
        } else {
            self.resolve_command_impl(cmd, &[])
        }
    }

    /// Helper to actually perform an autoload.
    /// This is a static function because it executes fish script, and so must be called without
    /// holding any particular locks.
    pub fn perform_autoload(path: &wstr, parser: &Parser) {
        // We do the useful part of what exec_subshell does ourselves
        // - we source the file.
        // We don't create a buffer or check ifs or create a read_limit

        let script_source = L!("source ").to_owned() + &escape(path)[..];
        let prev_statuses = parser.get_last_statuses();
        let _put_back = ScopeGuard::new((), |()| parser.set_last_statuses(prev_statuses));
        parser.eval(&script_source, &IoChain::new());
    }

    /// Mark that a command previously returned from path_to_autoload is finished autoloading.
    pub fn mark_autoload_finished(&mut self, cmd: &wstr) {
        let removed = self.current_autoloading.remove(cmd);
        assert!(removed, "cmd was not being autoloaded");
    }

    /// Return whether a command is currently being autoloaded.
    pub fn autoload_in_progress(&self, cmd: &wstr) -> bool {
        self.current_autoloading.contains(cmd)
    }

    /// Return whether a command could potentially be autoloaded.
    /// This does not actually mark the command as being autoloaded.
    pub fn can_autoload(&mut self, cmd: &wstr) -> bool {
        self.cache.check(cmd, true /* allow stale */).is_some()
    }

    /// Return whether autoloading has been attempted for a command.
    pub fn has_attempted_autoload(&self, cmd: &wstr) -> bool {
        self.cache.is_cached(cmd)
    }

    /// Return the names of all commands that have been autoloaded. Note this includes "in-flight"
    /// commands.
    pub fn get_autoloaded_commands(&self) -> Vec<WString> {
        let mut result = Vec::with_capacity(self.autoloaded_files.len());
        for k in self.autoloaded_files.keys() {
            result.push(k.to_owned());
        }
        // Sort the output to make it easier to test.
        result.sort();
        result
    }

    /// Mark that all autoloaded files have been forgotten.
    /// Future calls to path_to_autoload() will return previously-returned paths.
    pub fn clear(&mut self) {
        // Note there is no reason to invalidate the cache here.
        self.autoloaded_files.clear();
    }

    /// Invalidate any underlying cache.
    /// This is exposed for testing.
    #[cfg(test)]
    fn invalidate_cache(&mut self) {
        self.cache = Box::new(AutoloadFileCache::with_dirs(self.cache.dirs().to_owned()));
    }

    /// Like resolve_autoload(), but accepts the paths directly.
    /// This is exposed for testing.
    fn resolve_command_impl(&mut self, cmd: &wstr, paths: &[WString]) -> Option<WString> {
        // Are we currently in the process of autoloading this?
        if self.current_autoloading.contains(cmd) {
            return None;
        }

        // Check to see if our paths have changed. If so, replace our cache.
        // Note we don't have to modify autoloadable_files_. We'll naturally detect if those have
        // changed when we query the cache.
        if paths != self.cache.dirs() {
            self.cache = Box::new(AutoloadFileCache::with_dirs(paths.to_owned()));
        }

        // Do we have an entry to load?
        let file = self.cache.check(cmd, false)?;

        // Is this file the same as what we previously autoloaded?
        if let Some(loaded_file) = self.autoloaded_files.get(cmd) {
            if *loaded_file == file.file_id {
                // The file has been autoloaded and is unchanged.
                return None;
            }
        }

        // We're going to (tell our caller to) autoload this command.
        self.current_autoloading.insert(cmd.to_owned());
        self.autoloaded_files.insert(cmd.to_owned(), file.file_id);
        Some(file.path)
    }
}

/// The time before we'll recheck an autoloaded file.
const AUTOLOAD_STALENESS_INTERVALL: u64 = 15;

/// Represents a file that we might want to autoload.
#[derive(Clone)]
struct AutoloadableFile {
    /// The path to the file.
    path: WString,
    /// The metadata for the file.
    file_id: FileId,
}

// A timestamp is a monotonic point in time.
type Timestamp = time::Instant;
type MissesLruCache = LruCache<WString, Timestamp>;

struct KnownFile {
    file: AutoloadableFile,
    last_checked: Timestamp,
}

/// Class representing a cache of files that may be autoloaded.
/// This is responsible for performing cached accesses to a set of paths.
struct AutoloadFileCache {
    /// The directories from which to load.
    dirs: Vec<WString>,

    /// Our LRU cache of checks that were misses.
    /// The key is the command, the  value is the time of the check.
    misses_cache: MissesLruCache,

    /// The set of files that we have returned to the caller, along with the time of the check.
    /// The key is the command (not the path).
    known_files: HashMap<WString, KnownFile>,
}

impl Default for AutoloadFileCache {
    fn default() -> Self {
        Self::new()
    }
}

impl AutoloadFileCache {
    /// Initialize with a set of directories.
    fn with_dirs(dirs: Vec<WString>) -> Self {
        Self {
            dirs,
            misses_cache: MissesLruCache::new(NonZeroUsize::new(1024).unwrap()),
            known_files: HashMap::new(),
        }
    }

    /// Initialize with empty directories.
    fn new() -> Self {
        Self::with_dirs(vec![])
    }

    /// Return the directories.
    fn dirs(&self) -> &[WString] {
        &self.dirs
    }

    /// Check if a command `cmd` can be loaded.
    /// If `allow_stale` is true, allow stale entries; otherwise discard them.
    /// This returns an autoloadable file, or none() if there is no such file.
    fn check(&mut self, cmd: &wstr, allow_stale: bool) -> Option<AutoloadableFile> {
        // Check hits.
        if let Some(value) = self.known_files.get(cmd) {
            if allow_stale || Self::is_fresh(value.last_checked, Self::current_timestamp()) {
                // Re-use this cached hit.
                return Some(value.file.clone());
            }
            // The file is stale, remove it.
            self.known_files.remove(cmd);
        }

        // Check misses.
        if let Some(miss) = self.misses_cache.get(cmd) {
            if allow_stale || Self::is_fresh(*miss, Self::current_timestamp()) {
                // Re-use this cached miss.
                return None;
            }
            // The miss is stale, remove it.
            self.misses_cache.pop(cmd);
        }

        // We couldn't satisfy this request from the cache. Hit the disk.
        let file = self.locate_file(cmd);
        if let Some(file) = file.as_ref() {
            let old_value = self.known_files.insert(
                cmd.to_owned(),
                KnownFile {
                    file: file.clone(),
                    last_checked: Self::current_timestamp(),
                },
            );
            assert!(
                old_value.is_none(),
                "Known files cache should not have contained this cmd"
            );
        } else {
            let old_value = self
                .misses_cache
                .put(cmd.to_owned(), Self::current_timestamp());
            assert!(
                old_value.is_none(),
                "Misses cache should not have contained this cmd",
            );
        }
        file
    }

    /// Return true if a command is cached (either as a hit or miss).
    fn is_cached(&self, cmd: &wstr) -> bool {
        self.known_files.contains_key(cmd) || self.misses_cache.contains(cmd)
    }

    /// Return the current timestamp.
    fn current_timestamp() -> Timestamp {
        Timestamp::now()
    }

    /// Return whether a timestamp is fresh enough to use.
    fn is_fresh(then: Timestamp, now: Timestamp) -> bool {
        let seconds = now.duration_since(then).as_secs();
        seconds < AUTOLOAD_STALENESS_INTERVALL
    }

    /// Attempt to find an autoloadable file by searching our path list for a given command.
    /// Return the file, or none() if none.
    fn locate_file(&self, cmd: &wstr) -> Option<AutoloadableFile> {
        // If the command is empty or starts with NULL (i.e. is empty as a path)
        // we'd try to source the *directory*, which exists.
        // So instead ignore these here.
        if cmd.is_empty() {
            return None;
        }
        if cmd.as_char_slice()[0] == '\0' {
            return None;
        }
        // Re-use the storage for path.
        let mut path;
        for dir in self.dirs() {
            // Construct the path as dir/cmd.fish
            path = dir.to_owned();
            path.push('/');
            path.push_utfstr(cmd);
            path.push_str(".fish");

            let file_id = file_id_for_path(&path);
            if file_id != INVALID_FILE_ID {
                // Found it.
                return Some(AutoloadableFile { path, file_id });
            }
        }
        None
    }
}

#[test]
#[serial]
fn test_autoload() {
    let _cleanup = test_init();
    use crate::common::{charptr2wcstring, wcs2zstring};
    use crate::fds::wopen_cloexec;
    use crate::wutil::sprintf;
    use nix::fcntl::OFlag;

    macro_rules! run {
        ( $fmt:expr $(, $arg:expr )* $(,)? ) => {
             let cmd = wcs2zstring(&sprintf!($fmt $(, $arg)*));
             let status = unsafe { libc::system(cmd.as_ptr()) };
             assert!(status == 0);
        };
    }

    fn touch_file(path: &wstr) {
        use nix::sys::stat::Mode;
        use std::io::Write;

        let mut file = wopen_cloexec(
            path,
            OFlag::O_RDWR | OFlag::O_CREAT,
            Mode::from_bits_truncate(0o666),
        )
        .unwrap();
        file.write_all(b"Hello").unwrap();
    }

    let mut t1 = "/tmp/fish_test_autoload.XXXXXX\0".as_bytes().to_vec();
    let p1 = charptr2wcstring(unsafe { libc::mkdtemp(t1.as_mut_ptr().cast()) });
    let mut t2 = "/tmp/fish_test_autoload.XXXXXX\0".as_bytes().to_vec();
    let p2 = charptr2wcstring(unsafe { libc::mkdtemp(t2.as_mut_ptr().cast()) });

    let paths = &[p1.clone(), p2.clone()];
    let mut autoload = Autoload::new(L!("test_var"));
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_none());
    assert!(autoload
        .resolve_command_impl(L!("nothing"), paths)
        .is_none());
    assert!(autoload.get_autoloaded_commands().is_empty());

    run!("touch %ls/file1.fish", p1);
    run!("touch %ls/file2.fish", p2);
    autoload.invalidate_cache();

    assert!(!autoload.autoload_in_progress(L!("file1")));
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_some());
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_none());
    assert!(autoload.autoload_in_progress(L!("file1")));
    assert!(autoload.get_autoloaded_commands() == vec![L!("file1")]);
    autoload.mark_autoload_finished(L!("file1"));
    assert!(!autoload.autoload_in_progress(L!("file1")));
    assert!(autoload.get_autoloaded_commands() == vec![L!("file1")]);

    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_none());
    assert!(autoload
        .resolve_command_impl(L!("nothing"), paths)
        .is_none());
    assert!(autoload.resolve_command_impl(L!("file2"), paths).is_some());
    assert!(autoload.resolve_command_impl(L!("file2"), paths).is_none());
    autoload.mark_autoload_finished(L!("file2"));
    assert!(autoload.resolve_command_impl(L!("file2"), paths).is_none());
    assert!((autoload.get_autoloaded_commands() == vec![L!("file1"), L!("file2")]));

    autoload.clear();
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_some());
    autoload.mark_autoload_finished(L!("file1"));
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_none());
    assert!(autoload
        .resolve_command_impl(L!("nothing"), paths)
        .is_none());
    assert!(autoload.resolve_command_impl(L!("file2"), paths).is_some());
    assert!(autoload.resolve_command_impl(L!("file2"), paths).is_none());
    autoload.mark_autoload_finished(L!("file2"));

    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_none());
    touch_file(&sprintf!("%ls/file1.fish", p1));
    autoload.invalidate_cache();
    assert!(autoload.resolve_command_impl(L!("file1"), paths).is_some());
    autoload.mark_autoload_finished(L!("file1"));

    run!(L!("rm -Rf %ls"), p1);
    run!(L!("rm -Rf %ls"), p2);
}
