// SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
// SPDX-License-Identifier: BSD-2-Clause
//! Common definitions for the generate-tests tool.

use core::error::Error as StdError;
use core::fmt::{Display, Formatter, Result as FmtResult};

use camino::{Utf8Path, Utf8PathBuf};
use eyre::{Error as AnyError, eyre};
use serde_derive::{Deserialize, Serialize};

#[cfg(test)]
use eyre::WrapErr as _;

/// An error that occurred while processing the test data.
#[derive(Debug)]
#[non_exhaustive]
#[expect(clippy::error_impl_error, reason = "common enough convention")]
pub enum Error {
    /// A filesystem operation failed.
    FsError(AnyError),

    /// Something went really, really wrong.
    Internal(AnyError),

    /// A feature that has not been implemented yet.
    NotImplemented(String),
}

impl Display for Error {
    #[inline]
    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
        match *self {
            Self::FsError(_) => write!(f, "Could not access a file"),
            Self::Internal(_) => write!(f, "remrun/generate-tests internal error"),
            Self::NotImplemented(ref msg) => {
                write!(f, "remrun/generate-tests: not implemented yet: {msg}")
            }
        }
    }
}

impl StdError for Error {
    #[inline]
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        match *self {
            Self::FsError(ref err) | Self::Internal(ref err) => Some(err.as_ref()),
            Self::NotImplemented(_) => None,
        }
    }
}

/// The output target: either a file or the standard output stream.
#[expect(clippy::exhaustive_enums, reason = "this is part of the versioned API")]
pub enum OutputTarget {
    /// Output to the specified file.
    File(Utf8PathBuf),

    /// Output to the standard output stream.
    Stdout,
}

/// Runtime configuration for the `generate_tests` tool.
pub struct Config {
    /// Check-only mode; report whether the file would have been modified.
    check_only: bool,

    /// The top-level `remrun` source directory.
    root_dir: Utf8PathBuf,

    /// The shell program to generate.
    output_file: OutputTarget,
}

impl Config {
    /// Check-only mode; report whether the file would have been modified.
    #[inline]
    #[must_use]
    pub const fn check_only(&self) -> bool {
        self.check_only
    }

    /// The top-level `remrun` source directory.
    #[inline]
    #[must_use]
    pub fn root_dir(&self) -> &Utf8Path {
        &self.root_dir
    }

    /// The path to the shell program to generate, or none for standard output.
    #[inline]
    #[must_use]
    pub fn output_path(&self) -> Option<&Utf8Path> {
        match self.output_file {
            OutputTarget::File(ref output_path) => Some(output_path),
            OutputTarget::Stdout => None,
        }
    }

    /// Start constructing a new [`Config`] object part by part.
    #[inline]
    #[must_use]
    pub fn builder() -> ConfigBuilder {
        ConfigBuilder::default()
    }
}

/// Construct a new [`Config`] object part by part.
#[derive(Default)]
pub struct ConfigBuilder {
    /// Check-only mode; report whether the file would have been modified.
    check_only: bool,

    /// The top-level `remrun` source directory.
    root_dir: Option<Utf8PathBuf>,

    /// The shell program to generate.
    output_file: Option<OutputTarget>,
}

impl ConfigBuilder {
    /// Check-only mode; report whether the file would have been modified.
    #[inline]
    #[must_use]
    pub fn check_only(self, check_only: bool) -> Self {
        Self { check_only, ..self }
    }

    /// The top-level `remrun` source directory.
    #[inline]
    #[must_use]
    pub fn root_dir(self, root_dir: Utf8PathBuf) -> Self {
        Self {
            root_dir: Some(root_dir),
            ..self
        }
    }

    /// The shell program to generate.
    #[inline]
    #[must_use]
    pub fn output_file(self, output_file: OutputTarget) -> Self {
        Self {
            output_file: Some(output_file),
            ..self
        }
    }

    /// Build the [`Config`] object.
    ///
    /// # Errors
    ///
    /// [`Error::Internal`] if the required methods have not been invoked.
    #[inline]
    pub fn build(mut self) -> Result<Config, Error> {
        let root_dir = self
            .root_dir
            .take()
            .ok_or_else(|| Error::Internal(eyre!("ConfigBuilder::root_dir() not invoked")))?;
        let output_file = self
            .output_file
            .take()
            .ok_or_else(|| Error::Internal(eyre!("ConfigBuilder::output_file() not invoked")))?;
        Ok(Config {
            check_only: self.check_only,
            root_dir,
            output_file,
        })
    }
}

/// The description of a single test case.
#[derive(Deserialize, Serialize)]
pub struct TestCase {
    /// The human-readable name of the test case.
    title: String,

    /// Is this test supposed to succeed?
    success: bool,

    /// The name of the file to send/execute.
    filename: String,

    /// The regular expression to match the remrun output against.
    output_template: String,

    /// The options to pass to the program to execute.
    program_options: Vec<String>,

    /// The options to pass to remrun.
    remrun_options: Vec<String>,

    /// The name of the file to redirect the standard input from.
    stdin_filename: Option<String>,
}

impl TestCase {
    /// The human-readable name of the test case.
    #[inline]
    #[must_use]
    pub fn title(&self) -> &str {
        &self.title
    }

    /// Is this test supposed to succeed?
    #[inline]
    #[must_use]
    pub const fn success(&self) -> bool {
        self.success
    }

    /// The name of the file to send/execute.
    #[inline]
    #[must_use]
    pub fn filename(&self) -> &str {
        &self.filename
    }

    /// The regular expression to match the remrun output against.
    #[inline]
    #[must_use]
    pub fn output_template(&self) -> &str {
        &self.output_template
    }

    /// The options to pass to the program to execute.
    #[inline]
    #[must_use]
    pub fn program_options(&self) -> &[String] {
        &self.program_options
    }

    /// The options to pass to remrun.
    #[inline]
    #[must_use]
    pub fn remrun_options(&self) -> &[String] {
        &self.remrun_options
    }

    /// The name of the file to redirect the standard input from.
    #[inline]
    #[must_use]
    pub fn stdin_filename(&self) -> Option<&str> {
        self.stdin_filename.as_deref()
    }
}

/// The full test data.
#[derive(Deserialize, Serialize)]
pub struct TestData {
    /// The test cases themselves.
    tests: Vec<TestCase>,
}

impl TestData {
    /// The test cases themselves.
    #[inline]
    #[must_use]
    pub fn tests(&self) -> &[TestCase] {
        &self.tests
    }

    /// Convert into the tests themselves.
    #[cfg(test)]
    #[inline]
    pub fn into_tests_iter(self) -> impl Iterator<Item = TestCase> {
        self.tests.into_iter()
    }

    /// Build a [`TestData`] object from a [`TestCase`] iterator.
    #[cfg(test)]
    #[inline]
    #[must_use]
    pub fn from_tests_iter<I>(it: I) -> Self
    where
        I: Iterator<Item = TestCase>,
    {
        Self {
            tests: it.collect(),
        }
    }
}

/// Find the top-level source directory.
///
/// # Errors
///
/// [`Error::FsError`] if something could not be read from the filesystem.
/// [`Error::Internal`] if the directory structure is not as we expect it to be.
#[cfg(test)]
#[inline]
fn get_root_dir() -> Result<Utf8PathBuf, Error> {
    let cwd = camino::absolute_utf8(env!("CARGO_MANIFEST_DIR"))
        .context("Could not get the current working directory as valid UTF-8")
        .map_err(Error::FsError)?;
    if !matches!(
        cwd.file_name()
            .ok_or_else(|| Error::Internal(eyre!("No basename for {cwd}")))?,
        "generate-tests" | "remrun",
    ) {
        return Err(Error::Internal(eyre!(
            "Expected a <top-dir>/rust/generate-tests current directory, got {cwd}"
        )));
    }
    let parent = cwd
        .parent()
        .ok_or_else(|| Error::Internal(eyre!("No dirname for {cwd}")))?;
    if parent
        .file_name()
        .ok_or_else(|| Error::Internal(eyre!("No basename for {parent}")))?
        != "rust"
    {
        return Err(Error::Internal(eyre!(
            "Expected a <top-dir>/rust/generate-tests current directory, got {cwd}"
        )));
    }
    parent
        .parent()
        .ok_or_else(|| Error::Internal(eyre!("No dirname for {parent}")))
        .map(Into::into)
}

/// Build a [`Config`] object to use for the test suite.
///
/// # Errors
///
/// [`Error::FsError`] if something could not be read from the filesystem.
/// [`Error::Internal`] if the directory structure is not as we expect it to be.
#[cfg(test)]
#[inline]
pub fn get_test_config() -> Result<Config, Error> {
    Config::builder()
        .root_dir(get_root_dir()?)
        .output_file(OutputTarget::Stdout)
        .build()
}
