use crate::progress_bar;
use nu_engine::get_eval_block;
#[allow(deprecated)]
use nu_engine::{command_prelude::*, current_dir};
use nu_path::expand_path_with;
use nu_protocol::{
    ByteStreamSource, DataSource, OutDest, PipelineMetadata, Signals, ast,
    byte_stream::copy_with_signals, process::ChildPipe, shell_error::io::IoError,
};
use std::{
    fs::File,
    io::{self, BufRead, BufReader, Read, Write},
    path::{Path, PathBuf},
    thread,
    time::{Duration, Instant},
};

#[derive(Clone)]
pub struct Save;

impl Command for Save {
    fn name(&self) -> &str {
        "save"
    }

    fn description(&self) -> &str {
        "Save a file."
    }

    fn search_terms(&self) -> Vec<&str> {
        vec![
            "write",
            "write_file",
            "append",
            "redirection",
            "file",
            "io",
            ">",
            ">>",
        ]
    }

    fn signature(&self) -> nu_protocol::Signature {
        Signature::build("save")
            .input_output_types(vec![(Type::Any, Type::Nothing)])
            .required("filename", SyntaxShape::Filepath, "The filename to use.")
            .named(
                "stderr",
                SyntaxShape::Filepath,
                "the filename used to save stderr, only works with `-r` flag",
                Some('e'),
            )
            .switch("raw", "save file as raw binary", Some('r'))
            .switch("append", "append input to the end of the file", Some('a'))
            .switch("force", "overwrite the destination", Some('f'))
            .switch("progress", "enable progress bar", Some('p'))
            .category(Category::FileSystem)
    }

    fn run(
        &self,
        engine_state: &EngineState,
        stack: &mut Stack,
        call: &Call,
        input: PipelineData,
    ) -> Result<PipelineData, ShellError> {
        let raw = call.has_flag(engine_state, stack, "raw")?;
        let append = call.has_flag(engine_state, stack, "append")?;
        let force = call.has_flag(engine_state, stack, "force")?;
        let progress = call.has_flag(engine_state, stack, "progress")?;

        let span = call.head;
        #[allow(deprecated)]
        let cwd = current_dir(engine_state, stack)?;

        let path_arg = call.req::<Spanned<PathBuf>>(engine_state, stack, 0)?;
        let path = Spanned {
            item: expand_path_with(path_arg.item, &cwd, true),
            span: path_arg.span,
        };

        let stderr_path = call
            .get_flag::<Spanned<PathBuf>>(engine_state, stack, "stderr")?
            .map(|arg| Spanned {
                item: expand_path_with(arg.item, cwd, true),
                span: arg.span,
            });

        let from_io_error = IoError::factory(span, path.item.as_path());
        match input {
            PipelineData::ByteStream(stream, metadata) => {
                check_saving_to_source_file(metadata.as_ref(), &path, stderr_path.as_ref())?;

                let (file, stderr_file) = get_files(&path, stderr_path.as_ref(), append, force)?;

                let size = stream.known_size();
                let signals = engine_state.signals();

                match stream.into_source() {
                    ByteStreamSource::Read(read) => {
                        stream_to_file(read, size, signals, file, span, progress)?;
                    }
                    ByteStreamSource::File(source) => {
                        stream_to_file(source, size, signals, file, span, progress)?;
                    }
                    #[cfg(feature = "os")]
                    ByteStreamSource::Child(mut child) => {
                        fn write_or_consume_stderr(
                            stderr: ChildPipe,
                            file: Option<File>,
                            span: Span,
                            signals: &Signals,
                            progress: bool,
                        ) -> Result<(), ShellError> {
                            if let Some(file) = file {
                                match stderr {
                                    ChildPipe::Pipe(pipe) => {
                                        stream_to_file(pipe, None, signals, file, span, progress)
                                    }
                                    ChildPipe::Tee(tee) => {
                                        stream_to_file(tee, None, signals, file, span, progress)
                                    }
                                }?
                            } else {
                                match stderr {
                                    ChildPipe::Pipe(mut pipe) => {
                                        io::copy(&mut pipe, &mut io::stderr())
                                    }
                                    ChildPipe::Tee(mut tee) => {
                                        io::copy(&mut tee, &mut io::stderr())
                                    }
                                }
                                .map_err(|err| IoError::new(err, span, None))?;
                            }
                            Ok(())
                        }

                        match (child.stdout.take(), child.stderr.take()) {
                            (Some(stdout), stderr) => {
                                // delegate a thread to redirect stderr to result.
                                let handler = stderr
                                    .map(|stderr| {
                                        let signals = signals.clone();
                                        thread::Builder::new().name("stderr saver".into()).spawn(
                                            move || {
                                                write_or_consume_stderr(
                                                    stderr,
                                                    stderr_file,
                                                    span,
                                                    &signals,
                                                    progress,
                                                )
                                            },
                                        )
                                    })
                                    .transpose()
                                    .map_err(&from_io_error)?;

                                let res = match stdout {
                                    ChildPipe::Pipe(pipe) => {
                                        stream_to_file(pipe, None, signals, file, span, progress)
                                    }
                                    ChildPipe::Tee(tee) => {
                                        stream_to_file(tee, None, signals, file, span, progress)
                                    }
                                };
                                if let Some(h) = handler {
                                    h.join().map_err(|err| ShellError::ExternalCommand {
                                        label: "Fail to receive external commands stderr message"
                                            .to_string(),
                                        help: format!("{err:?}"),
                                        span,
                                    })??;
                                }
                                res?;
                            }
                            (None, Some(stderr)) => {
                                write_or_consume_stderr(
                                    stderr,
                                    stderr_file,
                                    span,
                                    signals,
                                    progress,
                                )?;
                            }
                            (None, None) => {}
                        };

                        child.wait()?;
                    }
                }

                Ok(PipelineData::Empty)
            }
            PipelineData::ListStream(ls, pipeline_metadata)
                if raw || prepare_path(&path, append, force)?.0.extension().is_none() =>
            {
                check_saving_to_source_file(
                    pipeline_metadata.as_ref(),
                    &path,
                    stderr_path.as_ref(),
                )?;

                let (mut file, _) = get_files(&path, stderr_path.as_ref(), append, force)?;
                for val in ls {
                    file.write_all(&value_to_bytes(val)?)
                        .map_err(&from_io_error)?;
                    file.write_all("\n".as_bytes()).map_err(&from_io_error)?;
                }
                file.flush().map_err(&from_io_error)?;

                Ok(PipelineData::empty())
            }
            input => {
                // It's not necessary to check if we are saving to the same file if this is a
                // collected value, and not a stream
                if !matches!(input, PipelineData::Value(..) | PipelineData::Empty) {
                    check_saving_to_source_file(
                        input.metadata().as_ref(),
                        &path,
                        stderr_path.as_ref(),
                    )?;
                }

                let bytes =
                    input_to_bytes(input, Path::new(&path.item), raw, engine_state, stack, span)?;

                // Only open file after successful conversion
                let (mut file, _) = get_files(&path, stderr_path.as_ref(), append, force)?;

                file.write_all(&bytes).map_err(&from_io_error)?;
                file.flush().map_err(&from_io_error)?;

                Ok(PipelineData::empty())
            }
        }
    }

    fn examples(&self) -> Vec<Example> {
        vec![
            Example {
                description: "Save a string to foo.txt in the current directory",
                example: r#"'save me' | save foo.txt"#,
                result: None,
            },
            Example {
                description: "Append a string to the end of foo.txt",
                example: r#"'append me' | save --append foo.txt"#,
                result: None,
            },
            Example {
                description: "Save a record to foo.json in the current directory",
                example: r#"{ a: 1, b: 2 } | save foo.json"#,
                result: None,
            },
            Example {
                description: "Save a running program's stderr to foo.txt",
                example: r#"do -i {} | save foo.txt --stderr foo.txt"#,
                result: None,
            },
            Example {
                description: "Save a running program's stderr to separate file",
                example: r#"do -i {} | save foo.txt --stderr bar.txt"#,
                result: None,
            },
            Example {
                description: "Show the extensions for which the `save` command will automatically serialize",
                example: r#"scope commands
    | where name starts-with "to "
    | insert extension { get name | str replace -r "^to " "" | $"*.($in)" }
    | select extension name
    | rename extension command
"#,
                result: None,
            },
        ]
    }

    fn pipe_redirection(&self) -> (Option<OutDest>, Option<OutDest>) {
        (Some(OutDest::PipeSeparate), Some(OutDest::PipeSeparate))
    }
}

fn saving_to_source_file_error(dest: &Spanned<PathBuf>) -> ShellError {
    ShellError::GenericError {
        error: "pipeline input and output are the same file".into(),
        msg: format!(
            "can't save output to '{}' while it's being read",
            dest.item.display()
        ),
        span: Some(dest.span),
        help: Some(
            "insert a `collect` command in the pipeline before `save` (see `help collect`).".into(),
        ),
        inner: vec![],
    }
}

fn check_saving_to_source_file(
    metadata: Option<&PipelineMetadata>,
    dest: &Spanned<PathBuf>,
    stderr_dest: Option<&Spanned<PathBuf>>,
) -> Result<(), ShellError> {
    let Some(DataSource::FilePath(source)) = metadata.map(|meta| &meta.data_source) else {
        return Ok(());
    };

    if &dest.item == source {
        return Err(saving_to_source_file_error(dest));
    }

    if let Some(dest) = stderr_dest {
        if &dest.item == source {
            return Err(saving_to_source_file_error(dest));
        }
    }

    Ok(())
}

/// Convert [`PipelineData`] bytes to write in file, possibly converting
/// to format of output file
fn input_to_bytes(
    input: PipelineData,
    path: &Path,
    raw: bool,
    engine_state: &EngineState,
    stack: &mut Stack,
    span: Span,
) -> Result<Vec<u8>, ShellError> {
    let ext = if raw {
        None
    } else if let PipelineData::ByteStream(..) = input {
        None
    } else if let PipelineData::Value(Value::String { .. }, ..) = input {
        None
    } else {
        path.extension()
            .map(|name| name.to_string_lossy().to_string())
    };

    let input = if let Some(ext) = ext {
        convert_to_extension(engine_state, &ext, stack, input, span)?
    } else {
        input
    };

    value_to_bytes(input.into_value(span)?)
}

/// Convert given data into content of file of specified extension if
/// corresponding `to` command exists. Otherwise attempt to convert
/// data to bytes as is
fn convert_to_extension(
    engine_state: &EngineState,
    extension: &str,
    stack: &mut Stack,
    input: PipelineData,
    span: Span,
) -> Result<PipelineData, ShellError> {
    if let Some(decl_id) = engine_state.find_decl(format!("to {extension}").as_bytes(), &[]) {
        let decl = engine_state.get_decl(decl_id);
        if let Some(block_id) = decl.block_id() {
            let block = engine_state.get_block(block_id);
            let eval_block = get_eval_block(engine_state);
            eval_block(engine_state, stack, block, input)
        } else {
            let call = ast::Call::new(span);
            decl.run(engine_state, stack, &(&call).into(), input)
        }
    } else {
        Ok(input)
    }
}

/// Convert [`Value::String`] [`Value::Binary`] or [`Value::List`] into [`Vec`] of bytes
///
/// Propagates [`Value::Error`] and creates error otherwise
fn value_to_bytes(value: Value) -> Result<Vec<u8>, ShellError> {
    match value {
        Value::String { val, .. } => Ok(val.into_bytes()),
        Value::Binary { val, .. } => Ok(val),
        Value::List { vals, .. } => {
            let val = vals
                .into_iter()
                .map(Value::coerce_into_string)
                .collect::<Result<Vec<String>, ShellError>>()?
                .join("\n")
                + "\n";

            Ok(val.into_bytes())
        }
        // Propagate errors by explicitly matching them before the final case.
        Value::Error { error, .. } => Err(*error),
        other => Ok(other.coerce_into_string()?.into_bytes()),
    }
}

/// Convert string path to [`Path`] and [`Span`] and check if this path
/// can be used with given flags
fn prepare_path(
    path: &Spanned<PathBuf>,
    append: bool,
    force: bool,
) -> Result<(&Path, Span), ShellError> {
    let span = path.span;
    let path = &path.item;

    if !(force || append) && path.exists() {
        Err(ShellError::GenericError {
            error: "Destination file already exists".into(),
            msg: format!(
                "Destination file '{}' already exists",
                path.to_string_lossy()
            ),
            span: Some(span),
            help: Some("you can use -f, --force to force overwriting the destination".into()),
            inner: vec![],
        })
    } else {
        Ok((path, span))
    }
}

fn open_file(path: &Path, span: Span, append: bool) -> Result<File, ShellError> {
    let file: Result<File, nu_protocol::shell_error::io::ErrorKind> = match (append, path.exists())
    {
        (true, true) => std::fs::OpenOptions::new()
            .append(true)
            .open(path)
            .map_err(|err| err.into()),
        _ => {
            // This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)
            // A TOCTOU problem exists here, which may cause wrong error message to be shown
            #[cfg(target_os = "windows")]
            if path.is_dir() {
                #[allow(
                    deprecated,
                    reason = "we don't get a IsADirectory error, so we need to provide it"
                )]
                Err(nu_protocol::shell_error::io::ErrorKind::from_std(
                    std::io::ErrorKind::IsADirectory,
                ))
            } else {
                std::fs::File::create(path).map_err(|err| err.into())
            }
            #[cfg(not(target_os = "windows"))]
            std::fs::File::create(path).map_err(|err| err.into())
        }
    };

    file.map_err(|err_kind| ShellError::Io(IoError::new(err_kind, span, PathBuf::from(path))))
}

/// Get output file and optional stderr file
fn get_files(
    path: &Spanned<PathBuf>,
    stderr_path: Option<&Spanned<PathBuf>>,
    append: bool,
    force: bool,
) -> Result<(File, Option<File>), ShellError> {
    // First check both paths
    let (path, path_span) = prepare_path(path, append, force)?;
    let stderr_path_and_span = stderr_path
        .as_ref()
        .map(|stderr_path| prepare_path(stderr_path, append, force))
        .transpose()?;

    // Only if both files can be used open and possibly truncate them
    let file = open_file(path, path_span, append)?;

    let stderr_file = stderr_path_and_span
        .map(|(stderr_path, stderr_path_span)| {
            if path == stderr_path {
                Err(ShellError::GenericError {
                    error: "input and stderr input to same file".into(),
                    msg: "can't save both input and stderr input to the same file".into(),
                    span: Some(stderr_path_span),
                    help: Some("you should use `o+e> file` instead".into()),
                    inner: vec![],
                })
            } else {
                open_file(stderr_path, stderr_path_span, append)
            }
        })
        .transpose()?;

    Ok((file, stderr_file))
}

fn stream_to_file(
    source: impl Read,
    known_size: Option<u64>,
    signals: &Signals,
    mut file: File,
    span: Span,
    progress: bool,
) -> Result<(), ShellError> {
    // TODO: maybe we can get a path in here
    let from_io_error = IoError::factory(span, None);

    // https://github.com/nushell/nushell/pull/9377 contains the reason for not using `BufWriter`
    if progress {
        let mut bytes_processed = 0;

        let mut bar = progress_bar::NuProgressBar::new(known_size);

        let mut last_update = Instant::now();

        let mut reader = BufReader::new(source);

        let res = loop {
            if let Err(err) = signals.check(span) {
                bar.abandoned_msg("# Cancelled #".to_owned());
                return Err(err);
            }

            match reader.fill_buf() {
                Ok(&[]) => break Ok(()),
                Ok(buf) => {
                    file.write_all(buf).map_err(&from_io_error)?;
                    let len = buf.len();
                    reader.consume(len);
                    bytes_processed += len as u64;
                    if last_update.elapsed() >= Duration::from_millis(75) {
                        bar.update_bar(bytes_processed);
                        last_update = Instant::now();
                    }
                }
                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
                Err(e) => break Err(e),
            }
        };

        // If the process failed, stop the progress bar with an error message.
        if let Err(err) = res {
            let _ = file.flush();
            bar.abandoned_msg("# Error while saving #".to_owned());
            Err(from_io_error(err).into())
        } else {
            file.flush().map_err(&from_io_error)?;
            Ok(())
        }
    } else {
        copy_with_signals(source, file, span, signals)?;
        Ok(())
    }
}
