use std::borrow::Cow;
use std::collections::BTreeMap;
use std::error::Error;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::str;
use std::sync::{Arc, Mutex};

use crate::env::{
    force_pass, force_update_snapshots, get_cargo_workspace, get_output_behavior,
    get_snapshot_update_behavior, memoize_snapshot_file, OutputBehavior, SnapshotUpdate,
};
use crate::output::{print_snapshot_diff_with_title, print_snapshot_summary_with_title};
use crate::settings::Settings;
use crate::snapshot::{MetaData, PendingInlineSnapshot, Snapshot, SnapshotContents};
use crate::utils::{path_to_storage, style};

lazy_static::lazy_static! {
    static ref TEST_NAME_COUNTERS: Mutex<BTreeMap<String, usize>> =
        Mutex::new(BTreeMap::new());
    static ref TEST_NAME_CLASH_DETECTION: Mutex<BTreeMap<String, bool>> =
        Mutex::new(BTreeMap::new());
}

// This macro is basically eprintln but without being captured and
// hidden by the test runner.
macro_rules! elog {
    () => (write!(std::io::stderr()).ok());
    ($($arg:tt)*) => ({
        writeln!(std::io::stderr(), $($arg)*).ok();
    })
}

/// Special marker to use an automatic name.
///
/// This can be passed as a snapshot name in a macro to explicitly tell
/// insta to use the automatic name.  This is useful in ambiguous syntax
/// situations.
#[derive(Debug)]
pub struct AutoName;

impl From<AutoName> for ReferenceValue<'static> {
    fn from(_value: AutoName) -> ReferenceValue<'static> {
        ReferenceValue::Named(None)
    }
}

impl From<Option<String>> for ReferenceValue<'static> {
    fn from(value: Option<String>) -> ReferenceValue<'static> {
        ReferenceValue::Named(value.map(Cow::Owned))
    }
}

impl From<String> for ReferenceValue<'static> {
    fn from(value: String) -> ReferenceValue<'static> {
        ReferenceValue::Named(Some(Cow::Owned(value)))
    }
}

impl<'a> From<Option<&'a str>> for ReferenceValue<'a> {
    fn from(value: Option<&'a str>) -> ReferenceValue<'a> {
        ReferenceValue::Named(value.map(Cow::Borrowed))
    }
}

impl<'a> From<&'a str> for ReferenceValue<'a> {
    fn from(value: &'a str) -> ReferenceValue<'a> {
        ReferenceValue::Named(Some(Cow::Borrowed(value)))
    }
}

pub enum ReferenceValue<'a> {
    Named(Option<Cow<'a, str>>),
    Inline(&'a str),
}

fn is_doctest(function_name: &str) -> bool {
    function_name.starts_with("rust_out::main::_doctest")
}

fn detect_snapshot_name(
    function_name: &str,
    module_path: &str,
    inline: bool,
    is_doctest: bool,
) -> Result<String, &'static str> {
    let mut name = function_name;

    // simplify doctest names
    if is_doctest && !inline {
        panic!("Cannot determine reliable names for snapshot in doctests.  Please use explicit names instead.");
    }

    // clean test name first
    name = name.rsplit("::").next().unwrap();
    let mut test_prefixed = false;
    if name.starts_with("test_") {
        name = &name[5..];
        test_prefixed = true;
    }

    // next check if we need to add a suffix
    let name = add_suffix_to_snapshot_name(Cow::Borrowed(name));
    let key = format!("{}::{}", module_path.replace("::", "__"), name);

    // because fn foo and fn test_foo end up with the same snapshot name we
    // make sure we detect this here and raise an error.
    let mut name_clash_detection = TEST_NAME_CLASH_DETECTION
        .lock()
        .unwrap_or_else(|x| x.into_inner());
    match name_clash_detection.get(&key) {
        None => {
            name_clash_detection.insert(key.clone(), test_prefixed);
        }
        Some(&was_test_prefixed) => {
            if was_test_prefixed != test_prefixed {
                panic!(
                    "Insta snapshot name clash detected between '{}' \
                     and 'test_{}' in '{}'. Rename one function.",
                    name, name, module_path
                );
            }
        }
    }

    // if the snapshot name clashes we need to increment a counter.
    // we really do not care about poisoning here.
    let mut counters = TEST_NAME_COUNTERS.lock().unwrap_or_else(|x| x.into_inner());
    let test_idx = counters.get(&key).cloned().unwrap_or(0) + 1;
    let rv = if test_idx == 1 {
        name.to_string()
    } else {
        format!("{}-{}", name, test_idx)
    };
    counters.insert(key, test_idx);

    Ok(rv)
}

/// If there is a suffix on the settings, append it to the snapshot name.
fn add_suffix_to_snapshot_name(name: Cow<'_, str>) -> Cow<'_, str> {
    Settings::with(|settings| {
        settings
            .snapshot_suffix()
            .map(|suffix| Cow::Owned(format!("{}@{}", name, suffix)))
            .unwrap_or_else(|| name)
    })
}

fn get_snapshot_filename(
    module_path: &str,
    assertion_file: &str,
    snapshot_name: &str,
    cargo_workspace: &Path,
    base: &str,
    is_doctest: bool,
) -> PathBuf {
    let root = Path::new(cargo_workspace);
    let base = Path::new(base);
    Settings::with(|settings| {
        root.join(base.parent().unwrap())
            .join(settings.snapshot_path())
            .join({
                use std::fmt::Write;
                let mut f = String::new();
                if settings.prepend_module_to_snapshot() {
                    if is_doctest {
                        write!(
                            &mut f,
                            "doctest_{}__",
                            Path::new(assertion_file)
                                .file_name()
                                .unwrap()
                                .to_string_lossy()
                                .replace('.', "_")
                        )
                        .unwrap();
                    } else {
                        write!(&mut f, "{}__", module_path.replace("::", "__")).unwrap();
                    }
                }
                write!(
                    &mut f,
                    "{}.snap",
                    snapshot_name.replace('/', "__").replace('\\', "__")
                )
                .unwrap();
                f
            })
    })
}

#[derive(Debug)]
struct SnapshotAssertionContext<'a> {
    cargo_workspace: Arc<PathBuf>,
    module_path: &'a str,
    snapshot_name: Option<Cow<'a, str>>,
    snapshot_file: Option<PathBuf>,
    old_snapshot: Option<Snapshot>,
    pending_snapshots_path: Option<PathBuf>,
    assertion_file: &'a str,
    assertion_line: u32,
    is_doctest: bool,
}

impl<'a> SnapshotAssertionContext<'a> {
    fn prepare(
        refval: ReferenceValue<'a>,
        manifest_dir: &'a str,
        function_name: &'a str,
        module_path: &'a str,
        assertion_file: &'a str,
        assertion_line: u32,
    ) -> Result<SnapshotAssertionContext<'a>, Box<dyn Error>> {
        let cargo_workspace = get_cargo_workspace(manifest_dir);
        let snapshot_name;
        let mut snapshot_file = None;
        let mut old_snapshot = None;
        let mut pending_snapshots_path = None;
        let is_doctest = is_doctest(function_name);

        match refval {
            ReferenceValue::Named(name) => {
                let name = match name {
                    Some(name) => add_suffix_to_snapshot_name(name),
                    None => detect_snapshot_name(function_name, module_path, false, is_doctest)
                        .unwrap()
                        .into(),
                };
                let file = get_snapshot_filename(
                    module_path,
                    assertion_file,
                    &name,
                    &cargo_workspace,
                    assertion_file,
                    is_doctest,
                );
                if fs::metadata(&file).is_ok() {
                    old_snapshot = Some(Snapshot::from_file(&file)?);
                }
                snapshot_name = Some(name);
                snapshot_file = Some(file);
            }
            ReferenceValue::Inline(contents) => {
                snapshot_name = detect_snapshot_name(function_name, module_path, true, is_doctest)
                    .ok()
                    .map(Cow::Owned);
                let mut pending_file = cargo_workspace.join(assertion_file);
                pending_file.set_file_name(format!(
                    ".{}.pending-snap",
                    pending_file
                        .file_name()
                        .expect("no filename")
                        .to_str()
                        .expect("non unicode filename")
                ));
                pending_snapshots_path = Some(pending_file);
                old_snapshot = Some(Snapshot::from_components(
                    module_path.replace("::", "__"),
                    None,
                    MetaData::default(),
                    SnapshotContents::from_inline(contents),
                ));
            }
        };

        Ok(SnapshotAssertionContext {
            cargo_workspace,
            module_path,
            snapshot_name,
            snapshot_file,
            old_snapshot,
            pending_snapshots_path,
            assertion_file,
            assertion_line,
            is_doctest,
        })
    }

    /// Given a path returns the local path within the workspace.
    pub fn localize_path(&self, p: &Path) -> Option<PathBuf> {
        self.cargo_workspace
            .join(p)
            .canonicalize()
            .ok()
            .and_then(|s| {
                s.strip_prefix(self.cargo_workspace.as_path())
                    .ok()
                    .map(|x| x.to_path_buf())
            })
    }

    /// Creates the new snapshot from input values.
    pub fn new_snapshot(&self, contents: SnapshotContents, expr: &str) -> Snapshot {
        Snapshot::from_components(
            self.module_path.replace("::", "__"),
            self.snapshot_name.as_ref().map(|x| x.to_string()),
            Settings::with(|settings| MetaData {
                source: Some(path_to_storage(Path::new(self.assertion_file))),
                assertion_line: Some(self.assertion_line),
                description: settings.description().map(Into::into),
                expression: if settings.omit_expression() {
                    None
                } else {
                    Some(expr.to_string())
                },
                info: settings.info().map(ToOwned::to_owned),
                input_file: settings
                    .input_file()
                    .and_then(|x| self.localize_path(x))
                    .map(|x| path_to_storage(&x)),
            }),
            contents,
        )
    }

    /// Cleanup logic for passing snapshots.
    pub fn cleanup_passing(&self) -> Result<(), Box<dyn Error>> {
        // let's just make sure there are no more pending files lingering
        // around.
        if let Some(ref snapshot_file) = self.snapshot_file {
            let mut snapshot_file = snapshot_file.clone();
            snapshot_file.set_extension("snap.new");
            fs::remove_file(snapshot_file).ok();
        }

        // and add a null pending snapshot to a pending snapshot file if needed
        if let Some(ref pending_snapshots) = self.pending_snapshots_path {
            if fs::metadata(pending_snapshots).is_ok() {
                PendingInlineSnapshot::new(None, None, self.assertion_line)
                    .save(pending_snapshots)?;
            }
        }
        Ok(())
    }

    /// Writes the changes of the snapshot back.
    pub fn update_snapshot(
        &self,
        new_snapshot: Snapshot,
    ) -> Result<SnapshotUpdate, Box<dyn Error>> {
        let unseen = self
            .snapshot_file
            .as_ref()
            .map_or(false, |x| fs::metadata(x).is_ok());
        let should_print = get_output_behavior() != OutputBehavior::Nothing;
        let snapshot_update = get_snapshot_update_behavior(unseen);

        match snapshot_update {
            SnapshotUpdate::InPlace => {
                if let Some(ref snapshot_file) = self.snapshot_file {
                    new_snapshot.save(snapshot_file)?;
                    if should_print {
                        elog!(
                            "{} {}",
                            if unseen {
                                style("created previously unseen snapshot").green()
                            } else {
                                style("updated snapshot").green()
                            },
                            style(snapshot_file.display()).cyan().underlined(),
                        );
                    }
                } else if should_print {
                    elog!(
                        "{}",
                        style(
                            "error: cannot update inline snapshots in-place \
                        (https://github.com/mitsuhiko/insta/issues/272)"
                        )
                        .red()
                        .bold(),
                    );
                }
            }
            SnapshotUpdate::NewFile => {
                if let Some(ref snapshot_file) = self.snapshot_file {
                    let mut new_path = snapshot_file.to_path_buf();
                    new_path.set_extension("snap.new");
                    new_snapshot.save_new(&new_path)?;
                    if should_print {
                        elog!(
                            "{} {}",
                            style("stored new snapshot").green(),
                            style(new_path.display()).cyan().underlined(),
                        );
                    }
                } else if self.is_doctest {
                    if should_print {
                        elog!(
                            "{}",
                            style("warning: cannot update inline snapshots in doctests")
                                .red()
                                .bold(),
                        );
                    }
                } else {
                    PendingInlineSnapshot::new(
                        Some(new_snapshot),
                        self.old_snapshot.clone(),
                        self.assertion_line,
                    )
                    .save(self.pending_snapshots_path.as_ref().unwrap())?;
                }
            }
            SnapshotUpdate::NoUpdate => {}
        }

        Ok(snapshot_update)
    }
}

/// This prints the information about the snapshot
fn print_snapshot_info(ctx: &SnapshotAssertionContext, new_snapshot: &Snapshot) {
    match get_output_behavior() {
        OutputBehavior::Summary => {
            print_snapshot_summary_with_title(
                ctx.cargo_workspace.as_path(),
                new_snapshot,
                ctx.old_snapshot.as_ref(),
                ctx.assertion_line,
                ctx.snapshot_file.as_deref(),
            );
        }
        OutputBehavior::Diff => {
            print_snapshot_diff_with_title(
                ctx.cargo_workspace.as_path(),
                new_snapshot,
                ctx.old_snapshot.as_ref(),
                ctx.assertion_line,
                ctx.snapshot_file.as_deref(),
            );
        }
        _ => {}
    }
}

#[cfg(feature = "glob")]
macro_rules! print_or_panic {
    ($fail_fast:expr, $($tokens:tt)*) => {{
        if (!$fail_fast) {
            eprintln!($($tokens)*);
            eprintln!();
        } else {
            panic!($($tokens)*);
        }
    }}
}

/// Finalizes the assertion based on the update result.
fn finalize_assertion(ctx: &SnapshotAssertionContext, update_result: SnapshotUpdate) {
    // if we are in glob mode, we want to adjust the finalization
    // so that we do not show the hints immediately.
    let fail_fast = {
        #[cfg(feature = "glob")]
        {
            if let Some(top) = crate::glob::GLOB_STACK.lock().unwrap().last() {
                top.fail_fast
            } else {
                true
            }
        }
        #[cfg(not(feature = "glob"))]
        {
            true
        }
    };

    if fail_fast
        && update_result == SnapshotUpdate::NewFile
        && get_output_behavior() != OutputBehavior::Nothing
        && !ctx.is_doctest
    {
        println!(
            "{hint}",
            hint = style("To update snapshots run `cargo insta review`").dim(),
        );
    }

    if update_result != SnapshotUpdate::InPlace && !force_pass() {
        if fail_fast && get_output_behavior() != OutputBehavior::Nothing {
            println!(
                "{hint}",
                hint = style(
                    "Stopped on the first failure. Run `cargo insta test` to run all snapshots."
                )
                .dim(),
            );
        }

        // if we are in glob mode, count the failures and print the
        // errors instead of panicking.  The glob will then panic at
        // the end.
        #[cfg(feature = "glob")]
        {
            let mut stack = crate::glob::GLOB_STACK.lock().unwrap();
            if let Some(glob_collector) = stack.last_mut() {
                glob_collector.failed += 1;
                if update_result == SnapshotUpdate::NewFile
                    && get_output_behavior() != OutputBehavior::Nothing
                {
                    glob_collector.show_insta_hint = true;
                }

                print_or_panic!(
                    fail_fast,
                    "snapshot assertion from glob for '{}' failed in line {}",
                    ctx.snapshot_name.as_deref().unwrap_or("unnamed snapshot"),
                    ctx.assertion_line
                );
                return;
            }
        }

        panic!(
            "snapshot assertion for '{}' failed in line {}",
            ctx.snapshot_name.as_deref().unwrap_or("unnamed snapshot"),
            ctx.assertion_line
        );
    }
}

/// This function is invoked from the macros to run the main assertion logic.
///
/// This will create the assertion context, run the main logic to assert
/// on snapshots and write changes to the pending snapshot files.  It will
/// also print the necessary bits of information to the output and fail the
/// assertion with a panic if needed.
#[allow(clippy::too_many_arguments)]
pub fn assert_snapshot(
    refval: ReferenceValue<'_>,
    new_snapshot_value: &str,
    manifest_dir: &str,
    function_name: &str,
    module_path: &str,
    assertion_file: &str,
    assertion_line: u32,
    expr: &str,
) -> Result<(), Box<dyn Error>> {
    let ctx = SnapshotAssertionContext::prepare(
        refval,
        manifest_dir,
        function_name,
        module_path,
        assertion_file,
        assertion_line,
    )?;

    // apply filters if they are available
    #[cfg(feature = "filters")]
    let new_snapshot_value =
        Settings::with(|settings| settings.filters().apply_to(new_snapshot_value));

    let new_snapshot = ctx.new_snapshot(new_snapshot_value.into(), expr);

    // memoize the snapshot file if requested.
    if let Some(ref snapshot_file) = ctx.snapshot_file {
        memoize_snapshot_file(snapshot_file);
    }

    // pass if the snapshots are missing
    if ctx.old_snapshot.as_ref().map(|x| x.contents()) == Some(new_snapshot.contents()) {
        ctx.cleanup_passing()?;

        if force_update_snapshots() {
            ctx.update_snapshot(new_snapshot)?;
        }
    // otherwise print information and update snapshots.
    } else {
        print_snapshot_info(&ctx, &new_snapshot);
        let update_result = ctx.update_snapshot(new_snapshot)?;
        finalize_assertion(&ctx, update_result);
    }

    Ok(())
}

/// Test snapshots in doctests.
///
/// ```
/// insta::assert_debug_snapshot!("named", vec![1, 2, 3, 4, 5]);
/// ```
///
/// ```should_panic
/// insta::assert_debug_snapshot!(vec![1, 2, 3, 4, 5]);
/// ```
///
/// ```
/// let some_string = "Coucou je suis un joli bug";
/// insta::assert_snapshot!(some_string, @"Coucou je suis un joli bug");
/// ```
///
/// ```
/// let some_string = "Coucou je suis un joli bug";
/// insta::assert_snapshot!(some_string, @"Coucou je suis un joli bug");
/// ```
const _DOCTEST1: bool = false;
