#![allow(dead_code, unreachable_pub)]

use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::process::Command;

use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::{ChildPath, FileWriteStr, PathChild, PathCreateDir};
use etcetera::BaseStrategy;
use rustc_hash::FxHashSet;

use prek_consts::PRE_COMMIT_CONFIG_YAML;
use prek_consts::env_vars::EnvVars;

pub fn git_cmd(dir: impl AsRef<Path>) -> Command {
    let mut cmd = Command::new("git");
    cmd.current_dir(dir)
        .args(["-c", "commit.gpgsign=false"])
        .args(["-c", "tag.gpgsign=false"])
        .args(["-c", "core.autocrlf=false"])
        .args(["-c", "user.name=Prek Test"])
        .args(["-c", "user.email=test@prek.dev"]);
    cmd
}

pub struct TestContext {
    temp_dir: ChildPath,
    home_dir: ChildPath,

    /// Standard filters for this test context.
    filters: Vec<(String, String)>,

    // To keep the directory alive.
    #[allow(dead_code)]
    _root: tempfile::TempDir,
}

impl TestContext {
    pub fn new() -> Self {
        let bucket = Self::test_bucket_dir();
        fs_err::create_dir_all(&bucket).expect("Failed to create test bucket");

        let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory");

        let temp_dir = ChildPath::new(root.path()).child("temp");
        fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory");

        Self::from_root(root, temp_dir)
    }

    pub fn new_at(path: PathBuf) -> Self {
        let bucket = Self::test_bucket_dir();
        fs_err::create_dir_all(&bucket).expect("Failed to create test bucket");

        let root = tempfile::TempDir::new_in(bucket).expect("Failed to create test root directory");

        let temp_dir = ChildPath::new(path);
        fs_err::create_dir_all(&temp_dir).expect("Failed to create test working directory");

        Self::from_root(root, temp_dir)
    }

    fn from_root(root: tempfile::TempDir, temp_dir: ChildPath) -> Self {
        let home_dir = ChildPath::new(root.path()).child("home");
        fs_err::create_dir_all(&home_dir).expect("Failed to create test home directory");

        let mut filters = Vec::new();

        filters.extend(
            Self::path_patterns(&temp_dir)
                .into_iter()
                .map(|pattern| (pattern, "[TEMP_DIR]/".to_string())),
        );
        filters.extend(
            Self::path_patterns(&home_dir)
                .into_iter()
                .map(|pattern| (pattern, "[HOME]/".to_string())),
        );

        if let Some(current_exe) = EnvVars::var_os("NEXTEST_BIN_EXE_prek") {
            filters.extend(
                Self::path_patterns(current_exe)
                    .into_iter()
                    .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())),
            );
        }
        let current_exe = assert_cmd::cargo::cargo_bin!("prek");
        filters.extend(
            Self::path_patterns(current_exe)
                .into_iter()
                .map(|pattern| (pattern, "[CURRENT_EXE]".to_string())),
        );

        Self {
            temp_dir,
            home_dir,
            filters,
            _root: root,
        }
    }

    pub fn test_bucket_dir() -> PathBuf {
        EnvVars::var(EnvVars::PREK_INTERNAL__TEST_DIR)
            .map(PathBuf::from)
            .unwrap_or_else(|_| {
                etcetera::base_strategy::choose_base_strategy()
                    .expect("Failed to find base strategy")
                    .data_dir()
                    .join("prek")
                    .join("tests")
            })
    }

    /// Generate an escaped regex pattern for the given path.
    fn path_pattern(path: impl AsRef<Path>) -> String {
        let separator = r"(\\\\|\\|\/)";
        format!(
            // Trim the trailing separator for cross-platform directories filters
            r"{}{}?",
            regex::escape(&path.as_ref().display().to_string())
                // Make separators platform-agnostic because on Windows we will display
                // paths with Unix-style separators sometimes. `PathBuf` debug output
                // escapes backslash separators, so match those as well.
                .replace('/', separator)
                .replace(r"\\", separator),
            separator,
        )
    }

    /// Generate various escaped regex patterns for the given path.
    pub fn path_patterns(path: impl AsRef<Path>) -> Vec<String> {
        let mut patterns = Vec::new();

        // We can only canonicalize paths that exist already
        if path.as_ref().exists() {
            patterns.push(Self::path_pattern(
                path.as_ref()
                    .canonicalize()
                    .expect("Failed to create canonical path"),
            ));
        }

        // Include a non-canonicalized version
        patterns.push(Self::path_pattern(path));

        patterns
    }

    /// Read a file in the temporary directory
    pub fn read(&self, file: impl AsRef<Path>) -> String {
        fs_err::read_to_string(self.temp_dir.join(&file))
            .unwrap_or_else(|_| panic!("Missing file: `{}`", file.as_ref().display()))
    }

    pub fn command(&self) -> Command {
        let mut cmd = if EnvVars::is_set(EnvVars::PREK_INTERNAL__RUN_ORIGINAL_PRE_COMMIT) {
            // Run the original pre-commit to check compatibility.
            let mut cmd = Command::new("pre-commit");
            cmd.current_dir(self.work_dir());
            cmd.env(EnvVars::PRE_COMMIT_HOME, &**self.home_dir());
            cmd
        } else {
            // The absolute path to a binary target's executable. This is only set when running an integration test or benchmark.
            // When reusing builds from an archive, this is set to the remapped path within the target directory.
            let bin = EnvVars::var_os("NEXTEST_BIN_EXE_prek")
                .map(PathBuf::from)
                .unwrap_or_else(|| PathBuf::from(assert_cmd::cargo::cargo_bin!("prek")));
            let mut cmd = Command::new(bin);
            cmd.current_dir(self.work_dir());
            cmd.env(EnvVars::PREK_HOME, &**self.home_dir());
            cmd.env(EnvVars::PREK_INTERNAL__SORT_FILENAMES, "1");
            cmd
        };

        cmd.env(
            EnvVars::PREK_INTERNAL__USER_CONFIG_PATH,
            self.user_config_path().path(),
        );

        // Disable git autocrlf to avoid line ending issues in tests.
        cmd.env("GIT_CONFIG_COUNT", "1")
            .env("GIT_CONFIG_KEY_0", "core.autocrlf")
            .env("GIT_CONFIG_VALUE_0", "false");

        cmd
    }

    fn user_config_path(&self) -> ChildPath {
        self.home_dir
            .child("config")
            .child("prek")
            .child("prek.toml")
    }

    pub fn write_user_config(&self, content: &str) {
        let config_dir = self.home_dir.child("config").child("prek");
        config_dir
            .create_dir_all()
            .expect("Failed to create user config directory");
        self.user_config_path()
            .write_str(content)
            .expect("Failed to write user config");
    }

    pub fn run(&self) -> Command {
        let mut command = self.command();
        command.arg("run");
        command
    }

    pub fn validate_config(&self) -> Command {
        let mut command = self.command();
        command.arg("validate-config");
        command
    }

    pub fn validate_manifest(&self) -> Command {
        let mut command = self.command();
        command.arg("validate-manifest");
        command
    }

    pub fn install(&self) -> Command {
        let mut command = self.command();
        command.arg("install");
        command
    }

    pub fn prepare_hooks(&self) -> Command {
        let mut command = self.command();
        command.arg("prepare-hooks");
        command
    }

    pub fn uninstall(&self) -> Command {
        let mut command = self.command();
        command.arg("uninstall");
        command
    }

    pub fn sample_config(&self) -> Command {
        let mut command = self.command();
        command.arg("sample-config");
        command
    }

    pub fn list(&self) -> Command {
        let mut command = self.command();
        command.arg("list");
        command
    }

    pub fn auto_update(&self) -> Command {
        let mut cmd = self.command();
        cmd.arg("auto-update");
        cmd
    }

    pub fn try_repo(&self) -> Command {
        let mut cmd = self.command();
        cmd.arg("try-repo");
        cmd
    }

    /// Standard snapshot filters _plus_ those for this test context.
    pub fn filters(&self) -> Vec<(&str, &str)> {
        // Put test context snapshots before the default filters
        // This ensures we don't replace other patterns inside paths from the test context first
        self.filters
            .iter()
            .map(|(p, r)| (p.as_str(), r.as_str()))
            .chain(INSTA_FILTERS.iter().copied())
            .collect()
    }

    /// Get the working directory for the test context.
    pub fn work_dir(&self) -> &ChildPath {
        &self.temp_dir
    }

    /// Get the home directory for the test context.
    pub fn home_dir(&self) -> &ChildPath {
        &self.home_dir
    }

    /// Initialize a sample project for prek.
    pub fn init_project(&self) {
        git_cmd(&self.temp_dir)
            .arg("-c")
            .arg("init.defaultBranch=master")
            .arg("init")
            .assert()
            .success();
    }

    /// Run `git add`.
    pub fn git_add(&self, path: impl AsRef<OsStr>) {
        git_cmd(&self.temp_dir)
            .arg("add")
            .arg(path)
            .assert()
            .success();
    }

    /// Run `git commit`.
    pub fn git_commit(&self, message: &str) {
        git_cmd(&self.temp_dir)
            .arg("commit")
            .arg("-m")
            .arg(message)
            .env(EnvVars::PREK_HOME, &**self.home_dir())
            .assert()
            .success();
    }

    /// Run `git tag`.
    pub fn git_tag(&self, tag: &str) {
        git_cmd(&self.temp_dir)
            .arg("tag")
            .arg(tag)
            .arg("-m")
            .arg(format!("Tag {tag}"))
            .assert()
            .success();
    }

    /// Run `git reset`.
    pub fn git_reset(&self, target: &str) {
        git_cmd(&self.temp_dir)
            .arg("reset")
            .arg(target)
            .assert()
            .success();
    }

    /// Run `git rm`.
    pub fn git_rm(&self, path: &str) {
        git_cmd(&self.temp_dir)
            .arg("rm")
            .arg("--cached")
            .arg(path)
            .assert()
            .success();
        let file_path = self.temp_dir.child(path);
        if file_path.exists() {
            fs_err::remove_file(file_path).unwrap();
        }
    }

    /// Run `git clean`.
    pub fn git_clean(&self) {
        git_cmd(&self.temp_dir)
            .arg("clean")
            .arg("-fdx")
            .assert()
            .success();
    }

    /// Create a new git branch.
    pub fn git_branch(&self, branch_name: &str) {
        git_cmd(&self.temp_dir)
            .arg("branch")
            .arg(branch_name)
            .assert()
            .success();
    }

    /// Switch to a git branch.
    pub fn git_checkout(&self, branch_name: &str) {
        git_cmd(&self.temp_dir)
            .arg("checkout")
            .arg(branch_name)
            .assert()
            .success();
    }

    /// Write a `.pre-commit-config.yaml` file in the temporary directory.
    pub fn write_pre_commit_config(&self, content: &str) {
        self.temp_dir
            .child(PRE_COMMIT_CONFIG_YAML)
            .write_str(content)
            .expect("Failed to write pre-commit config");
    }

    /// Setup a workspace with multiple projects, each with the same config.
    /// This creates a tree-like directory structure for testing workspace functionality.
    pub fn setup_workspace(&self, project_paths: &[&str], config: &str) -> anyhow::Result<()> {
        // Always create root config
        self.temp_dir
            .child(PRE_COMMIT_CONFIG_YAML)
            .write_str(config)?;

        // Create each project directory and config
        for path in project_paths {
            let project_dir = self.temp_dir.child(path);
            project_dir.create_dir_all()?;
            project_dir
                .child(PRE_COMMIT_CONFIG_YAML)
                .write_str(config)?;
        }

        Ok(())
    }

    /// Add extra filtering for cache size output
    #[must_use]
    pub fn with_filtered_cache_size(mut self) -> Self {
        // Filter raw byte counts (numbers on their own line)
        self.filters
            .push((r"(?m)^\d+\n".to_string(), "[SIZE]\n".to_string()));
        // Filter human-readable sizes (e.g., "384.2 KiB")
        self.filters.push((
            r"(?m)^\d+(\.\d+)? ([KMGTPE]i)?B\n".to_string(),
            "[SIZE]\n".to_string(),
        ));
        self
    }

    /// Add extra filtering for `cache clean` summary output.
    #[must_use]
    pub fn with_filtered_cache_clean_summary(mut self) -> Self {
        self.filters.push((
            r"(?m)^Removed \d+ files? \([^)]+\)\n".to_string(),
            "Removed [N] file(s) ([SIZE])\n".to_string(),
        ));
        self
    }
}

#[doc(hidden)] // Macro and test context only, don't use directly.
pub const INSTA_FILTERS: &[(&str, &str)] = &[
    // File sizes
    (r"(\s|\()(\d+\.)?\d+\s?([KMGTPE]i)?B", "$1[SIZE]"),
    // Rewrite Windows output to Unix output
    (r"\\{1,2}([\w\d]|\.\.|\.)", "/$1"),
    // The exact message is host language dependent
    (
        r"Caused by: .* \(os error 2\)",
        "Caused by: No such file or directory (os error 2)",
    ),
    // Time seconds
    (r"\b(\d+\.)?\d+(ms|s)\b", "[TIME]"),
    // Strip non-deterministic lock contention warnings from parallel test execution
    (r"(?m)^warning: Waiting to acquire lock.*\n", ""),
];

#[allow(unused_macros)]
macro_rules! cmd_snapshot {
    ($spawnable:expr, @$snapshot:literal) => {{
        cmd_snapshot!($crate::common::INSTA_FILTERS.iter().copied().collect::<Vec<_>>(), $spawnable, @$snapshot)
    }};
    ($filters:expr, $spawnable:expr, @$snapshot:literal) => {{
        let mut settings = insta::Settings::clone_current();
        for (matcher, replacement) in $filters {
            settings.add_filter(matcher, replacement);
        }
        let _guard = settings.bind_to_scope();
        insta_cmd::assert_cmd_snapshot!($spawnable, @$snapshot);
    }};
}

#[allow(unused_imports)]
pub(crate) use cmd_snapshot;

pub(crate) fn remove_bin_from_path(bin: &str, path: Option<OsString>) -> anyhow::Result<OsString> {
    let path = path.unwrap_or(EnvVars::var_os(EnvVars::PATH).expect("Path must be set"));
    let Ok(dirs) = which::which_all(bin) else {
        return Ok(path);
    };

    let dirs: FxHashSet<_> = dirs
        .filter_map(|path| path.parent().map(Path::to_path_buf))
        .collect();

    let new_path_entries: Vec<_> = std::env::split_paths(&path)
        .filter(|path| !dirs.contains(path.as_path()))
        .collect();

    Ok(std::env::join_paths(new_path_entries)?)
}
