use crate::commands::cd::CdArgs;
use crate::commands::command::EvaluatedWholeStreamCommandArgs;
use crate::commands::cp::CopyArgs;
use crate::commands::ls::LsArgs;
use crate::commands::mkdir::MkdirArgs;
use crate::commands::mv::MoveArgs;
use crate::commands::rm::RemoveArgs;
use crate::data::dir_entry_dict;
use crate::path::canonicalize;
use crate::prelude::*;
use crate::shell::completer::NuCompleter;
use crate::shell::shell::Shell;
use crate::utils::FileStructure;

use rustyline::completion::FilenameCompleter;
use rustyline::hint::{Hinter, HistoryHinter};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

use nu_errors::ShellError;
use nu_parser::expand_ndots;
use nu_protocol::{Primitive, ReturnSuccess, UntaggedValue};
use nu_source::Tagged;

pub struct FilesystemShell {
    pub(crate) path: String,
    pub(crate) last_path: String,
    completer: NuCompleter,
    hinter: HistoryHinter,
}

impl std::fmt::Debug for FilesystemShell {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "FilesystemShell @ {}", self.path)
    }
}

impl Clone for FilesystemShell {
    fn clone(&self) -> Self {
        FilesystemShell {
            path: self.path.clone(),
            last_path: self.path.clone(),
            completer: NuCompleter {
                file_completer: FilenameCompleter::new(),
                commands: self.completer.commands.clone(),
                homedir: self.homedir(),
            },
            hinter: HistoryHinter {},
        }
    }
}

impl FilesystemShell {
    pub fn basic(commands: CommandRegistry) -> Result<FilesystemShell, std::io::Error> {
        let path = std::env::current_dir()?;

        Ok(FilesystemShell {
            path: path.to_string_lossy().to_string(),
            last_path: path.to_string_lossy().to_string(),
            completer: NuCompleter {
                file_completer: FilenameCompleter::new(),
                commands,
                homedir: dirs::home_dir(),
            },
            hinter: HistoryHinter {},
        })
    }

    pub fn with_location(
        path: String,
        commands: CommandRegistry,
    ) -> Result<FilesystemShell, std::io::Error> {
        let path = canonicalize(std::env::current_dir()?, &path)?;
        let path = path.display().to_string();
        let last_path = path.clone();

        Ok(FilesystemShell {
            path,
            last_path,
            completer: NuCompleter {
                file_completer: FilenameCompleter::new(),
                commands,
                homedir: dirs::home_dir(),
            },
            hinter: HistoryHinter {},
        })
    }
}

impl Shell for FilesystemShell {
    fn name(&self) -> String {
        "filesystem".to_string()
    }

    fn homedir(&self) -> Option<PathBuf> {
        dirs::home_dir()
    }

    fn ls(
        &self,
        LsArgs {
            path,
            all,
            full,
            short_names,
            with_symlink_targets,
            du,
        }: LsArgs,
        name_tag: Tag,
        ctrl_c: Arc<AtomicBool>,
    ) -> Result<OutputStream, ShellError> {
        let ctrl_c_copy = ctrl_c.clone();
        let (path, p_tag) = match path {
            Some(p) => {
                let p_tag = p.tag;
                let mut p = p.item;
                if p.is_dir() {
                    if is_empty_dir(&p) {
                        return Ok(OutputStream::empty());
                    }
                    p.push("*");
                }
                (p, p_tag)
            }
            None => {
                if is_empty_dir(&self.path()) {
                    return Ok(OutputStream::empty());
                } else {
                    (PathBuf::from("./*"), name_tag.clone())
                }
            }
        };

        let mut paths = glob::glob(&path.to_string_lossy())
            .map_err(|e| ShellError::labeled_error(e.to_string(), "invalid pattern", &p_tag))?
            .peekable();

        if paths.peek().is_none() {
            return Err(ShellError::labeled_error(
                "No matches found",
                "no matches found",
                &p_tag,
            ));
        }

        // Generated stream: impl Stream<Item = Result<ReturnSuccess, ShellError>

        Ok(futures::stream::iter(paths.filter_map(move |path| {
            let path = match path.map_err(|e| ShellError::from(e.into_error())) {
                Ok(path) => path,
                Err(err) => return Some(Err(err)),
            };

            if !all && is_hidden_dir(&path) {
                return None;
            }

            let metadata = match std::fs::symlink_metadata(&path) {
                Ok(metadata) => Some(metadata),
                Err(e) => {
                    if e.kind() == std::io::ErrorKind::PermissionDenied {
                        None
                    } else {
                        return Some(Err(e.into()));
                    }
                }
            };

            let entry = dir_entry_dict(
                &path,
                metadata.as_ref(),
                name_tag.clone(),
                full,
                short_names,
                with_symlink_targets,
                du,
                ctrl_c.clone(),
            )
            .map(ReturnSuccess::Value);

            Some(entry)
        }))
        .interruptible(ctrl_c_copy)
        .to_output_stream())
    }

    fn cd(&self, args: CdArgs, name: Tag) -> Result<OutputStream, ShellError> {
        let path = match args.path {
            None => match dirs::home_dir() {
                Some(o) => o,
                _ => {
                    return Err(ShellError::labeled_error(
                        "Cannot change to home directory",
                        "cannot go to home",
                        &name,
                    ))
                }
            },
            Some(v) => {
                let Tagged { item: target, tag } = v;
                if target == Path::new("-") {
                    PathBuf::from(&self.last_path)
                } else {
                    let path = canonicalize(self.path(), target).map_err(|_| {
                        ShellError::labeled_error(
                            "Cannot change to directory",
                            "directory not found",
                            &tag,
                        )
                    })?;

                    if !path.is_dir() {
                        return Err(ShellError::labeled_error(
                            "Cannot change to directory",
                            "is not a directory",
                            &tag,
                        ));
                    }

                    #[cfg(unix)]
                    {
                        let has_exec = path
                            .metadata()
                            .map(|m| {
                                umask::Mode::from(m.permissions().mode()).has(umask::USER_READ)
                            })
                            .map_err(|e| {
                                ShellError::labeled_error(
                                    "Cannot change to directory",
                                    format!("cannot stat ({})", e),
                                    &tag,
                                )
                            })?;

                        if !has_exec {
                            return Err(ShellError::labeled_error(
                                "Cannot change to directory",
                                "permission denied",
                                &tag,
                            ));
                        }
                    }

                    path
                }
            }
        };

        let mut stream = VecDeque::new();

        stream.push_back(ReturnSuccess::change_cwd(
            path.to_string_lossy().to_string(),
        ));

        Ok(stream.into())
    }

    fn cp(
        &self,
        CopyArgs {
            src,
            dst,
            recursive,
        }: CopyArgs,
        name: Tag,
        path: &str,
    ) -> Result<OutputStream, ShellError> {
        let name_tag = name;

        let path = Path::new(path);
        let source = path.join(&src.item);
        let destination = path.join(&dst.item);

        let sources: Vec<_> = match glob::glob(&source.to_string_lossy()) {
            Ok(files) => files.collect(),
            Err(e) => {
                return Err(ShellError::labeled_error(
                    e.to_string(),
                    "invalid pattern",
                    src.tag,
                ))
            }
        };

        if sources.is_empty() {
            return Err(ShellError::labeled_error(
                "No matches found",
                "no matches found",
                src.tag,
            ));
        }

        if sources.len() > 1 && !destination.is_dir() {
            return Err(ShellError::labeled_error(
                "Destination must be a directory when copying multiple files",
                "is not a directory",
                dst.tag,
            ));
        }

        let any_source_is_dir = sources.iter().any(|f| match f {
            Ok(f) => f.is_dir(),
            Err(_) => false,
        });

        if any_source_is_dir && !recursive.item {
            return Err(ShellError::labeled_error(
                "Directories must be copied using \"--recursive\"",
                "resolves to a directory (not copied)",
                src.tag,
            ));
        }

        for entry in sources {
            if let Ok(entry) = entry {
                let mut sources = FileStructure::new();
                sources.walk_decorate(&entry)?;

                if entry.is_file() {
                    let sources = sources.paths_applying_with(|(source_file, _depth_level)| {
                        if destination.is_dir() {
                            let mut dest = canonicalize(&path, &dst.item)?;
                            if let Some(name) = entry.file_name() {
                                dest.push(name);
                            }
                            Ok((source_file, dest))
                        } else {
                            Ok((source_file, destination.clone()))
                        }
                    })?;

                    for (src, dst) in sources {
                        if src.is_file() {
                            std::fs::copy(src, dst).map_err(|e| {
                                ShellError::labeled_error(e.to_string(), e.to_string(), &name_tag)
                            })?;
                        }
                    }
                } else if entry.is_dir() {
                    let destination = if !destination.exists() {
                        destination.clone()
                    } else {
                        match entry.file_name() {
                            Some(name) => destination.join(name),
                            None => {
                                return Err(ShellError::labeled_error(
                                    "Copy aborted. Not a valid path",
                                    "not a valid path",
                                    dst.tag,
                                ))
                            }
                        }
                    };

                    std::fs::create_dir_all(&destination).map_err(|e| {
                        ShellError::labeled_error(e.to_string(), e.to_string(), &dst.tag)
                    })?;

                    let sources = sources.paths_applying_with(|(source_file, depth_level)| {
                        let mut dest = destination.clone();
                        let path = canonicalize(&path, &source_file)?;

                        let comps: Vec<_> = path
                            .components()
                            .map(|fragment| fragment.as_os_str())
                            .rev()
                            .take(1 + depth_level)
                            .collect();

                        for fragment in comps.into_iter().rev() {
                            dest.push(fragment);
                        }

                        Ok((PathBuf::from(&source_file), dest))
                    })?;

                    let dst_tag = &dst.tag;
                    for (src, dst) in sources {
                        if src.is_dir() && !dst.exists() {
                            std::fs::create_dir_all(&dst).map_err(|e| {
                                ShellError::labeled_error(e.to_string(), e.to_string(), dst_tag)
                            })?;
                        }

                        if src.is_file() {
                            std::fs::copy(&src, &dst).map_err(|e| {
                                ShellError::labeled_error(e.to_string(), e.to_string(), &name_tag)
                            })?;
                        }
                    }
                }
            }
        }

        Ok(OutputStream::empty())
    }

    fn mkdir(
        &self,
        MkdirArgs {
            rest: directories,
            show_created_paths,
        }: MkdirArgs,
        name: Tag,
        path: &str,
    ) -> Result<OutputStream, ShellError> {
        let path = Path::new(path);
        let mut stream = VecDeque::new();

        if directories.is_empty() {
            return Err(ShellError::labeled_error(
                "mkdir requires directory paths",
                "needs parameter",
                name,
            ));
        }

        for dir in directories.iter() {
            let create_at = path.join(&dir.item);

            let dir_res = std::fs::create_dir_all(&create_at);
            if let Err(reason) = dir_res {
                return Err(ShellError::labeled_error(
                    reason.to_string(),
                    reason.to_string(),
                    dir.tag(),
                ));
            }
            if show_created_paths {
                let val = format!("{:}", create_at.to_string_lossy()).into();
                stream.push_back(Ok(ReturnSuccess::Value(val)));
            }
        }

        Ok(stream.into())
    }

    fn mv(
        &self,
        MoveArgs { src, dst }: MoveArgs,
        _name: Tag,
        path: &str,
    ) -> Result<OutputStream, ShellError> {
        let path = Path::new(path);
        let source = path.join(&src.item);
        let destination = path.join(&dst.item);

        let sources =
            glob::glob(&source.to_string_lossy()).map_or_else(|_| Vec::new(), Iterator::collect);

        if sources.is_empty() {
            return Err(ShellError::labeled_error(
                "Invalid file or pattern",
                "invalid file or pattern",
                src.tag,
            ));
        }

        // We have two possibilities.
        //
        // First, the destination exists.
        //  - If a directory, move everything into that directory, otherwise
        //  - if only a single source, overwrite the file, otherwise
        //  - error.
        //
        // Second, the destination doesn't exist, so we can only rename a single source. Otherwise
        // it's an error.

        if (destination.exists() && !destination.is_dir() && sources.len() > 1)
            || (!destination.exists() && sources.len() > 1)
        {
            return Err(ShellError::labeled_error(
                "Can only move multiple sources if destination is a directory",
                "destination must be a directory when multiple sources",
                dst.tag,
            ));
        }

        for entry in sources {
            if let Ok(entry) = entry {
                move_file(
                    TaggedPathBuf(&entry, &src.tag),
                    TaggedPathBuf(&destination, &dst.tag),
                )?
            }
        }

        Ok(OutputStream::empty())
    }

    fn rm(
        &self,
        RemoveArgs {
            rest: targets,
            recursive,
            trash: _trash,
            permanent: _permanent,
        }: RemoveArgs,
        name: Tag,
        path: &str,
    ) -> Result<OutputStream, ShellError> {
        let name_tag = name;

        if targets.is_empty() {
            return Err(ShellError::labeled_error(
                "rm requires target paths",
                "needs parameter",
                name_tag,
            ));
        }

        let path = Path::new(path);
        let mut all_targets: HashMap<PathBuf, Tag> = HashMap::new();
        for target in targets {
            let all_dots = target
                .item
                .to_str()
                .map_or(false, |v| v.chars().all(|c| c == '.'));

            if all_dots {
                return Err(ShellError::labeled_error(
                    "Cannot remove any parent directory",
                    "cannot remove any parent directory",
                    target.tag,
                ));
            }

            let path = path.join(&target.item);
            match glob::glob(&path.to_string_lossy()) {
                Ok(files) => {
                    for file in files {
                        match file {
                            Ok(ref f) => {
                                all_targets
                                    .entry(f.clone())
                                    .or_insert_with(|| target.tag.clone());
                            }
                            Err(e) => {
                                return Err(ShellError::labeled_error(
                                    format!("Could not remove {:}", path.to_string_lossy()),
                                    e.to_string(),
                                    &target.tag,
                                ));
                            }
                        }
                    }
                }
                Err(e) => {
                    return Err(ShellError::labeled_error(
                        e.to_string(),
                        e.to_string(),
                        &name_tag,
                    ))
                }
            };
        }

        if all_targets.is_empty() {
            return Err(ShellError::labeled_error(
                "No valid paths",
                "no valid paths",
                name_tag,
            ));
        }

        Ok(
            futures::stream::iter(all_targets.into_iter().map(move |(f, tag)| {
                let is_empty = || match f.read_dir() {
                    Ok(mut p) => p.next().is_none(),
                    Err(_) => false,
                };

                if let Ok(metadata) = f.symlink_metadata() {
                    if metadata.is_file()
                        || metadata.file_type().is_symlink()
                        || recursive.item
                        || is_empty()
                    {
                        let result;
                        #[cfg(feature = "trash-support")]
                        {
                            let rm_always_trash = config::config(Tag::unknown())?
                                .get("rm_always_trash")
                                .map(|val| val.is_true())
                                .unwrap_or(false);
                            result = if _trash.item || (rm_always_trash && !_permanent.item) {
                                trash::remove(&f).map_err(|_| f.to_string_lossy())
                            } else if metadata.is_file() {
                                std::fs::remove_file(&f).map_err(|_| f.to_string_lossy())
                            } else {
                                std::fs::remove_dir_all(&f).map_err(|_| f.to_string_lossy())
                            };
                        }
                        #[cfg(not(feature = "trash-support"))]
                        {
                            result = if metadata.is_file() {
                                std::fs::remove_file(&f).map_err(|_| f.to_string_lossy())
                            } else {
                                std::fs::remove_dir_all(&f).map_err(|_| f.to_string_lossy())
                            };
                        }

                        if let Err(e) = result {
                            let msg = format!("Could not delete {:}", e);
                            Err(ShellError::labeled_error(msg, e, tag))
                        } else {
                            let val = format!("deleted {:}", f.to_string_lossy()).into();
                            Ok(ReturnSuccess::Value(val))
                        }
                    } else {
                        let msg =
                            format!("Cannot remove {:}. try --recursive", f.to_string_lossy());
                        Err(ShellError::labeled_error(
                            msg,
                            "cannot remove non-empty directory",
                            tag,
                        ))
                    }
                } else {
                    let msg = format!("no such file or directory: {:}", f.to_string_lossy());
                    Err(ShellError::labeled_error(
                        msg,
                        "no such file or directory",
                        tag,
                    ))
                }
            }))
            .to_output_stream(),
        )
    }

    fn path(&self) -> String {
        self.path.clone()
    }

    fn pwd(&self, args: EvaluatedWholeStreamCommandArgs) -> Result<OutputStream, ShellError> {
        let path = PathBuf::from(self.path());
        let p = match dunce::canonicalize(path.as_path()) {
            Ok(p) => p,
            Err(_) => {
                return Err(ShellError::labeled_error(
                    "unable to show current directory",
                    "pwd command failed",
                    &args.call_info.name_tag,
                ));
            }
        };

        let mut stream = VecDeque::new();
        stream.push_back(ReturnSuccess::value(
            UntaggedValue::Primitive(Primitive::String(p.to_string_lossy().to_string()))
                .into_value(&args.call_info.name_tag),
        ));

        Ok(stream.into())
    }

    fn set_path(&mut self, path: String) {
        let pathbuf = PathBuf::from(&path);
        let path = match canonicalize(self.path(), pathbuf.as_path()) {
            Ok(path) => {
                let _ = std::env::set_current_dir(&path);
                std::env::set_var("PWD", &path);
                path
            }
            _ => {
                // TODO: handle the case where the path cannot be canonicalized
                pathbuf
            }
        };
        self.last_path = self.path.clone();
        self.path = path.to_string_lossy().to_string();
    }

    fn complete(
        &self,
        line: &str,
        pos: usize,
        ctx: &rustyline::Context<'_>,
    ) -> Result<(usize, Vec<rustyline::completion::Pair>), rustyline::error::ReadlineError> {
        let expanded = expand_ndots(&line);

        // Find the first not-matching char position, if there is one
        let differ_pos = line
            .chars()
            .zip(expanded.chars())
            .enumerate()
            .find(|(_index, (a, b))| a != b)
            .map(|(differ_pos, _)| differ_pos);

        let pos = if let Some(differ_pos) = differ_pos {
            if differ_pos < pos {
                pos + (expanded.len() - line.len())
            } else {
                pos
            }
        } else {
            pos
        };

        self.completer.complete(&expanded, pos, ctx)
    }

    fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<String> {
        self.hinter.hint(line, pos, ctx)
    }
}

struct TaggedPathBuf<'a>(&'a PathBuf, &'a Tag);

fn move_file(from: TaggedPathBuf, to: TaggedPathBuf) -> Result<(), ShellError> {
    let TaggedPathBuf(from, from_tag) = from;
    let TaggedPathBuf(to, to_tag) = to;

    if to.exists() && from.is_dir() && to.is_file() {
        return Err(ShellError::labeled_error(
            "Cannot rename a directory to a file",
            "invalid destination",
            to_tag,
        ));
    }

    let destination_dir_exists = if to.is_dir() {
        true
    } else {
        to.parent().map(Path::exists).unwrap_or(true)
    };

    if !destination_dir_exists {
        return Err(ShellError::labeled_error(
            "Destination directory does not exist",
            "destination does not exist",
            to_tag,
        ));
    }

    let mut to = to.clone();
    if to.is_dir() {
        let from_file_name = match from.file_name() {
            Some(name) => name,
            None => {
                return Err(ShellError::labeled_error(
                    "Not a valid entry name",
                    "not a valid entry name",
                    from_tag,
                ))
            }
        };

        to.push(from_file_name);
    }

    // We first try a rename, which is a quick operation. If that doesn't work, we'll try a copy
    // and remove the old file. This is necessary if we're moving across filesystems.
    std::fs::rename(&from, &to)
        .or_else(|_| std::fs::copy(&from, &to).and_then(|_| std::fs::remove_file(&from)))
        .map_err(|e| {
            ShellError::labeled_error(
                format!("Could not move {:?} to {:?}. {:}", from, to, e.to_string()),
                "could not move",
                from_tag,
            )
        })
}

fn is_empty_dir(dir: impl AsRef<Path>) -> bool {
    match dir.as_ref().read_dir() {
        Err(_) => true,
        Ok(mut s) => s.next().is_none(),
    }
}

fn is_hidden_dir(dir: impl AsRef<Path>) -> bool {
    cfg_if::cfg_if! {
        if #[cfg(windows)] {
            use std::os::windows::fs::MetadataExt;

            if let Ok(metadata) = dir.as_ref().metadata() {
                let attributes = metadata.file_attributes();
                // https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
                (attributes & 0x2) != 0
            } else {
                false
            }
        } else {
            dir.as_ref()
                .file_name()
                .map(|name| name.to_string_lossy().starts_with('.'))
                .unwrap_or(false)
        }
    }
}
