use std::{
    io::stderr,
    path::{MAIN_SEPARATOR, Path, PathBuf},
};

use snapbox::{
    Data, cargo_bin,
    cmd::{Command, OutputAssert},
};
use tempfile::TempDir;

use crate::helpers::{add, add_remote, assert, commit, copy_dir_contents, get_tags, init, tag};

pub struct TestCase {
    file_name: &'static str,
    git: &'static [GitCommand],
    env: Option<(&'static str, &'static str)>,
    remote: Option<&'static str>,
    expected_tags: Option<&'static [&'static str]>,
}

impl TestCase {
    /// Create a new `TestCase`. `file_name` should be an invocation of `file!()`.
    pub const fn new(file_name: &'static str) -> Self {
        Self {
            file_name,
            env: None,
            git: &[],
            remote: None,
            expected_tags: None,
        }
    }

    pub const fn git(self, commands: &'static [GitCommand]) -> TestCase {
        TestCase {
            file_name: self.file_name,
            remote: self.remote,
            git: commands,
            env: None,
            expected_tags: self.expected_tags,
        }
    }

    pub fn with_remote(mut self, remote: &'static str) -> TestCase {
        self.remote = Some(remote);
        self
    }

    pub fn expected_tags(mut self, expected_tags: &'static [&'static str]) -> Self {
        self.expected_tags = Some(expected_tags);
        self
    }

    /// Set up a new temporary directory with the contents of the `in` directory (if any).
    /// Initialize a git repository and run the commands in `git`.
    pub fn arrange(&self) -> TempDir {
        let working_dir = tempfile::tempdir().unwrap();
        let path = working_dir.path();

        let in_dir = self.in_dir();
        if in_dir.exists() {
            copy_dir_contents(&in_dir, path);
        }

        init(path);
        if let Some(remote) = self.remote {
            add_remote(path, remote);
        }
        for command in self.git {
            match command {
                GitCommand::Commit(message) => {
                    commit(path, message, "Knope <knope@example.com>");
                }
                GitCommand::CommitWithAuthor {
                    message,
                    name,
                    email,
                } => commit(path, message, &format!("{name} <{email}>")),
                GitCommand::Tag(name) => {
                    tag(path, name);
                }
                GitCommand::Add(file) => {
                    add(path, file);
                }
            }
        }

        working_dir
    }

    /// Run `command` in `working_dir` with any `self.env` set.
    pub fn act(&self, working_dir: TempDir, command: &str) -> Asserts {
        let data_path = self.data_path();
        let parts = command.split_whitespace().collect::<Vec<_>>();
        let mut real = Command::new(cargo_bin!("knope"))
            .current_dir(working_dir.path())
            .with_assert(assert(true));
        let mut dry_run = Command::new(cargo_bin!("knope"))
            .current_dir(working_dir.path())
            .with_assert(assert(true));

        for arg in parts {
            real = real.arg(arg);
            dry_run = dry_run.arg(arg);
        }
        if let Some((key, value)) = self.env {
            real = real.env(key, value);
            dry_run = dry_run.env(key, value);
        }
        dry_run = dry_run.arg("--dry-run");

        let dry_run = if Self::dry_run_stdout(data_path).exists()
            || Self::dry_run_stderr(data_path).exists()
        {
            Some(dry_run.assert())
        } else {
            None
        };

        Asserts {
            dry_run,
            real: real.assert(),
            working_dir,
        }
    }

    pub fn assert(&self, asserts: Asserts) {
        let Asserts {
            real,
            dry_run,
            working_dir,
        } = asserts;
        let data_path = self.data_path();
        let dry_run_stdout_file = Self::dry_run_stdout(data_path);
        let dry_run_stderr_file = Self::dry_run_stderr(data_path);
        if dry_run_stdout_file.exists() {
            dry_run
                .unwrap()
                .success()
                .stdout_eq(Data::read_from(&dry_run_stdout_file, None));
        } else if dry_run_stderr_file.exists() {
            dry_run
                .unwrap()
                .failure()
                .stderr_eq(Data::read_from(&dry_run_stderr_file, None));
        }

        let stderr_file = data_path.join("stderr.log");
        if stderr_file.exists() {
            real.failure()
                .stderr_eq(Data::read_from(&stderr_file, None));
        } else {
            let stdout_file = data_path.join("stdout.log");
            let output = if stdout_file.exists() {
                Data::read_from(&stdout_file, None)
            } else {
                "".into()
            };
            real.success().stdout_eq(output);
        }

        let path = working_dir.path();

        let in_dir = self.in_dir();
        let mut out_dir = data_path.join("out");
        if !out_dir.exists() {
            out_dir = in_dir;
        }
        if out_dir.exists() {
            assert(false).subset_matches(out_dir, path);
        }

        if let Some(expected_tags) = self.expected_tags {
            let actual_tags = get_tags(path);
            pretty_assertions::assert_eq!(expected_tags, actual_tags);
        }
    }

    fn dry_run_stderr(data_path: &Path) -> PathBuf {
        data_path.join("dryrun_stderr.log")
    }

    fn dry_run_stdout(data_path: &Path) -> PathBuf {
        data_path.join("dryrun_stdout.log")
    }

    /// Runs `.arrange()`, `.act()`, and `.assert()`.
    pub fn run(self, command: &str) {
        self.assert(self.act(self.arrange(), command));
    }

    pub fn env(self, key: &'static str, value: &'static str) -> TestCase {
        TestCase {
            file_name: self.file_name,
            git: self.git,
            remote: self.remote,
            env: Some((key, value)),
            expected_tags: self.expected_tags,
        }
    }

    fn data_path(&self) -> &Path {
        let prefix = format!("crates{MAIN_SEPARATOR}knope{MAIN_SEPARATOR}",);
        Path::new(self.file_name.strip_prefix(&prefix).unwrap())
            .parent()
            .unwrap()
    }

    fn in_dir(&self) -> PathBuf {
        self.data_path().join("in")
    }
}

pub struct Asserts {
    real: OutputAssert,
    dry_run: Option<OutputAssert>,
    working_dir: TempDir,
}

#[derive(Clone, Copy, Debug)]
pub enum GitCommand {
    Add(&'static str),
    Commit(&'static str),
    CommitWithAuthor {
        message: &'static str,
        name: &'static str,
        email: &'static str,
    },
    Tag(&'static str),
}
