// SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
// SPDX-License-Identifier: BSD-2-Clause
//! Generate the test shell program.

use eyre::WrapErr as _;
use handlebars::{
    Context, Handlebars, Helper, HelperResult, Output, RenderContext, RenderErrorReason,
};
use log::debug;

use crate::defs::{Config, Error, TestData};

/// The name under which to register the `Handlebars` template.
const TEMPLATE_NAME: &str = "run-tests";

/// The path to the template file itself.
const TEMPLATE_PATH: &str = "tests/test-data/run-test.sh.hbs";

/// Provide a `datafile` helper for the `Handlebars` renderer.
///
/// Escape special characters in absolute filenames passed to the shell,
/// prepend `$datadir/` to relative ones, use `-` for the standard input stream.
fn helper_datafile(
    helper: &Helper<'_>,
    _: &Handlebars<'_>,
    _: &Context,
    _: &mut RenderContext<'_, '_>,
    out: &mut dyn Output,
) -> HelperResult {
    let value = helper
        .param(0)
        .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("datafile", 0))?
        .value()
        .as_str()
        .ok_or_else(|| {
            RenderErrorReason::ParamTypeMismatchForName(
                "datafile",
                "0".to_owned(),
                "string".to_owned(),
            )
        })?;
    let res = if value == "-" {
        "-".to_owned()
    } else if value.starts_with('/') {
        shell_words::quote(value).to_string()
    } else {
        format!("\"$datadir/{value}\"")
    };
    out.write(&res)?;
    Ok(())
}

/// Provide a `shell_quote` helper for the `Handlebars` renderer.
///
/// Escape special characters in filenames and commands.
fn helper_shell_quote(
    helper: &Helper<'_>,
    _: &Handlebars<'_>,
    _: &Context,
    _: &mut RenderContext<'_, '_>,
    out: &mut dyn Output,
) -> HelperResult {
    let value = helper
        .param(0)
        .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("shell_quote", 0))?
        .value()
        .as_str()
        .ok_or_else(|| {
            RenderErrorReason::ParamTypeMismatchForName(
                "shell_quote",
                "0".to_owned(),
                "string".to_owned(),
            )
        })?;
    let res = shell_words::quote(value);
    out.write(&res)?;
    Ok(())
}

/// Provide a `shell_join` helper for the `Handlebars` renderer.
///
/// Escape special characters in an array of words passed to the shell as a command.
fn helper_shell_join(
    helper: &Helper<'_>,
    _: &Handlebars<'_>,
    _: &Context,
    _: &mut RenderContext<'_, '_>,
    out: &mut dyn Output,
) -> HelperResult {
    let arr = helper
        .param(0)
        .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("shell_join", 0))?
        .value()
        .as_array()
        .ok_or_else(|| {
            RenderErrorReason::ParamTypeMismatchForName(
                "shell_join",
                "0".to_owned(),
                "array of strings".to_owned(),
            )
        })?
        .iter()
        .map(|item| {
            item.as_str().ok_or_else(|| {
                RenderErrorReason::ParamTypeMismatchForName(
                    "shell_join",
                    "0".to_owned(),
                    "array of strings".to_owned(),
                )
            })
        })
        .collect::<Result<Vec<_>, _>>()?;
    let res = shell_words::join(arr);
    out.write(&res)?;
    Ok(())
}

/// Build a handlebars renderer, load the template, etc.
///
/// # Errors
///
/// [`Error::Internal`] if something goes wrong with parsing or rendering the template.
#[inline]
pub fn generate(cfg: &Config, test_data: &TestData) -> Result<String, Error> {
    let template_path = cfg.root_dir().join(TEMPLATE_PATH);
    let mut renderer = Handlebars::new();
    renderer.set_strict_mode(true);
    renderer.register_escape_fn(ToOwned::to_owned);
    renderer.register_helper("datafile", Box::new(helper_datafile));
    renderer.register_helper("shell_quote", Box::new(helper_shell_quote));
    renderer.register_helper("shell_join", Box::new(helper_shell_join));
    renderer
        .register_template_file(TEMPLATE_NAME, &template_path)
        .with_context(|| format!("Could not parse the {template_path} template"))
        .map_err(Error::Internal)?;

    debug!("Rendering {count} tests", count = test_data.tests().len());
    renderer
        .render(TEMPLATE_NAME, test_data)
        .with_context(|| format!("Could not render the {TEMPLATE_NAME} template"))
        .map_err(Error::Internal)
}

#[cfg(test)]
#[expect(clippy::panic_in_result_fn, reason = "this is a test suite")]
mod tests {
    use std::fs;

    use eyre::{Result, WrapErr as _};
    use roundlet::test_log;
    use rstest::rstest;

    use crate::defs::{self, TestData};
    use crate::loader;

    /// The path to the autogenerated tests file to compare against.
    const GENERATED_PATH: &str = "tests/run-test.sh";

    #[rstest]
    #[case(0, 0)]
    #[case(1, 0)]
    #[case(0, 1)]
    #[case(1, 1)]
    #[case(3, 4)]
    fn generate(#[case] count_bad: usize, #[case] count_good: usize) -> Result<()> {
        test_log::setup_logging();
        let cfg = defs::get_test_config()?;
        let selected = {
            let tdata = loader::load_defs(&cfg)?;
            let (bad_tests, good_tests) = tdata.into_tests_iter().fold(
                (vec![], vec![]),
                |(mut acc_bad, mut acc_good), tcase| {
                    #[expect(clippy::collapsible_else_if, reason = "clarity")]
                    if tcase.success() {
                        if acc_good.len() < count_good {
                            acc_good.push(tcase);
                        }
                    } else {
                        if acc_bad.len() < count_bad {
                            acc_bad.push(tcase);
                        }
                    }
                    (acc_bad, acc_good)
                },
            );
            assert_eq!(bad_tests.len(), count_bad);
            assert_eq!(good_tests.len(), count_good);
            TestData::from_tests_iter(good_tests.into_iter().chain(bad_tests.into_iter()))
        };

        let contents = super::generate(&cfg, &selected)?;
        let (lines_bad, lines_good) =
            contents
                .lines()
                .fold((vec![], vec![]), |(mut acc_bad, mut acc_good), line| {
                    #[expect(clippy::else_if_without_else, reason = "we only handle these two")]
                    if line.starts_with("test_bad ") {
                        acc_bad.push(line);
                    } else if line.starts_with("test_good ") {
                        acc_good.push(line);
                    }
                    (acc_bad, acc_good)
                });

        assert_eq!((lines_bad.len(), lines_good.len()), (count_bad, count_good));
        Ok(())
    }

    #[test]
    fn generate_compare() -> Result<()> {
        test_log::setup_logging();
        let cfg = defs::get_test_config()?;
        let generated_path = cfg.root_dir().join(GENERATED_PATH);
        let tdata = loader::load_defs(&cfg)?;
        let contents = super::generate(&cfg, &tdata)?;
        assert_eq!(
            contents,
            fs::read_to_string(&generated_path)
                .with_context(|| format!("Could not read {generated_path}"))?
        );
        Ok(())
    }
}
