use mullvad_api::{ApiEndpoint, proxy::ApiConnectionMode};
use regex::Regex;
use std::{
    borrow::Cow,
    cmp::min,
    collections::{BTreeMap, HashSet},
    ffi::OsStr,
    fs::{self, File},
    io::{self, BufWriter, Read, Seek, SeekFrom, Write},
    path::{Path, PathBuf},
    sync::LazyLock,
};
use talpid_types::ErrorExt;

pub mod metadata;

/// Maximum number of bytes to read from each log file
const LOG_MAX_READ_BYTES: usize = 128 * 1024;
const EXTRA_BYTES: usize = 32 * 1024;
/// Fit five logs plus some system information in the report.
const REPORT_MAX_SIZE: usize = (5 * LOG_MAX_READ_BYTES) + EXTRA_BYTES;

/// Field delimiter in generated problem report
const LOG_DELIMITER: &str = "====================";

/// Line separator character sequence
#[cfg(not(windows))]
const LINE_SEPARATOR: &str = "\n";

#[cfg(windows)]
const LINE_SEPARATOR: &str = "\r\n";

const MAX_SEND_ATTEMPTS: usize = 3;

/// Custom macro to write a line to an output formatter that uses platform-specific newline
/// character sequences.
macro_rules! write_line {
    ($fmt:expr_2021 $(,)*) => { write!($fmt, "{}", LINE_SEPARATOR) };
    ($fmt:expr_2021, $pattern:expr_2021 $(, $arg:expr_2021)* $(,)*) => {
        write!($fmt, $pattern, $( $arg ),*)
            .and_then(|_| write!($fmt, "{}", LINE_SEPARATOR))
    };
}

/// These are critical errors that can happen when using the tool, that stops
/// it from working. Meaning it will print the error and exit.
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error("Failed to write the problem report to {path}")]
    WriteReportError {
        path: String,
        #[source]
        source: io::Error,
    },

    #[error("Failed to read the problem report at {path}")]
    ReadProblemReportError {
        path: String,
        #[source]
        source: io::Error,
    },

    #[error("Unable to create REST client")]
    CreateRpcClientError(#[source] mullvad_api::Error),

    #[error("Failed to send problem report")]
    SendProblemReportError(#[source] mullvad_api::rest::Error),

    #[error("Failed to send problem report {} times", MAX_SEND_ATTEMPTS)]
    SendFailedTooManyTimes,

    #[error("Unable to spawn Tokio runtime")]
    CreateRuntime(#[source] io::Error),

    #[cfg(not(target_os = "android"))]
    #[error("Unable to find cache directory")]
    ObtainCacheDirectory(#[source] mullvad_paths::Error),
}

/// These are errors that can happen during problem report collection.
/// They are not critical, but they will be added inside the problem report,
/// instead of whatever content was supposed to be there.
#[derive(thiserror::Error, Debug)]
pub enum LogError {
    #[cfg(not(target_os = "android"))]
    #[error("Unable to get log directory")]
    GetLogDir(#[source] mullvad_paths::Error),

    #[error("Failed to list the files in the log directory: {path}")]
    ListLogDir {
        path: String,
        #[source]
        source: io::Error,
    },

    #[error("Error reading the contents of log file: {path}")]
    ReadLogError { path: String },

    #[cfg(any(target_os = "linux", target_os = "macos"))]
    #[error("No home directory for current user")]
    NoHomeDir,

    #[cfg(target_os = "windows")]
    #[error("Missing %LOCALAPPDATA% environment variable")]
    NoLocalAppDataDir,
}

/// Problem report collector
#[derive(Debug, Default)]
pub struct ProblemReportCollector {
    pub extra_logs: Vec<PathBuf>,
    pub redact_custom_strings: Vec<String>,

    #[cfg(target_os = "android")]
    pub android_log_dir: PathBuf,
    #[cfg(target_os = "android")]
    pub extra_logs_dir: PathBuf,
    #[cfg(target_os = "android")]
    pub unverified_purchases: i32,
    #[cfg(target_os = "android")]
    pub pending_purchases: i32,
}

impl ProblemReportCollector {
    /// Collect the problem report and writes it to the specified path
    pub fn write_to_path(self, path: impl AsRef<Path>) -> Result<(), Error> {
        self.write(open_output_file(path)?)
    }

    /// Collect the problem report and writes it to the specified output
    pub fn write(self, output: WriteSource<impl Write>) -> Result<(), Error> {
        let mut problem_report = ProblemReport::new(self.redact_custom_strings);

        let daemon_logs_dir = {
            #[cfg(target_os = "android")]
            {
                Ok(&self.android_log_dir)
            }
            #[cfg(not(target_os = "android"))]
            {
                mullvad_paths::get_log_dir().map_err(LogError::GetLogDir)
            }
        };

        let daemon_logs = daemon_logs_dir.and_then(list_logs);
        match daemon_logs {
            Ok(daemon_logs) => {
                for log in daemon_logs {
                    match log {
                        Ok(path) => problem_report.add_log(&path),
                        Err(error) => problem_report.add_error("Unable to get log path", &error),
                    }
                }
            }
            Err(error) => {
                problem_report.add_error("Failed to list logs in daemon log directory", &error)
            }
        };

        #[cfg(not(target_os = "android"))]
        match mullvad_paths::frontend_log_dir()
            .map_err(LogError::GetLogDir)
            .and_then(list_logs)
        {
            Ok(frontend_logs) => {
                for log in frontend_logs {
                    match log {
                        Ok(path) => problem_report.add_log(&path),
                        Err(error) => problem_report.add_error("Unable to get log path", &error),
                    }
                }
            }
            Err(error) => {
                problem_report.add_error("Failed to list logs in frontend log directory", &error)
            }
        }
        #[cfg(target_os = "android")]
        {
            match write_logcat_to_file(&self.android_log_dir) {
                Ok(logcat_path) => problem_report.add_log(&logcat_path),
                Err(error) => problem_report.add_error("Failed to collect logcat", &error),
            }

            match list_logs(self.extra_logs_dir) {
                Ok(android_app_logs) => {
                    for log in android_app_logs {
                        match log {
                            Ok(path) => problem_report.add_log(&path),
                            Err(error) => {
                                problem_report.add_error("Unable to get log path", &error)
                            }
                        }
                    }
                }
                Err(error) => problem_report
                    .add_error("Failed to list logs in android app log directory", &error),
            }

            problem_report.add_metadata(
                "unverified-purchases".to_string(),
                self.unverified_purchases.to_string(),
            );
            problem_report.add_metadata(
                "pending-purchases".to_string(),
                self.pending_purchases.to_string(),
            );
        }

        problem_report.add_logs(self.extra_logs);

        problem_report
            .write_to(output.write)
            .map_err(|source| Error::WriteReportError {
                path: output.source,
                source,
            })
    }
}

/// A [Write] with a named source.
pub struct WriteSource<W: Write> {
    pub write: W,
    pub source: String,
}

/// Open a file to write the problem report to.
pub fn open_output_file(path: impl AsRef<Path>) -> Result<WriteSource<BufWriter<File>>, Error> {
    fn inner(path: impl AsRef<Path>) -> io::Result<BufWriter<File>> {
        let file = File::create(path)?;
        let mut permissions = file.metadata()?.permissions();
        permissions.set_readonly(true);
        file.set_permissions(permissions)?;
        Ok(BufWriter::new(file))
    }

    let file_path = path.as_ref().display().to_string();

    let write = inner(path).map_err(|source| Error::WriteReportError {
        path: file_path.clone(),
        source,
    })?;

    Ok(WriteSource {
        write,
        source: file_path,
    })
}

impl<W: Write> From<(W, String)> for WriteSource<W> {
    fn from((write, source): (W, String)) -> Self {
        WriteSource { write, source }
    }
}

/// Returns an iterator over all files in the given directory that has the `.log` extension.
fn list_logs(
    log_dir: impl AsRef<Path>,
) -> Result<impl Iterator<Item = Result<PathBuf, LogError>>, LogError> {
    fs::read_dir(log_dir.as_ref())
        .map_err(|source| LogError::ListLogDir {
            path: log_dir.as_ref().display().to_string(),
            source,
        })
        .map(|dir_entries| {
            let log_extension = Some(OsStr::new("log"));

            dir_entries.filter_map(move |dir_entry_result| match dir_entry_result {
                Ok(dir_entry) => {
                    let path = dir_entry.path();

                    if path.extension() == log_extension {
                        Some(Ok(path))
                    } else {
                        None
                    }
                }
                Err(source) => Some(Err(LogError::ListLogDir {
                    path: log_dir.as_ref().display().to_string(),
                    source,
                })),
            })
        })
}

#[cfg(target_os = "android")]
fn write_logcat_to_file(log_dir: &Path) -> Result<PathBuf, io::Error> {
    use std::process::{Command, Stdio};

    let logcat_path = log_dir.join("logcat.txt");
    let logcat_file = File::create(&logcat_path)?;
    let _stderr = logcat_file.try_clone()?;
    let stdout = Stdio::from(logcat_file);
    let stderr = Stdio::from(_stderr);

    let _output = Command::new("logcat")
        .arg("-d")
        .stdout(stdout)
        .stderr(stderr)
        .output()?;
    Ok(logcat_path)
}

pub fn send_problem_report(
    user_email: &str,
    user_message: &str,
    account_token: Option<&str>,
    report_path: &Path,
    cache_dir: &Path,
    endpoint: ApiEndpoint,
) -> Result<(), Error> {
    let report_content = normalize_newlines(
        read_file_lossy(report_path, REPORT_MAX_SIZE).map_err(|source| {
            Error::ReadProblemReportError {
                path: report_path.display().to_string(),
                source,
            }
        })?,
    );

    let runtime = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .map_err(Error::CreateRuntime)?;
    runtime.block_on(send_problem_report_inner(
        user_email,
        user_message,
        account_token,
        &report_content,
        cache_dir,
        &endpoint,
    ))
}

async fn send_problem_report_inner(
    user_email: &str,
    user_message: &str,
    account_token: Option<&str>,
    report_content: &str,
    cache_dir: &Path,
    endpoint: &ApiEndpoint,
) -> Result<(), Error> {
    let metadata = ProblemReport::parse_metadata(report_content).unwrap_or_else(metadata::collect);
    let api_runtime = mullvad_api::Runtime::with_cache(
        endpoint,
        cache_dir,
        false,
        #[cfg(target_os = "android")]
        None,
    )
    .await
    .map_err(Error::CreateRpcClientError)?;

    let connection_mode = ApiConnectionMode::try_from_cache(cache_dir).await;
    let api_client = mullvad_api::ProblemReportProxy::new(
        api_runtime.mullvad_rest_handle(connection_mode.into_provider()),
    );

    let message: String = match account_token {
        Some(account_token) => {
            format!("{user_message}\naccount-token: {account_token}")
        }
        None => user_message.to_string(),
    };

    for _attempt in 0..MAX_SEND_ATTEMPTS {
        match api_client
            .problem_report(user_email, &message, report_content, &metadata)
            .await
        {
            Ok(()) => {
                return Ok(());
            }
            Err(error) => {
                if !error.is_network_error() {
                    return Err(Error::SendProblemReportError(error));
                }
                log::error!(
                    "{}",
                    error.display_chain_with_msg(
                        "Failed to send problem report due to network error"
                    )
                );
            }
        }
    }
    Err(Error::SendFailedTooManyTimes)
}

#[derive(Debug)]
struct ProblemReport {
    metadata: BTreeMap<String, String>,
    logs: Vec<(String, String)>,
    log_paths: HashSet<PathBuf>,
    redact_custom_strings: Vec<String>,
}

impl ProblemReport {
    /// Creates a new problem report with system information. Logs can be added with `add_log`.
    /// Logs will have all strings in `redact_custom_strings` removed from them.
    pub fn new(mut redact_custom_strings: Vec<String>) -> Self {
        redact_custom_strings.retain(|redact| !redact.is_empty());

        ProblemReport {
            metadata: metadata::collect(),
            logs: Vec::new(),
            log_paths: HashSet::new(),
            redact_custom_strings,
        }
    }

    /// Add extra metadata to the problem report that is not possible to access from the daemon
    /// directly.
    #[cfg(target_os = "android")]
    pub fn add_metadata(&mut self, key: String, value: String) {
        self.metadata.insert(key, value);
    }

    /// Attach some file logs to this report. This method adds the error chain instead of the log
    /// contents if an error occurs while reading one of the log files.
    pub fn add_logs<I>(&mut self, paths: I)
    where
        I: IntoIterator,
        I::Item: AsRef<Path>,
    {
        for path in paths {
            self.add_log(path.as_ref());
        }
    }

    /// Attach a file log to this report. This method adds the error chain instead of the log
    /// contents if an error occurs while reading the log file.
    pub fn add_log(&mut self, path: &Path) {
        let expanded_path = path.canonicalize().unwrap_or_else(|_| path.to_owned());
        if self.log_paths.insert(expanded_path.clone()) {
            let redacted_path = self.redact(&expanded_path.to_string_lossy());
            let content = self.redact(&read_file_lossy(path, LOG_MAX_READ_BYTES).unwrap_or_else(
                |error| {
                    error.display_chain_with_msg(&format!(
                        "Error reading the contents of log file: {}",
                        expanded_path.display()
                    ))
                },
            ));
            self.logs.push((redacted_path, content));
            log::info!("Adding {}", expanded_path.display());
        }
    }

    /// Attach an error to the report.
    pub fn add_error(&mut self, message: &'static str, error: &impl ErrorExt) {
        let redacted_error = self.redact(&error.display_chain());
        self.logs.push((message.to_string(), redacted_error));
    }

    fn redact(&self, input: &str) -> String {
        let out1 = Self::redact_account_number(input);
        let out2 = Self::redact_home_dir(&out1);
        let out3 = Self::redact_network_info(&out2);
        let out4 = Self::redact_uuid_v4(&out3);
        self.redact_custom_strings(&out4).to_string()
    }

    fn redact_account_number(input: &str) -> Cow<'_, str> {
        static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new("\\d{16}").unwrap());
        RE.replace_all(input, "[REDACTED ACCOUNT NUMBER]")
    }

    fn redact_home_dir(input: &str) -> Cow<'_, str> {
        redact_home_dir_inner(input, dirs::home_dir()).into()
    }

    fn redact_network_info(input: &str) -> Cow<'_, str> {
        static RE: LazyLock<Regex> = LazyLock::new(|| {
            let boundary = "[^0-9a-zA-Z.:]";
            let combined_pattern = format!(
                "(?P<start>^|{})(?:{}|{}|{})",
                boundary,
                build_ipv4_regex(),
                build_ipv6_regex(),
                build_mac_regex(),
            );
            Regex::new(&combined_pattern).unwrap()
        });
        RE.replace_all(input, "$start[REDACTED]")
    }

    /// Redact all v4 UUIDs, including:
    /// * Account IDs
    /// * Device IDs
    /// * Network interface GUIDs on Windows
    fn redact_uuid_v4(input: &str) -> Cow<'_, str> {
        static RE: LazyLock<Regex> = LazyLock::new(|| {
            Regex::new(r"(?i)\{?[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}\}?")
                .unwrap()
        });
        RE.replace_all(input, "[REDACTED]")
    }

    fn redact_custom_strings<'a>(&self, input: &'a str) -> Cow<'a, str> {
        // Can probably me made a lot faster with aho-corasick if optimization is ever needed.
        let mut out = Cow::from(input);
        for redact in &self.redact_custom_strings {
            out = out.replace(redact, "[REDACTED]").into()
        }
        out
    }

    fn write_to<W: Write>(&self, mut output: W) -> io::Result<()> {
        // IMPORTANT: Make sure this implementation stays in sync with `parse_metadata` below.
        write_line!(output, "System information:")?;
        for (key, value) in &self.metadata {
            write_line!(output, "{}: {}", key, value)?;
        }
        // Write empty line to separate metadata from first log
        write_line!(output)?;
        for (label, content) in &self.logs {
            write_line!(output, "{}", LOG_DELIMITER)?;
            write_line!(output, "Log: {}", label)?;
            write_line!(output, "{}", LOG_DELIMITER)?;
            output.write_all(content.as_bytes())?;
            write_line!(output)?;
        }
        Ok(())
    }

    /// Tries to parse out the metadata map from a string that is supposed to be a report written by
    /// this struct.
    pub fn parse_metadata(report: &str) -> Option<BTreeMap<String, String>> {
        // IMPORTANT: Make sure this implementation stays in sync with `write_to` above.
        const PATTERN: &str = ": ";
        let mut lines = report.lines();
        if lines.next() != Some("System information:") {
            return None;
        }
        let mut metadata = BTreeMap::new();
        for line in lines {
            // Abort on first empty line, as this is the separator between the metadata and the
            // first log
            if line.is_empty() {
                break;
            }
            let split_i = line.find(PATTERN)?;
            let key = &line[..split_i];
            let value = &line[split_i + PATTERN.len()..];
            metadata.insert(key.to_owned(), value.to_owned());
        }
        Some(metadata)
    }
}

fn redact_home_dir_inner(input: &str, home_dir: Option<PathBuf>) -> String {
    #[cfg(target_os = "windows")]
    {
        // Redact all paths that match:
        // - <drive letter>:\Users\<username>
        // - \Device\HarddiskVolumeX\Users\<username>
        static RE: LazyLock<Regex> = LazyLock::new(|| {
            Regex::new(r"(?i)(?:[A-Z]:\\Users\\[^\\]+|\\Device\\[^\\]+\\Users\\[^\\]+)").unwrap()
        });

        let mut out = RE.replace_all(input, "~").into_owned();

        if let Some(home) = home_dir {
            out = out.replace(home.to_string_lossy().as_ref(), "~");

            // Also redact equivalent paths that use a device prefix instead of a drive letter.
            let mut home = home;
            let prefix = home.components().next();
            if let Some(prefix @ std::path::Component::Prefix(_)) = prefix.as_ref() {
                home = home.strip_prefix(prefix).unwrap().to_path_buf();
            }
            let expr = format!(r"[\w\\]+{}", regex::escape(&home.display().to_string()));
            let regex = Regex::new(&expr).unwrap();

            out = regex.replace_all(&out, "~").to_string();
        }

        out
    }

    #[cfg(not(target_os = "windows"))]
    {
        // Redact all paths that match:
        // - /home/<username>
        // - /Users/<username>
        static RE: LazyLock<Regex> =
            LazyLock::new(|| Regex::new(r"(?i)(?:/home/[^/]+|/Users/[^/]+)").unwrap());

        let mut out = RE.replace_all(input, "~").into_owned();

        if let Some(home) = home_dir {
            out = out.replace(home.to_string_lossy().as_ref(), "~");
        }

        out
    }
}

fn build_mac_regex() -> String {
    let octet = "[[:xdigit:]]{2}"; // 0 - ff

    // five pairs of two hexadecimal chars followed by colon or dash
    // followed by a pair of hexadecimal chars
    format!("(?:{octet}[:-]){{5}}({octet})")
}

fn build_ipv4_regex() -> String {
    // regex adapted from  https://www.regular-expressions.info/ip.html

    let above_250 = "25[0-5]";
    let above_200 = "2[0-4][0-9]";
    let above_100 = "1[0-9][0-9]";

    // 100-119 | 120-126 | 128-129 | 130 - 199
    let above_100_not_127 = "1(?:[01][0-9]|2[0-6]|2[89]|[3-9][0-9])";

    let above_0 = "0?[0-9][0-9]?";

    // matches 0-255, except 127
    let first_octet = format!("(?:{above_250}|{above_200}|{above_100_not_127}|{above_0})");

    // matches 0-255
    let ip_octet = format!("(?:{above_250}|{above_200}|{above_100}|{above_0})");

    format!("(?:{first_octet}\\.{ip_octet}\\.{ip_octet}\\.{ip_octet})")
}

fn build_ipv6_regex() -> String {
    // Regular expression obtained from:
    // https://stackoverflow.com/a/17871737
    let ipv4_segment = "(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])";
    let ipv4_address = format!("({ipv4_segment}\\.){{3,3}}{ipv4_segment}");

    let ipv6_segment = "[0-9a-fA-F]{1,4}";

    let long = format!("({ipv6_segment}:){{7,7}}{ipv6_segment}");
    let compressed_1 = format!("({ipv6_segment}:){{1,7}}:");
    let compressed_2 = format!("({ipv6_segment}:){{1,6}}:{ipv6_segment}");
    let compressed_3 = format!("({ipv6_segment}:){{1,5}}(:{ipv6_segment}){{1,2}}");
    let compressed_4 = format!("({ipv6_segment}:){{1,4}}(:{ipv6_segment}){{1,3}}");
    let compressed_5 = format!("({ipv6_segment}:){{1,3}}(:{ipv6_segment}){{1,4}}");
    let compressed_6 = format!("({ipv6_segment}:){{1,2}}(:{ipv6_segment}){{1,5}}");
    let compressed_7 = format!("{ipv6_segment}:((:{ipv6_segment}){{1,6}})");
    let compressed_8 = format!(":((:{ipv6_segment}){{1,7}}|:)");
    let link_local = "[Ff][Ee]80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}";
    let ipv4_mapped = format!("::([fF]{{4}}(:0{{1,4}}){{0,1}}:){{0,1}}{ipv4_address}");
    let ipv4_embedded = format!("({ipv6_segment}:){{1,4}}:{ipv4_address}");

    format!(
        "{long}|{link_local}|{ipv4_mapped}|{ipv4_embedded}|{compressed_8}|{compressed_7}|{compressed_6}|{compressed_5}|{compressed_4}|{compressed_3}|{compressed_2}|{compressed_1}",
    )
}

/// Helper to lossily read a file to a `String`. If the file size exceeds the given `max_bytes`,
/// only the last `max_bytes` bytes of the file are read.
fn read_file_lossy(path: &Path, max_bytes: usize) -> io::Result<String> {
    let mut file = File::open(path)?;
    let file_size = file.metadata()?.len();

    if file_size > max_bytes as u64 {
        file.seek(SeekFrom::Start(file_size - max_bytes as u64))?;
    }

    let capacity = min(file_size, max_bytes as u64) as usize;
    let mut buffer = Vec::with_capacity(capacity);
    file.take(max_bytes as u64).read_to_end(&mut buffer)?;
    Ok(String::from_utf8_lossy(&buffer).into_owned())
}

#[cfg(not(windows))]
fn normalize_newlines(text: String) -> String {
    text
}

#[cfg(windows)]
fn normalize_newlines(text: String) -> String {
    text.replace(LINE_SEPARATOR, "\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn redacts_ipv4() {
        assert_redacts("1.2.3.4");
        assert_redacts("10.127.0.1");
        assert_redacts("192.168.1.1");
        assert_redacts("10.0.16.1");
        assert_redacts("173.54.12.32");
        assert_redacts("68.4.4.1");
    }

    #[test]
    fn does_not_redact_localhost_ipv4() {
        assert_does_not_redact("127.0.0.1");
    }

    #[test]
    fn redacts_ipv6() {
        assert_redacts("2001:0db8:85a3:0000:0000:8a2e:0370:7334");
        assert_redacts("2001:db8:85a3:0:0:8a2e:370:7334");
        assert_redacts("2001:db8:85a3::8a2e:370:7334");
        assert_redacts("2001:db8:0:0:0:0:2:1");
        assert_redacts("2001:db8::2:1");
        assert_redacts("2001:db8:0000:1:1:1:1:1");
        assert_redacts("2001:db8:0:1:1:1:1:1");
        assert_redacts("2001:db8:0:0:1:0:0:1");
        assert_redacts("2001:db8::1:0:0:1");
        assert_redacts("abcd:dead:beef::");
        assert_redacts("abcd:dead:beef:1234::");
        assert_redacts("::dead:beef:1234");
        assert_redacts("0::0");
        assert_redacts("0:0:0:0::1");
    }

    #[test]
    fn doesnt_redact_not_ipv6() {
        assert_does_not_redact("[talpid_core::firewall]");
    }

    #[test]
    fn redacts_uuid_v4() {
        assert_redacts("1248e97e-134b-4820-92e1-abaf191c2840");
        assert_redacts("6B29FC40-CA47-1067-B31D-00DD010662DA");
        assert_redacts("123123ab-12ab-89cd-45ef-012345678901");
        assert_redacts("{123123ab-12ab-89cd-45ef-012345678901}");
    }

    #[test]
    #[cfg(windows)]
    fn redacts_home_dir() {
        let assert_redacts_home_dir = |home_dir, test_str| {
            let input = format!(r"pre {test_str}\remaining\path post");
            let actual = redact_home_dir_inner(&input, Some(PathBuf::from(home_dir)));
            assert_eq!(r"pre ~\remaining\path post", actual);
        };

        let home_dir = r"C:\Users\user";

        assert_redacts_home_dir(home_dir, r"\Device\HarddiskVolume1\Users\user");
        assert_redacts_home_dir(home_dir, r"C:\Users\user");
        assert_redacts_home_dir(home_dir, r"C:\Users\other-user");
        assert_redacts_home_dir(home_dir, r"C:\users\other-user");
    }

    #[test]
    #[cfg(windows)]
    fn redacts_windows_user_paths_without_home_dir() {
        let input = r"pre C:\users\other-user\remaining\path post";
        let actual = redact_home_dir_inner(input, None);
        assert_eq!(r"pre ~\remaining\path post", actual);

        let input = r"pre \Device\HarddiskVolume1\Users\other-user\remaining\path post";
        let actual = redact_home_dir_inner(input, None);
        assert_eq!(r"pre ~\remaining\path post", actual);
    }

    #[test]
    #[cfg(not(windows))]
    fn redacts_home_dir() {
        let assert_redacts_home_dir = |home_dir, test_str| {
            let input = format!(r"pre {test_str}/remaining/path post");
            let actual = redact_home_dir_inner(&input, Some(PathBuf::from(home_dir)));
            assert_eq!(r"pre ~/remaining/path post", actual);
        };

        let home_dir = r"/home/user";

        assert_redacts_home_dir(home_dir, r"/home/user");
        assert_redacts_home_dir(home_dir, r"/home/other-user");
        assert_redacts_home_dir(home_dir, r"/Users/other-user");

        let home_dir = r"/Users/user";

        assert_redacts_home_dir(home_dir, r"/home/user");
        assert_redacts_home_dir(home_dir, r"/home/other-user");
        assert_redacts_home_dir(home_dir, r"/Users/other-user");
    }

    #[test]
    fn doesnt_redact_not_uuid_v4() {
        assert_does_not_redact("23123ab-12ab-89cd-45ef-012345678901");
        assert_does_not_redact("GGGGGGGG-GGGG-GGGG-GGGG-GGGGGGGGGGGG");
    }

    #[test]
    fn does_not_redact_time() {
        assert_does_not_redact("09:47:59");
    }

    fn assert_redacts(input: &str) {
        let report = ProblemReport::new(vec![]);
        let actual = report.redact(&format!("pre {input} post"));
        assert_eq!("pre [REDACTED] post", actual);
    }

    fn assert_does_not_redact(input: &str) {
        let report = ProblemReport::new(vec![]);
        let res = report.redact(input);
        assert_eq!(input, res);
    }

    #[test]
    fn parse_metadata() {
        let report = ProblemReport::new(Vec::new());
        let mut report_data = Vec::new();
        report
            .write_to(&mut report_data)
            .expect("Unable to write report to vector");

        let report_string = std::str::from_utf8(&report_data).expect("Report is not correct UTF-8");

        let parsed_metadata = ProblemReport::parse_metadata(report_string)
            .expect("Unable to parse metadata from report");
        let expected_metadata = metadata::collect();

        assert_eq!(parsed_metadata.len(), expected_metadata.len());
        for (key, value) in &expected_metadata {
            let parsed_value = parsed_metadata
                .get(key)
                .expect("Parsed metadata and new one don't match");
            if key == "id" {
                assert_ne!(parsed_value, value, "id not supposed to match");
            } else {
                assert_eq!(parsed_value, value, "value for key '{key}' does not match");
            }
        }
    }
}
