//! This test harness is for verifying that the C++ code from cxx's C++ code
//! generator (via `cxx_gen`) triggers the intended C++ compiler diagnostics.

#![allow(unknown_lints, mismatched_lifetime_syntaxes)]

use proc_macro2::TokenStream;
use std::borrow::Cow;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{self, Stdio};
use tempfile::TempDir;

mod smoke_test;

/// 1. Takes a `#[cxx::bridge]` and generates `.cc` and `.h` files,
/// 2. Places additional source files (typically handwritten header files),
/// 3. Compiles the generated `.cc` file.
pub struct Test {
    temp_dir: TempDir,

    /// Path to the `.cc` file (in `temp_dir`) that is generated by the
    /// `cxx_gen` crate out of the `cxx_bridge` argument passed to `Test::new`.
    generated_cc: PathBuf,
}

impl Test {
    /// Creates a new test for the given `cxx_bridge`.
    ///
    /// Example:
    ///
    /// ```
    /// let test = Test::new(quote!{
    ///     #[cxx::bridge]
    ///     mod ffi {
    ///         unsafe extern "C++" {
    ///             include!("include.h");
    ///             pub fn do_cpp_thing();
    ///         }
    ///     }
    /// });
    /// ```
    ///
    /// # Panics
    ///
    /// Panics if there is a failure when generating `.cc` and `.h` files from
    /// the `cxx_bridge`.
    #[must_use]
    pub fn new(cxx_bridge: TokenStream) -> Self {
        let prefix = concat!(env!("CARGO_CRATE_NAME"), "-");
        let scratch = scratch::path("cxx-test-suite");
        let temp_dir = TempDir::with_prefix_in(prefix, scratch).unwrap();
        let generated_h = temp_dir.path().join("cxx_bridge.generated.h");
        let generated_cc = temp_dir.path().join("cxx_bridge.generated.cc");

        let opt = cxx_gen::Opt::default();
        let generated = cxx_gen::generate_header_and_cc(cxx_bridge, &opt).unwrap();
        fs::write(&generated_h, &generated.header).unwrap();
        fs::write(&generated_cc, &generated.implementation).unwrap();

        Self {
            temp_dir,
            generated_cc,
        }
    }

    /// Writes a file to the temporary test directory.
    ///
    /// The new file will be present in the `-I` include path passed to the C++
    /// compiler.
    ///
    /// # Panics
    ///
    /// Panics if there is an error when writing the file.
    pub fn write_file(&self, filename: impl AsRef<Path>, contents: &str) {
        fs::write(self.temp_dir.path().join(filename), contents).unwrap();
    }

    /// Compiles the `.cc` file generated in `Self::new`.
    ///
    /// # Panics
    ///
    /// Panics if there is a problem with spawning the C++ compiler.
    /// (Compilation errors will *not* result in a panic.)
    #[must_use]
    pub fn compile(&self) -> CompilationResult {
        let mut build = cc::Build::new();
        build
            .include(self.temp_dir.path())
            .out_dir(self.temp_dir.path())
            .cpp(true);

        // Arbitrarily using C++20 for now. If some test cases require a
        // specific C++ standard, we can make this configurable.
        build.std("c++20");

        // Set info required by the `cc` crate.
        //
        // The correct host triple during execution of this test is the target
        // triple from the Rust compilation of this test -- not the Rust host
        // triple.
        build
            .opt_level(3)
            .host(target_triple::TARGET)
            .target(target_triple::TARGET);

        // The `cc` crate does not currently expose the `Command` for building a
        // single C++ source file. Work around that by passing `-c <file.cc>`.
        let mut command = build.get_compiler().to_command();
        command
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .current_dir(self.temp_dir.path())
            .arg("-c")
            .arg(&self.generated_cc);
        let output = command.spawn().unwrap().wait_with_output().unwrap();
        CompilationResult(output)
    }
}

/// Wrapper around the output from a C++ compiler.
pub struct CompilationResult(process::Output);

impl CompilationResult {
    fn stdout(&self) -> Cow<str> {
        String::from_utf8_lossy(&self.0.stdout)
    }

    fn stderr(&self) -> Cow<str> {
        String::from_utf8_lossy(&self.0.stderr)
    }

    fn dump_output_and_panic(&self, msg: &str) -> ! {
        eprintln!("{}", self.stdout());
        eprintln!("{}", self.stderr());
        panic!("{msg}");
    }

    fn error_lines(&self) -> Vec<String> {
        assert!(!self.0.status.success());

        // MSVC reports errors to stdout rather than stderr, so consider both.
        let stdout = self.stdout();
        let stderr = self.stderr();
        let all_lines = stdout.lines().chain(stderr.lines());

        all_lines
            .filter(|line| {
                // This should match MSVC error output
                // (e.g. `file.cc(<line number>): error C2338: static_assert failed: ...`)
                // as well as Clang or GCC error output
                // (e.g. `file.cc:<line>:<column>: error: static assertion failed: ...`
                line.contains(": error")
            })
            .map(str::to_owned)
            .collect()
    }

    /// Asserts that the C++ compilation succeeded.
    ///
    /// # Panics
    ///
    /// Panics if the C++ compiler reported an error.
    pub fn assert_success(&self) {
        if !self.0.status.success() {
            self.dump_output_and_panic("Compiler reported an error");
        }
    }

    /// Verifies that the compilation failed with a single error, and return the
    /// stderr line describing this error.
    ///
    /// Note that different compilers may return slightly different error
    /// messages, so tests should be careful to only verify presence of some
    /// substrings.
    ///
    /// # Panics
    ///
    /// Panics if there was no error, or if there was more than a single error.
    #[must_use]
    pub fn expect_single_error(&self) -> String {
        let error_lines = self.error_lines();
        if error_lines.is_empty() {
            self.dump_output_and_panic("No error lines found, despite non-zero exit code?");
        }
        if error_lines.len() > 1 {
            self.dump_output_and_panic("Unexpectedly more than 1 error line was present");
        }

        // `eprintln` to help with debugging test failues that may happen later.
        let single_error_line = error_lines.into_iter().next().unwrap();
        eprintln!("Got single error as expected: {single_error_line}");
        single_error_line
    }
}
