#![warn(missing_docs)]
#![cfg_attr(not(test), warn(clippy::unwrap_used))]

//! Shared benchmark fixtures and helpers for the shuck workspace.

use std::path::{Path, PathBuf};
use std::sync::LazyLock;

use shuck_parser::parser::{ParseResult, Parser};
#[cfg(feature = "parser-benchmarking")]
use shuck_parser::parser::{ParseStatus, ParserBenchmarkCounters};

/// Categorize fixtures by expected runtime so Criterion can spend
/// more time where it is useful without making the slowest cases drag.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestCaseSpeed {
    /// Small fixtures that can use Criterion's default-style sample count.
    Fast,
    /// Medium fixtures that need fewer iterations.
    Normal,
    /// Large fixtures that should use the minimum supported sample count.
    Slow,
}

impl TestCaseSpeed {
    /// Return the Criterion sample size associated with this speed bucket.
    pub fn sample_size(self) -> usize {
        match self {
            Self::Fast => 100,
            Self::Normal => 20,
            // Criterion enforces a minimum sample size of 10.
            Self::Slow => 10,
        }
    }
}

/// Benchmark fixture source bundled with this crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TestFile {
    /// Stable fixture name used in benchmark labels.
    pub name: &'static str,
    /// Shell source text for the fixture.
    pub source: &'static str,
    /// Runtime bucket for this fixture.
    pub speed: TestCaseSpeed,
}

/// Benchmark case made up of one or more bundled test files.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TestCase {
    /// Stable benchmark case name.
    pub name: &'static str,
    /// Fixture files included in this benchmark case.
    pub files: &'static [TestFile],
    /// Runtime bucket for the aggregate case.
    pub speed: TestCaseSpeed,
}

impl TestCase {
    /// Return the total source size across all files in this case.
    pub fn total_bytes(self) -> u64 {
        self.files.iter().map(|file| file.source.len() as u64).sum()
    }
}

fn fixture_source(bytes: &'static [u8]) -> &'static str {
    let source = match std::str::from_utf8(bytes) {
        Ok(source) => source,
        Err(err) => panic!("benchmark fixtures should be valid UTF-8: {err}"),
    };
    if source.contains('\r') {
        Box::leak(
            source
                .replace("\r\n", "\n")
                .replace('\r', "\n")
                .into_boxed_str(),
        )
    } else {
        source
    }
}

/// Built-in benchmark fixtures used by the benchmark suite.
pub static TEST_FILES: LazyLock<Vec<TestFile>> = LazyLock::new(|| {
    vec![
        TestFile {
            name: "fzf-install",
            source: fixture_source(include_bytes!("../resources/files/fzf-install.sh")),
            speed: TestCaseSpeed::Fast,
        },
        TestFile {
            name: "homebrew-install",
            source: fixture_source(include_bytes!("../resources/files/homebrew-install.sh")),
            speed: TestCaseSpeed::Fast,
        },
        TestFile {
            name: "ruby-build",
            source: fixture_source(include_bytes!("../resources/files/ruby-build.sh")),
            speed: TestCaseSpeed::Normal,
        },
        TestFile {
            name: "pyenv-python-build",
            source: fixture_source(include_bytes!("../resources/files/pyenv-python-build.sh")),
            speed: TestCaseSpeed::Normal,
        },
        TestFile {
            name: "nvm",
            source: fixture_source(include_bytes!("../resources/files/nvm.sh")),
            speed: TestCaseSpeed::Slow,
        },
        TestFile {
            name: "bashtop",
            source: fixture_source(include_bytes!("../resources/files/bashtop.sh")),
            speed: TestCaseSpeed::Slow,
        },
    ]
});

/// Returns single-file and aggregate benchmark cases derived from `TEST_FILES`.
pub fn benchmark_cases() -> Vec<TestCase> {
    let mut cases = TEST_FILES
        .iter()
        .map(|file| TestCase {
            name: file.name,
            files: std::slice::from_ref(file),
            speed: file.speed,
        })
        .collect::<Vec<_>>();

    cases.push(TestCase {
        name: "all",
        files: TEST_FILES.as_slice(),
        speed: TestCaseSpeed::Slow,
    });

    cases
}

/// Returns the benchmark resources directory within this crate.
pub fn resources_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("resources")
}

/// Parses a bundled fixture with the default parser configuration.
pub fn parse_fixture(source: &str) -> ParseResult {
    Parser::new(source).parse()
}

#[cfg(feature = "parser-benchmarking")]
#[doc(hidden)]
pub struct CountedParseFixtureOutput {
    pub output: ParseResult,
    pub counters: ParserBenchmarkCounters,
    pub recovered: bool,
}

#[cfg(feature = "parser-benchmarking")]
#[doc(hidden)]
pub fn parse_fixture_with_benchmark_counters(source: &str) -> CountedParseFixtureOutput {
    let (output, counters) = Parser::new(source).parse_with_benchmark_counters();

    CountedParseFixtureOutput {
        recovered: output.status != ParseStatus::Clean,
        output,
        counters,
    }
}

/// Configures the benchmark allocator used by this crate's benches.
#[macro_export]
macro_rules! configure_benchmark_allocator {
    () => {
        #[cfg(not(target_os = "windows"))]
        #[global_allocator]
        static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
    };
}

#[cfg(test)]
mod tests {
    #[cfg(feature = "parser-benchmarking")]
    use super::{Parser, parse_fixture_with_benchmark_counters};
    use super::{TEST_FILES, benchmark_cases, parse_fixture, resources_dir};
    use serde::Deserialize;
    use shuck_formatter::{FormattedSource, ShellFormatOptions, format_file_ast, format_source};
    use shuck_linter::{AnalysisRequest, LinterSettings, ShellCheckCodeMap};

    #[derive(Debug, Deserialize)]
    struct Manifest {
        fixtures: Vec<Fixture>,
    }

    #[derive(Debug, Deserialize)]
    struct Fixture {
        local_filename: String,
        byte_size: usize,
    }

    #[test]
    fn fixture_sources_match_manifest_sizes() {
        let manifest = serde_json::from_str::<Manifest>(include_str!("../resources/manifest.json"))
            .expect("benchmark fixture manifest should parse");

        let fixture_sizes = manifest
            .fixtures
            .iter()
            .map(|fixture| (fixture.local_filename.as_str(), fixture.byte_size))
            .collect::<std::collections::BTreeMap<_, _>>();

        for fixture in manifest.fixtures.iter() {
            let source = std::fs::read_to_string(resources_dir().join(&fixture.local_filename))
                .unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture.local_filename));
            assert_eq!(
                source.len(),
                fixture.byte_size,
                "{}",
                fixture.local_filename
            );
            assert!(!source.is_empty(), "{}", fixture.local_filename);
        }

        for test_file in TEST_FILES.iter() {
            let local_filename = format!("files/{}.sh", test_file.name);
            assert_eq!(
                fixture_sizes.get(local_filename.as_str()).copied(),
                Some(test_file.source.len()),
                "{}",
                test_file.name
            );
            assert!(!test_file.source.is_empty(), "{}", test_file.name);
        }
    }

    #[test]
    fn benchmark_cases_include_per_file_and_aggregate_cases() {
        let cases = benchmark_cases();

        assert_eq!(cases.len(), TEST_FILES.len() + 1);
        assert_eq!(cases.last().map(|case| case.name), Some("all"));
        assert_eq!(
            cases.last().map(|case| case.total_bytes()),
            Some(TEST_FILES.iter().map(|file| file.source.len() as u64).sum())
        );
    }

    #[test]
    fn resources_directory_exists() {
        assert!(resources_dir().is_dir());
    }

    #[test]
    fn benchmark_corpus_parses_in_best_effort_mode() {
        for file in TEST_FILES.iter() {
            let output = parse_fixture(file.source);
            assert!(
                !output.file.body.is_empty(),
                "{} should produce some parsed commands",
                file.name
            );
        }
    }

    #[test]
    fn benchmark_corpus_survives_lint_pipeline() {
        let settings = LinterSettings::default();
        let shellcheck_map = ShellCheckCodeMap::default();

        for file in TEST_FILES.iter() {
            let output = parse_fixture(file.source);
            let diagnostics = AnalysisRequest::from_parse_result(&output, file.source, &settings)
                .with_shellcheck_map(&shellcheck_map)
                .lint();

            assert!(
                diagnostics.len() < usize::MAX,
                "{} should produce a finite diagnostic set",
                file.name
            );
        }
    }

    #[test]
    fn benchmark_corpus_survives_formatter_pipeline() {
        let options = ShellFormatOptions::default();

        for file in TEST_FILES.iter() {
            match format_source(file.source, None, &options) {
                Ok(FormattedSource::Unchanged) | Ok(FormattedSource::Formatted(_)) => {}
                Err(error) => panic!("{} should format successfully: {error}", file.name),
            }
        }
    }

    #[test]
    fn benchmark_corpus_survives_ast_formatter_pipeline() {
        let options = ShellFormatOptions::default();

        for file in TEST_FILES.iter() {
            let output = parse_fixture(file.source);
            match format_file_ast(file.source, output.file, None, &options) {
                Ok(FormattedSource::Unchanged) | Ok(FormattedSource::Formatted(_)) => {}
                Err(error) => panic!("{} should format from AST successfully: {error}", file.name),
            }
        }
    }

    #[cfg(feature = "parser-benchmarking")]
    #[test]
    fn counted_parse_fixture_matches_best_effort_parse_mode() {
        let file = TEST_FILES
            .iter()
            .find(|file| file.name == "nvm")
            .expect("nvm benchmark fixture should exist");

        let counted = parse_fixture_with_benchmark_counters(file.source);
        let uncounted_recovered = Parser::new(file.source).parse().is_err();

        assert_eq!(counted.recovered, uncounted_recovered);
        assert!(
            !counted.output.file.body.is_empty(),
            "counted parse should produce some parsed commands"
        );
        assert!(counted.counters.lexer_current_position_calls > 0);
        assert!(counted.counters.parser_set_current_spanned_calls > 0);
        assert!(counted.counters.parser_advance_raw_calls > 0);
    }
}
