// Run clippy on a fixed set of crates and collect the warnings.
// This helps observing the impact clippy changes have on a set of real-world code (and not just our
// testsuite).
//
// When a new lint is introduced, we can search the results for new warnings and check for false
// positives.

#![feature(iter_collect_into)]
#![warn(
    trivial_casts,
    trivial_numeric_casts,
    rust_2018_idioms,
    unused_lifetimes,
    unused_qualifications
)]
#![allow(
    clippy::collapsible_else_if,
    clippy::needless_borrows_for_generic_args,
    clippy::module_name_repetitions,
    clippy::literal_string_with_formatting_args
)]

mod config;
mod driver;
mod input;
mod json;
mod output;
mod popular_crates;
mod recursive;

use crate::config::{Commands, LintcheckConfig, OutputFormat};
use crate::recursive::LintcheckServer;

use std::env::consts::EXE_SUFFIX;
use std::io::{self};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::{env, fs};

use cargo_metadata::Message;
use input::read_crates;
use output::{ClippyCheckOutput, ClippyWarning, RustcIce};
use rayon::prelude::*;

#[must_use]
pub fn target_dir() -> String {
    env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_owned())
}

fn lintcheck_sources() -> String {
    format!("{}/lintcheck/sources", target_dir())
}

/// Represents the actual source code of a crate that we ran "cargo clippy" on
#[derive(Debug)]
struct Crate {
    version: String,
    name: String,
    // path to the extracted sources that clippy can check
    path: PathBuf,
    options: Option<Vec<String>>,
    base_url: String,
}

impl Crate {
    /// Run `cargo clippy` on the `Crate` and collect and return all the lint warnings that clippy
    /// issued
    #[expect(clippy::too_many_lines)]
    fn run_clippy_lints(
        &self,
        clippy_driver_path: &Path,
        target_dir_index: &AtomicUsize,
        total_crates_to_lint: usize,
        config: &LintcheckConfig,
        lint_levels_args: &[String],
        server: Option<&LintcheckServer>,
    ) -> Vec<ClippyCheckOutput> {
        // advance the atomic index by one
        let index = target_dir_index.fetch_add(1, Ordering::SeqCst);
        // "loop" the index within 0..thread_limit
        let thread_index = index % config.max_jobs;
        let perc = (index * 100) / total_crates_to_lint;

        if config.max_jobs == 1 {
            println!(
                "{index}/{total_crates_to_lint} {perc}% Linting {} {}",
                &self.name, &self.version
            );
        } else {
            println!(
                "{index}/{total_crates_to_lint} {perc}% Linting {} {} in target dir {thread_index:?}",
                &self.name, &self.version
            );
        }

        let cargo_home = env!("CARGO_HOME");

        // `src/lib.rs` -> `target/lintcheck/sources/crate-1.2.3/src/lib.rs`
        let remap_relative = format!("={}", self.path.display());
        // Fallback for other sources, `~/.cargo/...` -> `$CARGO_HOME/...`
        let remap_cargo_home = format!("{cargo_home}=$CARGO_HOME");
        // `~/.cargo/registry/src/index.crates.io-6f17d22bba15001f/crate-2.3.4/src/lib.rs`
        //     -> `crate-2.3.4/src/lib.rs`
        let remap_crates_io = format!("{cargo_home}/registry/src/index.crates.io-6f17d22bba15001f/=");

        let mut clippy_args = vec![
            "--remap-path-prefix",
            &remap_relative,
            "--remap-path-prefix",
            &remap_cargo_home,
            "--remap-path-prefix",
            &remap_crates_io,
        ];

        if let Some(options) = &self.options {
            for opt in options {
                clippy_args.push(opt);
            }
        }

        clippy_args.extend(lint_levels_args.iter().map(String::as_str));

        let mut cmd;

        if config.perf {
            cmd = Command::new("perf");
            let perf_data_filename = get_perf_data_filename(&self.path);
            cmd.args(&[
                "record",
                "-e",
                "instructions", // Only count instructions
                "-g",           // Enable call-graph, useful for flamegraphs and produces richer reports
                "--quiet",      // Do not tamper with lintcheck's normal output
                "--compression-level=22",
                "--freq=3000", // Slow down program to capture all events
                "-o",
                &perf_data_filename,
                "--",
                "cargo",
            ]);
        } else {
            cmd = Command::new("cargo");
        }

        cmd.arg(if config.fix { "fix" } else { "check" })
            .arg("--quiet")
            .current_dir(&self.path)
            .env("CLIPPY_ARGS", clippy_args.join("__CLIPPY_HACKERY__"))
            .env("CLIPPY_DISABLE_DOCS_LINKS", "1");

        if let Some(server) = server {
            // `cargo clippy` is a wrapper around `cargo check` that mainly sets `RUSTC_WORKSPACE_WRAPPER` to
            // `clippy-driver`. We do the same thing here with a couple changes:
            //
            // `RUSTC_WRAPPER` is used instead of `RUSTC_WORKSPACE_WRAPPER` so that we can lint all crate
            // dependencies rather than only workspace members
            //
            // The wrapper is set to `lintcheck` itself so we can force enable linting and ignore certain crates
            // (see `crate::driver`)
            let status = cmd
                .env("CARGO_TARGET_DIR", shared_target_dir("recursive"))
                .env("RUSTC_WRAPPER", env::current_exe().unwrap())
                // Pass the absolute path so `crate::driver` can find `clippy-driver`, as it's executed in various
                // different working directories
                .env("CLIPPY_DRIVER", clippy_driver_path)
                .env("LINTCHECK_SERVER", server.local_addr.to_string())
                .status()
                .expect("failed to run cargo");

            assert_eq!(status.code(), Some(0));

            return Vec::new();
        }

        if !config.fix && !config.perf {
            cmd.arg("--message-format=json");
        }

        let shared_target_dir = shared_target_dir(&format!("_{thread_index:?}"));
        let all_output = cmd
            // use the looping index to create individual target dirs
            .env("CARGO_TARGET_DIR", shared_target_dir.as_os_str())
            // Roughly equivalent to `cargo clippy`/`cargo clippy --fix`
            .env("RUSTC_WORKSPACE_WRAPPER", clippy_driver_path)
            .output()
            .unwrap();
        let stdout = String::from_utf8_lossy(&all_output.stdout);
        let stderr = String::from_utf8_lossy(&all_output.stderr);
        let status = &all_output.status;

        if !status.success() {
            eprintln!(
                "\nWARNING: bad exit status after checking {} {} \n",
                self.name, self.version
            );
        }

        if config.fix {
            if let Some(stderr) = stderr
                .lines()
                .find(|line| line.contains("failed to automatically apply fixes suggested by rustc to crate"))
            {
                let subcrate = &stderr[63..];
                println!(
                    "ERROR: failed to apply some suggestion to {} / to (sub)crate {subcrate}",
                    self.name
                );
            }
            // fast path, we don't need the warnings anyway
            return Vec::new();
        }

        // We don't want to keep target directories if benchmarking
        if config.perf {
            let _ = fs::remove_dir_all(&shared_target_dir);
        }

        // get all clippy warnings and ICEs
        let mut entries: Vec<ClippyCheckOutput> = Message::parse_stream(stdout.as_bytes())
            .filter_map(|msg| match msg {
                Ok(Message::CompilerMessage(message)) => ClippyWarning::new(
                    normalize_diag(message.message, shared_target_dir.to_str().unwrap()),
                    &self.base_url,
                    &self.name,
                ),
                _ => None,
            })
            .map(ClippyCheckOutput::ClippyWarning)
            .collect();

        if let Some(ice) = RustcIce::from_stderr_and_status(&self.name, *status, &stderr) {
            entries.push(ClippyCheckOutput::RustcIce(ice));
        } else if !status.success() {
            println!("non-ICE bad exit status for {} {}: {}", self.name, self.version, stderr);
        }

        entries
    }
}

/// The target directory can sometimes be stored in the file name of spans.
/// This is problematic since the directory in constructed from the thread
/// ID and also used in our CI to determine if two lint emissions are the
/// same or not. This function simply normalizes the `_<thread_id>` to `_*`.
fn normalize_diag(
    mut message: cargo_metadata::diagnostic::Diagnostic,
    thread_target_dir: &str,
) -> cargo_metadata::diagnostic::Diagnostic {
    let mut dir_found = false;
    message
        .spans
        .iter_mut()
        .filter(|span| span.file_name.starts_with(thread_target_dir))
        .for_each(|span| {
            dir_found = true;
            span.file_name
                .replace_range(0..thread_target_dir.len(), shared_target_dir("_*").to_str().unwrap());
        });

    if dir_found && let Some(rendered) = &mut message.rendered {
        *rendered = rendered.replace(thread_target_dir, shared_target_dir("_*").to_str().unwrap());
    }
    message
}

/// Builds clippy inside the repo to make sure we have a clippy executable we can use.
fn build_clippy(release_build: bool) -> String {
    let mut build_cmd = Command::new("cargo");
    build_cmd.args([
        "run",
        "--bin=clippy-driver",
        if release_build { "-r" } else { "" },
        "--",
        "--version",
    ]);

    if release_build {
        build_cmd.env("CARGO_PROFILE_RELEASE_DEBUG", "true");
    }

    let output = build_cmd.stderr(Stdio::inherit()).output().unwrap();

    if !output.status.success() {
        eprintln!("Error: Failed to compile Clippy!");
        std::process::exit(1);
    }
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn main() {
    // We're being executed as a `RUSTC_WRAPPER` as part of `--recursive`
    if let Ok(addr) = env::var("LINTCHECK_SERVER") {
        driver::drive(&addr);
    }

    // assert that we launch lintcheck from the repo root (via cargo lintcheck)
    if fs::metadata("lintcheck/Cargo.toml").is_err() {
        eprintln!("lintcheck needs to be run from clippy's repo root!\nUse `cargo lintcheck` alternatively.");
        std::process::exit(3);
    }

    let config = LintcheckConfig::new();

    match config.subcommand {
        Some(Commands::Diff {
            old,
            new,
            truncate,
            write_summary,
        }) => json::diff(&old, &new, truncate, write_summary),
        Some(Commands::Popular { output, number }) => popular_crates::fetch(output, number).unwrap(),
        None => lintcheck(config),
    }
}

#[expect(clippy::too_many_lines)]
fn lintcheck(config: LintcheckConfig) {
    let clippy_ver = build_clippy(config.perf);
    let clippy_driver_path = fs::canonicalize(format!(
        "{}/{}/clippy-driver{EXE_SUFFIX}",
        target_dir(),
        if config.perf { "release" } else { "debug" }
    ))
    .unwrap();

    // assert that clippy is found
    assert!(
        clippy_driver_path.is_file(),
        "{}/{}/clippy-driver binary not found! {}",
        target_dir(),
        if config.perf { "release" } else { "debug" },
        clippy_driver_path.display()
    );

    // download and extract the crates, then run clippy on them and collect clippy's warnings
    // flatten into one big list of warnings

    let (crates, recursive_options) = read_crates(&config.sources_toml_path);

    let counter = AtomicUsize::new(1);
    let mut lint_level_args: Vec<String> = vec!["--cap-lints=allow".into()];
    if config.lint_filter.is_empty() {
        let groups = if config.all_lints {
            &[
                "clippy::all",
                "clippy::cargo",
                "clippy::nursery",
                "clippy::pedantic",
                "clippy::restriction",
            ][..]
        } else {
            &["clippy::all", "clippy::pedantic"]
        };
        groups
            .iter()
            .map(|group| format!("--force-warn={group}"))
            .collect_into(&mut lint_level_args);
    } else {
        config
            .lint_filter
            .iter()
            .map(|filter| {
                let mut filter = filter.clone();
                filter.insert_str(0, "--force-warn=");
                filter
            })
            .collect_into(&mut lint_level_args);
    }

    let crates: Vec<Crate> = crates
        .into_iter()
        .filter(|krate| {
            if let Some(only_one_crate) = &config.only {
                krate.name == *only_one_crate
            } else {
                true
            }
        })
        .map(|krate| krate.download_and_prepare())
        .collect();

    if crates.is_empty() {
        eprintln!(
            "ERROR: could not find crate '{}' in lintcheck/lintcheck_crates.toml",
            config.only.unwrap(),
        );
        std::process::exit(1);
    }

    // run parallel with rayon

    // This helps when we check many small crates with dep-trees that don't have a lot of branches in
    // order to achieve some kind of parallelism

    rayon::ThreadPoolBuilder::new()
        .num_threads(config.max_jobs)
        .build_global()
        .unwrap();

    let server = config.recursive.then(|| {
        let _: io::Result<()> = fs::remove_dir_all(format!("{}/lintcheck/shared_target_dir/recursive", target_dir()));

        LintcheckServer::spawn(recursive_options)
    });

    let mut clippy_entries: Vec<ClippyCheckOutput> = crates
        .par_iter()
        .flat_map(|krate| {
            krate.run_clippy_lints(
                &clippy_driver_path,
                &counter,
                crates.len(),
                &config,
                &lint_level_args,
                server.as_ref(),
            )
        })
        .collect();

    if let Some(server) = server {
        let server_clippy_entries = server.warnings().map(ClippyCheckOutput::ClippyWarning);

        clippy_entries.extend(server_clippy_entries);
    }

    // if we are in --fix mode, don't change the log files, terminate here
    if config.fix {
        return;
    }

    // split up warnings and ices
    let mut warnings: Vec<ClippyWarning> = vec![];
    let mut raw_ices: Vec<RustcIce> = vec![];
    for entry in clippy_entries {
        if let ClippyCheckOutput::ClippyWarning(x) = entry {
            warnings.push(x);
        } else if let ClippyCheckOutput::RustcIce(x) = entry {
            raw_ices.push(x);
        }
    }

    let text = match config.format {
        OutputFormat::Text | OutputFormat::Markdown => {
            output::summarize_and_print_changes(&warnings, &raw_ices, clippy_ver, &config)
        },
        OutputFormat::Json => {
            if !raw_ices.is_empty() {
                for ice in raw_ices {
                    println!("{ice}");
                }
                panic!("Some crates ICEd");
            }

            json::output(warnings)
        },
    };

    println!("Writing logs to {}", config.lintcheck_results_path.display());
    fs::create_dir_all(config.lintcheck_results_path.parent().unwrap()).unwrap();
    fs::write(&config.lintcheck_results_path, text).unwrap();
}

/// Traverse a directory looking for `perf.data.<number>` files, and adds one
/// to the most recent of those files, returning the new most recent `perf.data`
/// file name.
fn get_perf_data_filename(source_path: &Path) -> String {
    if source_path.join("perf.data").exists() {
        let mut max_number = 0;
        fs::read_dir(source_path)
            .unwrap()
            .filter_map(Result::ok)
            .filter(|path| {
                path.file_name()
                    .as_os_str()
                    .to_string_lossy() // We don't care about data loss, as we're checking for equality
                    .starts_with("perf.data")
            })
            .for_each(|path| {
                let file_name = path.file_name();
                let file_name = file_name.as_os_str().to_str().unwrap().split('.').next_back().unwrap();
                if let Ok(parsed_file_name) = file_name.parse::<usize>()
                    && parsed_file_name >= max_number
                {
                    max_number = parsed_file_name + 1;
                }
            });
        return format!("perf.data.{max_number}");
    }
    String::from("perf.data")
}

/// Returns the path to the Clippy project directory
#[must_use]
fn clippy_project_root() -> &'static Path {
    Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()
}

/// The qualifier can be used to separate different threads from another. By
/// default it should be set to `_<thread_id>`
#[must_use]
fn shared_target_dir(qualifier: &str) -> PathBuf {
    clippy_project_root()
        .join(format!("{}/lintcheck/shared_target_dir", target_dir()))
        .join(qualifier)
}

#[test]
fn lintcheck_test() {
    let args = [
        "run",
        "--target-dir",
        "lintcheck/target",
        "--manifest-path",
        "./lintcheck/Cargo.toml",
        "--",
        "--crates-toml",
        "lintcheck/test_sources.toml",
    ];
    let status = Command::new("cargo")
        .args(args)
        .current_dir("..") // repo root
        .status();
    //.output();

    assert!(status.unwrap().success());
}
