#![expect(clippy::print_stdout)]

mod ignore_list;
pub mod jsdoc;
pub mod options;
mod spec;

use std::{
    fmt::Write,
    path::{Path, PathBuf},
};

use cow_utils::CowUtils;
use rustc_hash::FxHashSet;
use similar::TextDiff;
use walkdir::WalkDir;

use oxc_allocator::Allocator;
use oxc_formatter::JsFormatOptions;
use oxc_formatter_css::CssFormatOptions;
use oxc_formatter_graphql::GraphqlFormatOptions;
use oxc_formatter_json::JsonFormatOptions;
use oxc_span::SourceType;

use crate::{
    ignore_list::IGNORE_TESTS,
    options::TestRunnerOptions,
    spec::{SpecOptions, parse_spec},
};

#[test]
#[cfg(any(coverage, coverage_nightly))]
fn test() {
    use crate::options::TestLanguage;
    TestRunner::new(TestRunnerOptions::default()).run();
    TestRunner::new(TestRunnerOptions {
        language: TestLanguage::Ts,
        ..TestRunnerOptions::default()
    })
    .run();
}

fn root() -> PathBuf {
    oxc_tasks_common::project_root().join("tasks").join("prettier_conformance")
}

fn fixtures_root() -> PathBuf {
    root().join("prettier").join("tests").join("format")
}

fn snap_root() -> PathBuf {
    root().join("snapshots")
}

const FORMAT_TEST_SPEC_NAME: &str = "format.test.js";
const SNAPSHOT_DIR_NAME: &str = "__snapshots__";
const SNAPSHOT_FILE_NAME: &str = "format.test.js.snap";

#[derive(Default)]
struct SnapshotResults {
    /// `(path, (failed_count, passed_count, diff_ratio))` per file with at least one mismatch
    failed_files: Vec<(PathBuf, (usize, usize, f32))>,
    /// Files the formatter failed to parse for at least one options combination
    skipped_files: Vec<PathBuf>,
}

pub struct TestRunner {
    options: TestRunnerOptions,
}

impl TestRunner {
    pub fn new(options: TestRunnerOptions) -> Self {
        Self { options }
    }

    /// # Panics
    pub fn run(&self) {
        let test_lang = self.options.language.as_str();
        let test_dirs =
            Self::collect_test_dirs(&self.options.language.fixtures_roots(&fixtures_root()));

        // If filter is set, only run the specified test for debug
        if self.options.filter.is_some() {
            for dir in &test_dirs {
                let inputs = Self::collect_test_files(dir, self.options.filter.as_ref());
                // If filter is set, many of the tests can be skipped
                if !inputs.is_empty() {
                    // This will print the diff
                    let _results = self.test_snapshots(dir, &inputs, true);
                }
            }

            return;
        }

        // Otherwise, run all tests and generate coverage reports
        let mut total_tested_file_count = 0;
        let mut total_failed_file_count = 0;
        let mut total_skipped_files = vec![];
        let mut failed_reports = String::new();
        failed_reports.push_str("# Failed\n");
        failed_reports.push('\n');
        failed_reports.push_str("| Spec path | Failed or Passed | Match ratio |\n");
        failed_reports.push_str("| :-------- | :--------------: | :---------: |\n");
        for dir in &test_dirs {
            let inputs = Self::collect_test_files(dir, None);
            let results = self.test_snapshots(dir, &inputs, false);

            total_tested_file_count += inputs.len();
            total_failed_file_count += results.failed_files.len();
            total_skipped_files.extend(results.skipped_files);

            for (path, (failed, passed, ratio)) in results.failed_files {
                writeln!(
                    failed_reports,
                    "| {} | {}{} | {:.2}% |",
                    path.strip_prefix(fixtures_root()).unwrap().to_string_lossy(),
                    "💥".repeat(failed),
                    "✨".repeat(passed),
                    ratio * 100.0
                )
                .unwrap();
            }
        }

        let passed = total_tested_file_count - total_failed_file_count;
        #[expect(clippy::cast_precision_loss)]
        let percentage = (passed as f64 / total_tested_file_count as f64) * 100.0;
        // Files our formatter cannot parse are counted as passed above
        // (they have no diff to report); surface them so the gap is visible.
        let summary = format!(
            "{test_lang} compatibility: {passed}/{total_tested_file_count} ({percentage:.2}%), {} files skipped",
            total_skipped_files.len()
        );

        // Print summary
        println!("{summary}");
        // And generate coverage reports
        let mut snapshot = format!("{summary}\n\n{failed_reports}");
        if !total_skipped_files.is_empty() {
            snapshot
                .push_str("\n# Skipped (parse error, TODO: should be ignored or supported)\n\n");
            for path in &total_skipped_files {
                writeln!(
                    snapshot,
                    "- {}",
                    path.strip_prefix(fixtures_root()).unwrap().to_string_lossy()
                )
                .unwrap();
            }
        }
        std::fs::write(snap_root().join(format!("prettier.{test_lang}.snap.md")), snapshot)
            .unwrap();
    }

    /// Read the first level of directories that contain `__snapshots__` and `format.test.js`
    /// ```text
    /// js/arrows <------------------------------- THIS
    /// ├── __snapshots__
    /// ├── arrow-chain-with-trailing-comments.js
    /// ├── arrow_function_expression.js
    /// ├── format.test.js
    /// ├── semi <-------------------------------- AND THIS
    /// │   ├── __snapshots__
    /// │   ├── format.test.js
    /// │   └── semi.js
    /// └── tuple-and-record.js
    /// ```
    fn collect_test_dirs(fixture_roots: &Vec<PathBuf>) -> Vec<PathBuf> {
        let mut test_dirs = FxHashSet::default();

        for fixture_root in fixture_roots {
            let dirs = WalkDir::new(fixture_root)
                .min_depth(1)
                .into_iter()
                .filter_map(Result::ok)
                .map(|e| {
                    let mut path = e.into_path();
                    if path.is_file()
                        && let Some(parent_path) = path.parent()
                    {
                        path = parent_path.into();
                    }
                    path
                })
                .filter(|path| {
                    path.join(SNAPSHOT_DIR_NAME).exists()
                        && path.join(FORMAT_TEST_SPEC_NAME).exists()
                })
                .collect::<Vec<_>>();

            test_dirs.extend(dirs);
        }

        let mut test_dirs = test_dirs.into_iter().collect::<Vec<_>>();
        test_dirs.sort_unstable();

        test_dirs
    }

    /// Read all test files in the directory with applying ignore + filter
    /// ```text
    /// js/arrows
    /// ├── __snapshots__
    /// ├── arrow-chain-with-trailing-comments.js <---- THIS
    /// ├── arrow_function_expression.js <------------- AND THIS
    /// ├── format.test.js
    /// └── tuple-and-record.js <---------------------- AND THIS
    /// ```
    fn collect_test_files(dir: &Path, filter: Option<&String>) -> Vec<PathBuf> {
        let mut test_files: Vec<PathBuf> = WalkDir::new(dir)
            .min_depth(1)
            .max_depth(1)
            .into_iter()
            .filter_map(Result::ok)
            .filter(|e| !e.file_type().is_dir())
            .filter(|e| e.path().file_name().is_none_or(|name| name != FORMAT_TEST_SPEC_NAME))
            .filter(|e| !IGNORE_TESTS.iter().any(|s| e.path().to_string_lossy().contains(s)))
            .filter(|e| filter.is_none_or(|name| e.path().to_string_lossy().contains(name)))
            .map(|e| e.path().to_path_buf())
            .collect();
        test_files.sort_unstable();

        test_files
    }

    /// Run `oxc_formatter` and compare the output with the Prettier's snapshot
    fn test_snapshots(
        &self,
        dir: &Path,
        test_files: &Vec<PathBuf>,
        has_debug_filter: bool,
    ) -> SnapshotResults {
        // Parse all `runFormatTest()` calls and collect format options
        let spec_path = &dir.join(FORMAT_TEST_SPEC_NAME);
        let spec_calls = parse_spec(spec_path, self.options.language);
        debug_assert!(
            !spec_calls.is_empty(),
            "There is no `runFormatTest()` in {}, please check if it is correct?",
            spec_path.to_string_lossy()
        );

        let spec_calls = spec_calls
            .into_iter()
            .filter(|call| match &call.0 {
                SpecOptions::Js(options) => {
                    // Skip all options that are not supported yet
                    !options.experimental_operator_position.is_start()
                        && !options.experimental_ternaries
                }
                SpecOptions::Json(_) | SpecOptions::Graphql(_) | SpecOptions::Css(_) => true,
            })
            .collect::<Vec<_>>();

        let snapshots =
            std::fs::read_to_string(dir.join(SNAPSHOT_DIR_NAME).join(SNAPSHOT_FILE_NAME)).unwrap();

        let mut results = SnapshotResults::default();
        for path in test_files {
            if self.options.debug || has_debug_filter {
                println!("Test: {}", path.to_string_lossy());
            }
            // Single source text is used for multiple options
            let source_text = std::fs::read_to_string(path).unwrap();

            let mut failed_count = 0;
            let mut skipped_count = 0;
            let mut total_diff_ratio = 0.0;
            // Check every combination of options!
            for (format_options, snapshot_options) in &spec_calls {
                // Single snapshot file contains multiple test cases, so need to find the right one
                let expected = Self::find_output_from_snapshots(
                    &snapshots,
                    path.file_name().unwrap().to_string_lossy().as_ref(),
                    snapshot_options,
                )
                .unwrap();

                let Some(actual) = Self::run_formatter(path, &source_text, format_options.clone())
                else {
                    // Skip the test if parsing failed
                    skipped_count += 1;
                    if self.options.debug {
                        println!("  => Skipped (parsing failed)");
                    }
                    continue;
                };

                let actual = Self::replace_escape_and_eol(
                    &actual,
                    expected.contains("LF>") || expected.contains("<CR"),
                );

                let result = expected == actual;
                let diff = TextDiff::from_lines(&expected, &actual);

                if !result {
                    failed_count += 1;
                    total_diff_ratio += diff.ratio();
                }

                if has_debug_filter {
                    println!(
                        "Options: {{ {} }}",
                        snapshot_options
                            .iter()
                            .filter(|(k, _)| k != "parsers")
                            .map(|(k, v)| format!("{k}: {v}"))
                            .collect::<Vec<_>>()
                            .join(", ")
                    );

                    if result {
                        println!("Passed ✅");
                    } else {
                        println!("Failed ❌");
                        oxc_tasks_common::print_text_diff(&diff);
                    }
                    println!();
                }
            }

            if failed_count != 0 {
                let total_count = spec_calls.len();
                let passed_count = total_count - failed_count;
                #[expect(clippy::cast_precision_loss)]
                let max_diff_ratio = total_count as f32;
                results.failed_files.push((
                    path.clone(),
                    (failed_count, passed_count, total_diff_ratio / max_diff_ratio),
                ));
            }
            if skipped_count != 0 {
                results.skipped_files.push(path.clone());
            }
        }

        results
    }

    /// Extract single output section from snapshot file which contains multiple test cases.
    ///
    /// Format is like below:
    /// ```text
    /// filename1
    /// ===optionsA===
    /// ====input1====
    /// ===output1A===
    /// ==============
    /// filename1
    /// ===optionsB===
    /// ====input1====
    /// ===output1B===
    /// ==============
    ///
    /// filename2
    /// ===optionsA===
    /// ====input2====
    /// ===output2A===
    /// ==============
    /// ```
    ///
    /// There are also options-like strings after the filename, but it seems that format is not guaranteed...
    /// Thus, we need to find the right section by filename and options for sure.
    fn find_output_from_snapshots(
        snap_content: &str,
        file_name: &str,
        snapshot_options: &[(String, String)],
    ) -> Option<String> {
        let filename_started = snap_content.find(&format!("exports[`{file_name} "))?;
        let after_filename = &snap_content[filename_started..];

        // Anchor on the options block (header + key:value lines). The line that
        // follows is a `printWidth` visualization whose exact rendering varies by
        // Prettier version, so we skip it by jumping to the following
        // `=====input=====`. To disambiguate between blocks where one options
        // list is a prefix of another (e.g. `parsers: [...]` vs
        // `parsers: [...] + singleQuote: true`), we require the gap between the
        // matched options and the input marker to be exactly one line (the
        // visualization), retrying past the false match otherwise.
        let options_pattern = format!(
            "====================================options=====================================
{}
",
            snapshot_options
                .iter()
                .map(|(k, v)| format!("{k}: {v}"))
                .collect::<Vec<_>>()
                .join("\n"),
        );
        let input_marker =
            "\n=====================================input======================================\n";
        let mut search_from = 0;
        let expected = loop {
            let pos = after_filename[search_from..].find(&options_pattern)?;
            let after_options = search_from + pos + options_pattern.len();
            let input_pos = after_filename[after_options..].find(input_marker)?;
            let between = &after_filename[after_options..after_options + input_pos];
            if !between.contains('\n') {
                break &after_filename[after_options + input_pos..];
            }
            search_from = after_options;
        };

        let output_start_line =
            "=====================================output=====================================\n";
        let output_started = expected.find(output_start_line)?;
        let output_end_line =
            "\n================================================================================";
        let output_ended = expected.find(output_end_line)?;

        let output = expected[output_started..output_ended]
            .trim_start_matches(output_start_line)
            .trim_end_matches(output_end_line);

        Some(output.to_string())
    }

    /// Apply the same escape rules as Prettier does.
    /// If Prettier's snapshot contains `<LF>`, `<CR>` or `<CRLF>`, we also need to visualize.
    fn replace_escape_and_eol(input: &str, need_eol_visualized: bool) -> String {
        let input = input
            .cow_replace("\\", "\\\\")
            .cow_replace("`", "\\`")
            .cow_replace("${", "\\${")
            .into_owned();

        if need_eol_visualized {
            let mut chars = input.chars();
            let mut result = String::new();

            while let Some(char) = chars.next() {
                match char {
                    '\u{a}' => result.push_str("<LF>\n"),
                    '\u{d}' => {
                        let next = chars.clone().next();
                        if next == Some('\u{a}') {
                            result.push_str("<CRLF>\n");
                            chars.next();
                        } else {
                            result.push_str("<CR>\n");
                        }
                    }
                    _ => {
                        result.push(char);
                    }
                }
            }

            return result;
        }

        input
    }

    /// Dispatches by language: JS/TS via `oxc_formatter`, JSON via `oxc_formatter_json`,
    /// GraphQL via `oxc_formatter_graphql`.
    fn run_formatter(
        path: &Path,
        source_text: &str,
        format_options: SpecOptions,
    ) -> Option<String> {
        match format_options {
            SpecOptions::Js(opts) => Self::run_js_formatter(path, source_text, *opts),
            SpecOptions::Json(opts) => Self::run_json_formatter(source_text, opts),
            SpecOptions::Graphql(opts) => Self::run_graphql_formatter(source_text, opts),
            SpecOptions::Css(opts) => Self::run_css_formatter(source_text, opts),
        }
    }

    fn run_js_formatter(
        path: &Path,
        source_text: &str,
        format_options: JsFormatOptions,
    ) -> Option<String> {
        let allocator = Allocator::default();
        let source_type = SourceType::from_path(path).unwrap();

        let formatted =
            oxc_formatter::format(&allocator, source_text, source_type, format_options, None)
                .ok()?;
        Some(formatted.print().ok()?.into_code())
    }

    fn run_json_formatter(source_text: &str, format_options: JsonFormatOptions) -> Option<String> {
        let allocator = Allocator::default();
        let formatted = oxc_formatter_json::format(&allocator, source_text, format_options).ok()?;
        let printed = formatted.print().ok()?;
        Some(printed.into_code())
    }

    fn run_graphql_formatter(
        source_text: &str,
        format_options: GraphqlFormatOptions,
    ) -> Option<String> {
        let allocator = Allocator::default();
        let formatted =
            oxc_formatter_graphql::format(&allocator, source_text, format_options).ok()?;
        let printed = formatted.print().ok()?;
        Some(printed.into_code())
    }

    fn run_css_formatter(source_text: &str, format_options: CssFormatOptions) -> Option<String> {
        let allocator = Allocator::default();
        let formatted =
            oxc_formatter_css::format(&allocator, source_text, format_options, None).ok()?;
        let printed = formatted.print().ok()?;
        Some(printed.into_code())
    }
}
