//! Tests that verify rustfix applies the appropriate changes to a file.
//!
//! This test works by reading a series of `*.rs` files in the
//! `tests/everything` directory. For each `.rs` file, it runs `rustc` to
//! collect JSON diagnostics from the file. It feeds that JSON data into
//! rustfix and applies the recommended suggestions to the `.rs` file. It then
//! compares the result with the corresponding `.fixed.rs` file. If they don't
//! match, then the test fails.
//!
//! There are several debugging environment variables for this test that you can set:
//!
//! - `RUST_LOG=parse_and_replace=debug`: Print debug information.
//! - `RUSTFIX_TEST_BLESS=test-name.rs`: When given the name of a test, this
//!   will overwrite the `.json` and `.fixed.rs` files with the expected
//!   values. This can be used when adding a new test.
//! - `RUSTFIX_TEST_RECORD_JSON=1`:  Records the JSON output to
//!   `*.recorded.json` files. You can then move that to `.json` or whatever
//!   you need.
//! - `RUSTFIX_TEST_RECORD_FIXED_RUST=1`: Records the fixed result to
//!   `*.recorded.rs` files. You can then move that to `.rs` or whatever you
//!   need.

#![allow(clippy::disallowed_methods, clippy::print_stdout, clippy::print_stderr)]

use anyhow::{anyhow, ensure, Context, Error};
use rustfix::apply_suggestions;
use std::collections::HashSet;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use tempfile::tempdir;
use tracing::{debug, info, warn};

mod fixmode {
    pub const EVERYTHING: &str = "yolo";
}

mod settings {
    // can be set as env var to debug
    pub const CHECK_JSON: &str = "RUSTFIX_TEST_CHECK_JSON";
    pub const RECORD_JSON: &str = "RUSTFIX_TEST_RECORD_JSON";
    pub const RECORD_FIXED_RUST: &str = "RUSTFIX_TEST_RECORD_FIXED_RUST";
    pub const BLESS: &str = "RUSTFIX_TEST_BLESS";
}

static mut VERSION: (u32, bool) = (0, false);

// Temporarily copy from `cargo_test_macro::version`.
fn version() -> (u32, bool) {
    static INIT: std::sync::Once = std::sync::Once::new();
    INIT.call_once(|| {
        let output = Command::new("rustc")
            .arg("-V")
            .output()
            .expect("cargo should run");
        let stdout = std::str::from_utf8(&output.stdout).expect("utf8");
        let vers = stdout.split_whitespace().skip(1).next().unwrap();
        let is_nightly = option_env!("CARGO_TEST_DISABLE_NIGHTLY").is_none()
            && (vers.contains("-nightly") || vers.contains("-dev"));
        let minor = vers.split('.').skip(1).next().unwrap().parse().unwrap();
        unsafe { VERSION = (minor, is_nightly) }
    });
    unsafe { VERSION }
}

fn compile(file: &Path) -> Result<Output, Error> {
    let tmp = tempdir()?;

    let args: Vec<OsString> = vec![
        file.into(),
        "--error-format=json".into(),
        "--emit=metadata".into(),
        "--crate-name=rustfix_test".into(),
        "--out-dir".into(),
        tmp.path().into(),
    ];

    let res = Command::new(env::var_os("RUSTC").unwrap_or("rustc".into()))
        .args(&args)
        .env("CLIPPY_DISABLE_DOCS_LINKS", "true")
        .env_remove("RUST_LOG")
        .output()?;

    Ok(res)
}

fn compile_and_get_json_errors(file: &Path) -> Result<String, Error> {
    let res = compile(file)?;
    let stderr = String::from_utf8(res.stderr)?;
    if stderr.contains("is only accepted on the nightly compiler") {
        panic!("rustfix tests require a nightly compiler");
    }

    match res.status.code() {
        Some(0) | Some(1) | Some(101) => Ok(stderr),
        _ => Err(anyhow!(
            "failed with status {:?}: {}",
            res.status.code(),
            stderr
        )),
    }
}

fn compiles_without_errors(file: &Path) -> Result<(), Error> {
    let res = compile(file)?;

    match res.status.code() {
        Some(0) => Ok(()),
        _ => {
            info!(
                "file {:?} failed to compile:\n{}",
                file,
                String::from_utf8(res.stderr)?
            );
            Err(anyhow!(
                "failed with status {:?} (`env RUST_LOG=parse_and_replace=info` for more info)",
                res.status.code(),
            ))
        }
    }
}

fn diff(expected: &str, actual: &str) -> String {
    use similar::{ChangeTag, TextDiff};
    use std::fmt::Write;

    let mut res = String::new();
    let diff = TextDiff::from_lines(expected.trim(), actual.trim());

    let mut different = false;
    for op in diff.ops() {
        for change in diff.iter_changes(op) {
            let prefix = match change.tag() {
                ChangeTag::Equal => continue,
                ChangeTag::Insert => "+",
                ChangeTag::Delete => "-",
            };
            if !different {
                writeln!(&mut res, "differences found (+ == actual, - == expected):").unwrap();
                different = true;
            }
            write!(&mut res, "{} {}", prefix, change.value()).unwrap();
        }
    }
    if different {
        write!(&mut res, "").unwrap();
    }

    res
}

fn test_rustfix_with_file<P: AsRef<Path>>(file: P, mode: &str) -> Result<(), Error> {
    let file: &Path = file.as_ref();
    let json_file = file.with_extension("json");
    let fixed_file = file.with_extension("fixed.rs");

    let filter_suggestions = if mode == fixmode::EVERYTHING {
        rustfix::Filter::Everything
    } else {
        rustfix::Filter::MachineApplicableOnly
    };

    debug!("next up: {:?}", file);
    let code = fs::read_to_string(file)?;
    let errors = compile_and_get_json_errors(file)
        .with_context(|| format!("could not compile {}", file.display()))?;
    let suggestions =
        rustfix::get_suggestions_from_json(&errors, &HashSet::new(), filter_suggestions)
            .context("could not load suggestions")?;

    if std::env::var(settings::RECORD_JSON).is_ok() {
        fs::write(file.with_extension("recorded.json"), &errors)?;
    }

    if std::env::var(settings::CHECK_JSON).is_ok() {
        let expected_json = fs::read_to_string(&json_file)
            .with_context(|| format!("could not load json fixtures for {}", file.display()))?;
        let expected_suggestions =
            rustfix::get_suggestions_from_json(&expected_json, &HashSet::new(), filter_suggestions)
                .context("could not load expected suggestions")?;

        ensure!(
            expected_suggestions == suggestions,
            "got unexpected suggestions from clippy:\n{}",
            diff(
                &format!("{:?}", expected_suggestions),
                &format!("{:?}", suggestions)
            )
        );
    }

    let fixed = apply_suggestions(&code, &suggestions)
        .with_context(|| format!("could not apply suggestions to {}", file.display()))?
        .replace('\r', "");

    if std::env::var(settings::RECORD_FIXED_RUST).is_ok() {
        fs::write(file.with_extension("recorded.rs"), &fixed)?;
    }

    if let Some(bless_name) = std::env::var_os(settings::BLESS) {
        if bless_name == file.file_name().unwrap() {
            std::fs::write(&json_file, &errors)?;
            std::fs::write(&fixed_file, &fixed)?;
        }
    }

    let expected_fixed = fs::read_to_string(&fixed_file)
        .with_context(|| format!("could read fixed file for {}", file.display()))?
        .replace('\r', "");
    ensure!(
        fixed.trim() == expected_fixed.trim(),
        "file {} doesn't look fixed:\n{}",
        file.display(),
        diff(fixed.trim(), expected_fixed.trim())
    );

    compiles_without_errors(&fixed_file)?;

    Ok(())
}

fn get_fixture_files(p: &str) -> Result<Vec<PathBuf>, Error> {
    Ok(fs::read_dir(p)?
        .map(|e| e.unwrap().path())
        .filter(|p| p.is_file())
        .filter(|p| {
            let x = p.to_string_lossy();
            x.ends_with(".rs") && !x.ends_with(".fixed.rs") && !x.ends_with(".recorded.rs")
        })
        .collect())
}

fn assert_fixtures(dir: &str, mode: &str) {
    let files = get_fixture_files(dir)
        .with_context(|| format!("couldn't load dir `{dir}`"))
        .unwrap();
    let mut failures = 0;

    let is_not_nightly = !version().1;

    for file in &files {
        if file
            .file_stem()
            .unwrap()
            .to_str()
            .unwrap()
            .ends_with(".nightly")
            && is_not_nightly
        {
            info!("skipped: {file:?}");
            continue;
        }
        if let Err(err) = test_rustfix_with_file(file, mode) {
            println!("failed: {}", file.display());
            warn!("{:?}", err);
            failures += 1;
        }
        info!("passed: {:?}", file);
    }

    if failures > 0 {
        panic!(
            "{} out of {} fixture asserts failed\n\
             (run with `env RUST_LOG=parse_and_replace=info` to get more details)",
            failures,
            files.len(),
        );
    }
}

#[test]
fn everything() {
    tracing_subscriber::fmt::init();
    assert_fixtures("./tests/everything", fixmode::EVERYTHING);
}
