// Copyright (C) 2025  The Software Heritage developers
// See the AUTHORS file at the top-level directory of this distribution
// License: GNU General Public License version 3, or any later version
// See top-level LICENSE file for more information

use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;

use anyhow::{Context, Result};
use osv::schema::{Event, Range, RangeType};
use swh_contents::blame::blame::Blamer;
use swh_contents::diff::differ::RevDiffer;
use swh_contents::diff::tokenizers::TrimmedAsciiTokenizer;
use swh_graph::graph::SwhGraphWithProperties;
use swh_graph::labels::LabelNameId;
use swh_graph::views::GraphSpy;
use swh_graph::{NodeType, SWHID, swhid};
use tempfile::TempDir;

use swh_osv::szz::strategies::{LineRange, NaiveStrategyFactory, StrategyFactory};
use swh_osv::szz::{
    FileCreationCache, HunkRecord, IntroductionFile, IntroductionFiles, IntroductionFilesRecord,
    MiddleCommitRecord, RevisionRecord, SzzLineDetailsRecord, SzzOutputRecord,
    list_revs_from_szz_output,
};

type NoWriter = &'static mut Vec<u8>;

#[path = "./graphs.rs"]
mod graphs;
use graphs::*;

// inspired by https://stackoverflow.com/a/27582993/539465
macro_rules! map {
    ($($k:expr => $v:expr),* $(,)?) => {{
        vec![$(($k, $v),)*].into_iter().collect()
    }};
}
macro_rules! set {
    ($($v:expr),* $(,)?) => {{
        vec![$($v,)*].into_iter().collect()
    }};
}

fn read_middle_revisions_dir(dir: &Path) -> Result<Vec<MiddleCommitRecord<'static>>> {
    let mut records = Vec::new();
    for entry in std::fs::read_dir(dir).context("Could not read dir")? {
        dbg!(&entry);
        let path = entry.context("Could not stat entry")?.path();
        let mut reader = csv::Reader::from_reader(
            zstd::stream::read::Decoder::new(File::open(path).context("Could not open entry")?)
                .context("Invalid zstd file")?,
        );
        for record in reader.deserialize() {
            let MiddleCommitRecord {
                rev,
                rev_author_timestamp,
                rev_committer_timestamp,
                predecessor_rev,
                predecessor_rev_author_timestamp,
                predecessor_rev_committer_timestamp,
                hunk_id,
                ancestor_rev,
                ancestor_rev_author_timestamp,
                ancestor_rev_committer_timestamp,
                tag,
                file_path,
                code_before_id,
                code_after_id,
                code_before,
                code_after,
            } = record.context("Could not parse record")?;
            records.push(MiddleCommitRecord {
                rev,
                rev_author_timestamp,
                rev_committer_timestamp,
                predecessor_rev,
                predecessor_rev_author_timestamp,
                predecessor_rev_committer_timestamp,
                hunk_id,
                ancestor_rev,
                ancestor_rev_author_timestamp,
                ancestor_rev_committer_timestamp,
                tag,
                file_path,
                code_before_id,
                code_after_id,
                code_before: code_before.into_owned().into(),
                code_after: code_after.into_owned().into(),
            });
        }
    }
    Ok(records)
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_chain_introduced_in_middle() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nbad line3\nline4\n";
    let file3 = b"line1\nline2\ngood line3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {
        2 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 2..3 } },
        })
    };
    assert_eq!(result, expected);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_fix_only_inserts() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nline3\n";
    let file3 = b"line1\nline2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {};
    assert_eq!(result, expected);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_chain_introduced_in_initial_rev() {
    let file1 = b"line1\nbad line2\n";
    let file2 = b"line1\nbad line2\nline3\n";
    let file3 = b"line1\ngood line2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 1..2 } },
        })
    };
    assert_eq!(result, expected);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_line_number_changed() {
    let file1 = b"line1\nbad line2\n";
    let file2 = b"line 0\nline1\nbad line2\nline3\n";
    let file3 = b"line 0\nline1\ngood line2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 1..2 } },
        })
    };
    assert_eq!(result, expected);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn vszz() {
    let tmp_dir = TempDir::new().unwrap();

    let file1 = b"line1\nbaad line2\n";
    let file2 = b"line1\nbad line2\nline3\nline4\n";
    let file3 = b"line1\ngood line2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let mut processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    )
    .with_min_line_similarity_ratio(Some(0.75))
    .with_middle_revisions_out_dir(tmp_dir.path())
    .unwrap();
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    processor.flush().expect("Could not flush SzzProcessor");
    drop(processor); // FIXME: .flush() is not enough for some reason
    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 1..2 } },
        })
    };
    assert_eq!(result, expected);

    assert_eq!(
        read_middle_revisions_dir(tmp_dir.path()).unwrap(),
        vec![MiddleCommitRecord {
            rev: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
            rev_author_timestamp: None,
            rev_committer_timestamp: None,
            predecessor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
            predecessor_rev_author_timestamp: None,
            predecessor_rev_committer_timestamp: None,
            hunk_id: "".into(),
            ancestor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
            ancestor_rev_author_timestamp: None,
            ancestor_rev_committer_timestamp: None,
            tag: "V-SZZ_middle_revision".into(),
            file_path: "test.txt".to_string(),
            code_before_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000021),
            code_after_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000022),
            code_before: "line1\nbaad line2\n".into(),
            code_after: "line1\nbad line2\nline3\nline4\n".into(),
        }]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn vszz_line_number_changed_in_match() {
    let tmp_dir = TempDir::new().unwrap();

    let file1 = b"line1\nbaad line2\n";
    let file2 = b"line 0\nline1\nbad line2\nline3\n";
    let file3 = b"line 0\nline1\ngood line2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let mut processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    )
    .with_min_line_similarity_ratio(Some(0.75))
    .with_middle_revisions_out_dir(tmp_dir.path())
    .unwrap();
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    processor.flush().expect("Could not flush SzzProcessor");
    drop(processor); // FIXME: .flush() is not enough for some reason

    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 1..2 } },
        })
    };
    assert_eq!(result, expected);

    assert_eq!(
        read_middle_revisions_dir(tmp_dir.path()).unwrap(),
        vec![MiddleCommitRecord {
            rev: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
            rev_author_timestamp: None,
            rev_committer_timestamp: None,
            predecessor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
            predecessor_rev_author_timestamp: None,
            predecessor_rev_committer_timestamp: None,
            hunk_id: "".into(),
            ancestor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
            ancestor_rev_author_timestamp: None,
            ancestor_rev_committer_timestamp: None,
            tag: "V-SZZ_middle_revision".into(),
            file_path: "test.txt".to_string(),
            code_before_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000021),
            code_after_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000022),
            code_before: "line1\nbaad line2\n".into(),
            code_after: "line 0\nline1\nbad line2\nline3\n".into(),
        }]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn vszz_line_number_changed_before_match() {
    let file1 = b"line1\nbaad line2\n";
    let file2 = b"line 0\nbad line2\nline3\n";
    let file3 = b"line 0\nline1\nbad line2\nline3\nline4\n";
    let file4 = b"line 0\nline1\ngood line2\nline3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3, file4]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    )
    .with_min_line_similarity_ratio(Some(0.75));
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 4, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 1..2 } },
        })
    };
    assert_eq!(result, expected);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn vszz_order_changed() {
    let file1 = b"baaad line2\nline1\nline3\nbad line4\nline5";
    let file2 = b"line1\nbaaad line2\nline3\nbaad line4\nline5";
    let file3 = b"line1\ngood line2\nline3\ngood line4\nline5";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    )
    .with_min_line_similarity_ratio(Some(0.75));
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let result = processor
        .szz(&differ, &mut strategy, 3, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");
    let expected = map! {
        1 => IntroductionFiles(map!{
            Box::from([LabelNameId(0)]) => IntroductionFile { line_ranges: set!{ 0..1, 3..4 } },
        })
    };
    assert_eq!(result, expected);
}

#[test]
fn test_list_revs_from_szz_output() {
    let temp_dir = tempfile::tempdir().unwrap();

    // First file with multiple records and all types of revision IDs
    let file1_path = temp_dir.path().join("output1.ndjson");
    let mut file1 = File::create(&file1_path).unwrap();
    let record1 = SzzOutputRecord {
        vuln_filename: "vuln1.json".to_string(),
        fix_revs: vec![
            swhid!(swh:1:rev:0000000000000000000000000000000000000001),
            swhid!(swh:1:rev:0000000000000000000000000000000000000002),
        ],
        fix_revs_tag_names: vec![],
        introduced_at_zero: false,
        known_introduction_revs: vec![swhid!(swh:1:rev:0000000000000000000000000000000000000003)],
        known_introduction_revs_tag_names: vec![],
        computed_introduction_revs: map! {
            swhid!(swh:1:rev:0000000000000000000000000000000000000004) => IntroductionFilesRecord(Default::default()),
            swhid!(swh:1:rev:0000000000000000000000000000000000000005) => IntroductionFilesRecord(Default::default()),
        },
        computed_introduction_revs_tag_names: vec![],
    };
    let record2 = SzzOutputRecord {
        vuln_filename: "vuln2.json".to_string(),
        fix_revs: vec![swhid!(swh:1:rev:0000000000000000000000000000000000000006)],
        fix_revs_tag_names: vec![],
        introduced_at_zero: false,
        known_introduction_revs: vec![],
        known_introduction_revs_tag_names: vec![],
        computed_introduction_revs: map! {
            swhid!(swh:1:rev:0000000000000000000000000000000000000007) => IntroductionFilesRecord(Default::default()),
        },
        computed_introduction_revs_tag_names: vec![],
    };
    writeln!(file1, "{}", serde_json::to_string(&record1).unwrap()).unwrap();
    writeln!(file1, "{}", serde_json::to_string(&record2).unwrap()).unwrap();
    drop(file1);

    // Second file with duplicates from first file and some new IDs
    let file2_path = temp_dir.path().join("output2.ndjson");
    let mut file2 = File::create(&file2_path).unwrap();
    let record3 = SzzOutputRecord {
        vuln_filename: "vuln3.json".to_string(),
        fix_revs: vec![
            swhid!(swh:1:rev:0000000000000000000000000000000000000002),
            swhid!(swh:1:rev:0000000000000000000000000000000000000003),
            swhid!(swh:1:rev:0000000000000000000000000000000000000008),
        ],
        fix_revs_tag_names: vec![],
        introduced_at_zero: false,
        known_introduction_revs: vec![swhid!(swh:1:rev:0000000000000000000000000000000000000009)],
        known_introduction_revs_tag_names: vec![],
        computed_introduction_revs: map! {
            swhid!(swh:1:rev:0000000000000000000000000000000000000005) => IntroductionFilesRecord(Default::default()),
            swhid!(swh:1:rev:000000000000000000000000000000000000000a) => IntroductionFilesRecord(Default::default()),
        },
        computed_introduction_revs_tag_names: vec![],
    };
    writeln!(file2, "{}", serde_json::to_string(&record3).unwrap()).unwrap();
    drop(file2);

    let revs = list_revs_from_szz_output(&get_10rev_linear_graph(), temp_dir.path(), None).unwrap();
    let mut sorted_revs: Vec<_> = revs.into_iter().collect();
    sorted_revs.sort();

    assert_eq!(sorted_revs, vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
}

#[test]
fn test_list_revs_from_szz_output_empty_file() {
    let temp_dir = tempfile::tempdir().unwrap();
    let file_path = temp_dir.path().join("output.ndjson");
    File::create(&file_path).unwrap();

    let graph = Arc::new(get_10rev_linear_graph());
    let revs = list_revs_from_szz_output(&graph, temp_dir.path(), None).unwrap();
    let sorted_revs: Vec<_> = revs.into_iter().collect();

    assert_eq!(sorted_revs, Vec::<usize>::new());
}

#[test]
fn test_list_revs_from_szz_output_from_middle_revisions() {
    let temp_dir = tempfile::tempdir().unwrap();
    let file_path = temp_dir.path().join("output.ndjson");
    File::create(&file_path).unwrap();

    let middle_revisions_dir = tempfile::tempdir().unwrap();
    let file_path = middle_revisions_dir.path().join("middle_revisions.csv");
    csv::Writer::from_path(file_path)
        .unwrap()
        .serialize(MiddleCommitRecord {
            rev: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
            rev_author_timestamp: None,
            rev_committer_timestamp: None,
            predecessor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
            predecessor_rev_author_timestamp: None,
            predecessor_rev_committer_timestamp: None,
            hunk_id: "".into(),
            ancestor_rev: swhid!(swh:1:rev:0000000000000000000000000000000000000008),
            ancestor_rev_author_timestamp: None,
            ancestor_rev_committer_timestamp: None,
            tag: "V-SZZ_middle_revision".into(),
            file_path: "test.txt".to_string(),
            code_before_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000021),
            code_after_id: swhid!(swh:1:cnt:0000000000000000000000000000000000000022),
            code_before: "foo".into(),
            code_after: "bar".into(),
        })
        .unwrap();

    let graph = Arc::new(get_10rev_linear_graph());
    let revs =
        list_revs_from_szz_output(&graph, temp_dir.path(), Some(middle_revisions_dir.path()))
            .unwrap();
    let mut sorted_revs: Vec<_> = revs.into_iter().collect();
    sorted_revs.sort();

    assert_eq!(sorted_revs, vec![2, 3, 8]);
}

#[test]
fn test_list_revs_from_szz_output_empty_directory() {
    let temp_dir = tempfile::tempdir().unwrap();

    let graph = Arc::new(get_10rev_linear_graph());
    let revs = list_revs_from_szz_output(&graph, temp_dir.path(), None).unwrap();
    let sorted_revs: Vec<_> = revs.into_iter().collect();

    assert_eq!(sorted_revs, Vec::<usize>::new());
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_with_valid_fix() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nbad line3\nline4\n";
    let file3 = b"line1\nline2\ngood line3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
        .with_concurrent_downloads_per_task(4);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("0000000000000000000000000000000000000002".to_string()),
            Event::Fixed("0000000000000000000000000000000000000003".to_string()),
        ],
    };

    processor
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 1);
    assert_eq!(
        stats.ignored_because_unknown_commit.load(Ordering::Relaxed),
        0
    );
    assert_eq!(
        stats.total_known_introduction_revs.load(Ordering::Relaxed),
        1
    );
    assert_eq!(
        stats
            .total_computed_introduction_revs
            .load(Ordering::Relaxed),
        1
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_ignores_unknown_commits() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nbad line3\nline4\n";
    let file3 = b"line1\nline2\ngood line3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
        .with_concurrent_downloads_per_task(4);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
            Event::Fixed("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
        ],
    };

    processor
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 0);
    assert_eq!(
        stats.ignored_because_unknown_commit.load(Ordering::Relaxed),
        1
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_ignores_multiple_fixes() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nbad line3\nline4\n";
    let file3 = b"line1\nline2\ngood line3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
        .with_concurrent_downloads_per_task(4);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("0000000000000000000000000000000000000001".to_string()),
            Event::Fixed("0000000000000000000000000000000000000002".to_string()),
            Event::Fixed("0000000000000000000000000000000000000003".to_string()),
        ],
    };

    processor
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 0);
    assert_eq!(
        stats
            .ignored_because_more_than_one_fix
            .load(Ordering::Relaxed),
        1
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_max_hunks() {
    // two hunks: lines 2 and 3, and lines 18 and 19
    let file1 = b"line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n\
                  line11\nline12\nline13\nline14\nline15\nline16\nline17\nline18\nline19\nline20\n";
    let file2 = b"line1\nbad line2\nbad line3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n\
                  line11\nline12\nline13\nline14\nline15\nline16\nline17\nbad line18\nbad line19\nline20\n";
    let file3 = b"line1\ngood line2\ngood line3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n\
                  line11\nline12\nline13\nline14\nline15\nline16\nline17\ngood line18\ngood line19\nline20\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("0000000000000000000000000000000000000002".to_string()),
            Event::Fixed("0000000000000000000000000000000000000003".to_string()),
        ],
    };

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor_with_limit = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever.clone(),
        strategy,
        blamer.clone(),
    )
    .with_concurrent_downloads_per_task(4)
    .with_max_hunks(Some(2));

    processor_with_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_with_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 0);

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let processor_without_limit =
        swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
            .with_concurrent_downloads_per_task(4)
            .with_max_hunks(Some(3));

    processor_without_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_without_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 1);
    assert_eq!(
        stats
            .total_computed_introduction_revs
            .load(Ordering::Relaxed),
        1
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_max_modified_files() {
    // 1 modified file between rev2 and rev3
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nbad line2\n";
    let file3 = b"line1\ngood line2\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("0000000000000000000000000000000000000002".to_string()),
            Event::Fixed("0000000000000000000000000000000000000003".to_string()),
        ],
    };

    // max_modified_files=1 rejects
    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor_with_limit = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever.clone(),
        strategy,
        blamer.clone(),
    )
    .with_concurrent_downloads_per_task(4)
    .with_max_modified_files(Some(1));

    processor_with_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_with_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 0);

    // max_modified_files=2 accepts
    let strategy = NaiveStrategyFactory::new(graph.clone());
    let processor_without_limit =
        swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
            .with_concurrent_downloads_per_task(4)
            .with_max_modified_files(Some(2));

    processor_without_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_without_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 1);
    assert_eq!(
        stats
            .total_computed_introduction_revs
            .load(Ordering::Relaxed),
        1
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_szz_processor_max_blame_steps() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nline3\n";
    let file3 = b"line1\nline2\nbad line3\n";
    let file4 = b"line1\nline2\nbad line3\nline4\n";
    let file5 = b"line1\nline2\nbad line3\nline4\nline5\n";
    let file6 = b"line1\nline2\ngood line3\nline4\nline5\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever =
        get_content_retriever!(&graph, [file1, file2, file3, file4, file5, file6]);

    let range = Range {
        range_type: RangeType::Git,
        repo: None,
        events: vec![
            Event::Introduced("0000000000000000000000000000000000000003".to_string()),
            Event::Fixed("0000000000000000000000000000000000000006".to_string()),
        ],
    };

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor_with_limit = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever.clone(),
        strategy,
        blamer.clone(),
    )
    .with_concurrent_downloads_per_task(4)
    .with_max_blame_steps_per_hunk(Some(2));

    processor_with_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_with_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 0);

    let strategy = NaiveStrategyFactory::new(graph.clone());
    let processor_without_limit =
        swh_osv::szz::SzzProcessor::new(graph, content_retriever, strategy, blamer)
            .with_concurrent_downloads_per_task(4)
            .with_max_blame_steps_per_hunk(Some(3));

    processor_without_limit
        .process_range(
            &mut None::<NoWriter>,
            &mut None::<NoWriter>,
            "TEST-0001.json",
            &range,
        )
        .await
        .unwrap();

    let stats = processor_without_limit.stats();
    assert_eq!(stats.num_successful_szz_runs.load(Ordering::Relaxed), 1);
    assert_eq!(
        stats
            .total_computed_introduction_revs
            .load(Ordering::Relaxed),
        1
    );
}

fn parse_ndjson<T: serde::de::DeserializeOwned>(buf: &[u8]) -> Vec<T> {
    serde_json::Deserializer::from_slice(buf)
        .into_iter()
        .map(|r| r.unwrap())
        .collect()
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_line_details_single_vulnerable_line() {
    let file1 = b"line1\nline2\n";
    let file2 = b"line1\nline2\nbad line3\nline4\n";
    let file3 = b"line1\nline2\ngood line3\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 3, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    assert_eq!(
        records,
        vec![SzzLineDetailsRecord {
            fix_rev: RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
                author_timestamp: None,
                committer_timestamp: None,
            },
            path: "test.txt".to_string(),
            vulnerable_hunk_before_fix: HunkRecord {
                hunk: "bad line3\n".to_string(),
                line_range: LineRange(2..3),
            },
            introduction_rev: RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
                author_timestamp: None,
                committer_timestamp: None,
            },
            vulnerable_hunk_after_introduction: HunkRecord {
                hunk: "bad line3\n".to_string(),
                line_range: LineRange(2..3),
            },
            hunk_before_introduction: Some(HunkRecord {
                hunk: "".to_string(),
                line_range: LineRange(2..2),
            }),
            file_creation: set![RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
                author_timestamp: None,
                committer_timestamp: None,
            }],
            intro_to_fix_rev_num_blame_steps: 1,
            intro_to_fix_rev_distance: 1,
            creation_to_intro_rev_distance: 1,
            creation_to_fix_rev_distance: 2,
        }]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_line_details_multiple_records_and_root_introduction() {
    let file1 = b"bad line1\nline2\n";
    let file2 = b"bad line1\nbad line2\nline3\n";
    let file3 = b"good line1\ngood line2\nline3\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 3, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    let rev1 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev2 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev3 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let file_creation: rapidhash::RapidHashSet<RevisionRecord> = set![RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    }];
    assert_eq!(
        records,
        vec![
            SzzLineDetailsRecord {
                fix_rev: rev3.clone(),
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "bad line1\n".to_string(),
                    line_range: LineRange(0..1),
                },
                introduction_rev: rev1,
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "bad line1\n".to_string(),
                    line_range: LineRange(0..1),
                },
                hunk_before_introduction: None,
                file_creation: file_creation.clone(),
                intro_to_fix_rev_num_blame_steps: 2,
                intro_to_fix_rev_distance: 2,
                creation_to_intro_rev_distance: 0,
                creation_to_fix_rev_distance: 2,
            },
            SzzLineDetailsRecord {
                fix_rev: rev3,
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "bad line2\n".to_string(),
                    line_range: LineRange(1..2),
                },
                introduction_rev: rev2,
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "bad line2\n".to_string(),
                    line_range: LineRange(1..2),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "line2\n".to_string(),
                    line_range: LineRange(1..2),
                }),
                file_creation,
                intro_to_fix_rev_num_blame_steps: 2,
                intro_to_fix_rev_distance: 1,
                creation_to_intro_rev_distance: 1,
                creation_to_fix_rev_distance: 2,
            },
        ]
    );
}

/// Verifies that file_creation contains all creation revisions when a file
/// was independently created in two root commits that were later merged.
///
/// Graph topology:
/// ```text
/// rev1 (root)  \
///               -> rev3 (merge) -> rev4 (fix)
/// rev2 (root)  /
/// ```
///
/// Both rev1 and rev2 are roots that create the file, so file_creation = [rev1, rev2].
#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_line_details_file_created_by_two_merged_commits() {
    // rev1 and rev2 are roots that each create the file with a different preamble before the
    // bad line; rev3 is their merge, combining both preambles
    let file1 = b"line_a\nbad line\n";
    let file2 = b"line_b\nbad line\n";
    let file3 = b"line_a\nline_b\nbad line\n";
    let file4 = b"line_a\nline_b\ngood line\n";

    let graph = Arc::new(get_merge_2roots_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3, file4]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 4, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    let rev1 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev2 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev4 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000004),
        author_timestamp: None,
        committer_timestamp: None,
    };
    assert_eq!(
        records,
        vec![SzzLineDetailsRecord {
            fix_rev: rev4,
            path: "test.txt".to_string(),
            // "bad line\n" is at line 2 in rev3 (after line_a and line_b)
            vulnerable_hunk_before_fix: HunkRecord {
                hunk: "bad line\n".to_string(),
                line_range: LineRange(2..3),
            },
            // Blame traces rev3 line 2 → rev1 line 1 (rev1 is first parent; "bad line" is
            // unchanged, just shifted by the insertion of "line_b" during the merge)
            introduction_rev: rev1.clone(),
            vulnerable_hunk_after_introduction: HunkRecord {
                hunk: "bad line\n".to_string(),
                line_range: LineRange(1..2),
            },
            hunk_before_introduction: None,
            // Both rev1 and rev2 are root commits that independently create the file
            file_creation: set![rev1, rev2],
            // Blame: rev3 -> rev1 (step 1), then rev1 is a root (step 2 confirms introduction)
            intro_to_fix_rev_num_blame_steps: 2,
            // rev4 -> rev3 -> rev1 = 2
            intro_to_fix_rev_distance: 2,
            // intro_rev = rev1 = file_creation[0], distance = 0
            creation_to_intro_rev_distance: 0,
            // min(dist(rev1, rev4), dist(rev2, rev4)) = min(2, 2) = 2
            creation_to_fix_rev_distance: 2,
        }]
    );
}

/// Verifies that rev_distance uses BFS (shortest path), not DFS.
///
/// ```text
/// rev1 <- rev2 <- rev3 (merge) <- rev4 (fix)
///      <----------/  (shortcut: rev3 also has rev1 as parent)
/// ```
#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_line_details_rev_distance_uses_bfs() {
    // All revs 1-3 have the same content (different blobs); rev4 is the fix
    let file1 = b"bad line\n";
    let file2 = b"bad line\n";
    let file3 = b"bad line\n";
    let file4 = b"good line\n";

    let graph = Arc::new(get_merge_shortcut_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3, file4]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 4, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    let rev1 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev4 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000004),
        author_timestamp: None,
        committer_timestamp: None,
    };
    assert_eq!(
        records,
        vec![SzzLineDetailsRecord {
            fix_rev: rev4,
            path: "test.txt".to_string(),
            vulnerable_hunk_before_fix: HunkRecord {
                hunk: "bad line\n".to_string(),
                line_range: LineRange(0..1),
            },
            introduction_rev: rev1.clone(),
            vulnerable_hunk_after_introduction: HunkRecord {
                hunk: "bad line\n".to_string(),
                line_range: LineRange(0..1),
            },
            hunk_before_introduction: None,
            file_creation: set![rev1],
            intro_to_fix_rev_num_blame_steps: 2,
            // Shortest path: rev4->rev3->rev1 = 2, not rev4->rev3->rev2->rev1 = 3
            intro_to_fix_rev_distance: 2,
            creation_to_intro_rev_distance: 0,
            creation_to_fix_rev_distance: 2,
        }]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_vulnerable_hunk_after_introduction_vs_before_fix() {
    let file1 = b"  bad line\n";
    let file2 = b"new line\nbad line\n";
    let file3 = b"  new line\ngood line\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone())
        .with_tokenizer(TrimmedAsciiTokenizer);

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer =
        Blamer::new(graph.clone(), content_retriever.clone()).with_tokenizer(TrimmedAsciiTokenizer);
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 3, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    assert_eq!(
        records,
        vec![SzzLineDetailsRecord {
            fix_rev: RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
                author_timestamp: None,
                committer_timestamp: None,
            },
            path: "test.txt".to_string(),
            vulnerable_hunk_before_fix: HunkRecord {
                hunk: "bad line\n".to_string(),
                line_range: LineRange(1..2),
            },
            introduction_rev: RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
                author_timestamp: None,
                committer_timestamp: None,
            },
            vulnerable_hunk_after_introduction: HunkRecord {
                hunk: "  bad line\n".to_string(),
                line_range: LineRange(0..1),
            },
            hunk_before_introduction: None,
            file_creation: set![RevisionRecord {
                swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
                author_timestamp: None,
                committer_timestamp: None,
            }],
            intro_to_fix_rev_num_blame_steps: 2,
            intro_to_fix_rev_distance: 2,
            creation_to_intro_rev_distance: 0,
            creation_to_fix_rev_distance: 2,
        }]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_hunk_before_introduction_varying_hunk_sizes() {
    // Hunk 1: 3 lines → 1 line (many-to-one)
    // Hunk 2: 1 line → 3 lines (one-to-many)
    // Hunk 3: 2 lines → 3 lines (many-to-many)
    let file1 = b"A1\nA2\nA3\nsep1\nB1\nsep2\nC1\nC2\n";
    let file2 = b"badA\nsep1\nbadB1\nbadB2\nbadB3\nsep2\nbadC1\nbadC2\nbadC3\n";
    let file3 = b"goodA\nsep1\ngoodB1\ngoodB2\ngoodB3\nsep2\ngoodC1\ngoodC2\ngoodC3\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 3, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    let rev1 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev2 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev3 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let file_creation: rapidhash::RapidHashSet<RevisionRecord> = set![rev1];
    assert_eq!(
        records,
        vec![
            // Many-to-one: 3 old lines (A1, A2, A3) replaced by 1 vulnerable line (badA)
            SzzLineDetailsRecord {
                fix_rev: rev3.clone(),
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "badA\n".to_string(),
                    line_range: LineRange(0..1),
                },
                introduction_rev: rev2.clone(),
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "badA\n".to_string(),
                    line_range: LineRange(0..1),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "A1\nA2\nA3\n".to_string(),
                    line_range: LineRange(0..3),
                }),
                file_creation: file_creation.clone(),
                intro_to_fix_rev_num_blame_steps: 1,
                intro_to_fix_rev_distance: 1,
                creation_to_intro_rev_distance: 1,
                creation_to_fix_rev_distance: 2,
            },
            // One-to-many: 1 old line (B1) replaced by 3 vulnerable lines (badB1-3)
            SzzLineDetailsRecord {
                fix_rev: rev3.clone(),
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "badB1\nbadB2\nbadB3\n".to_string(),
                    line_range: LineRange(2..5),
                },
                introduction_rev: rev2.clone(),
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "badB1\nbadB2\nbadB3\n".to_string(),
                    line_range: LineRange(2..5),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "B1\n".to_string(),
                    line_range: LineRange(4..5),
                }),
                file_creation: file_creation.clone(),
                intro_to_fix_rev_num_blame_steps: 1,
                intro_to_fix_rev_distance: 1,
                creation_to_intro_rev_distance: 1,
                creation_to_fix_rev_distance: 2,
            },
            // Many-to-many: 2 old lines (C1, C2) replaced by 3 vulnerable lines (badC1-3)
            SzzLineDetailsRecord {
                fix_rev: rev3,
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "badC1\nbadC2\nbadC3\n".to_string(),
                    line_range: LineRange(6..9),
                },
                introduction_rev: rev2,
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "badC1\nbadC2\nbadC3\n".to_string(),
                    line_range: LineRange(6..9),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "C1\nC2\n".to_string(),
                    line_range: LineRange(6..8),
                }),
                file_creation,
                intro_to_fix_rev_num_blame_steps: 1,
                intro_to_fix_rev_distance: 1,
                creation_to_intro_rev_distance: 1,
                creation_to_fix_rev_distance: 2,
            },
        ]
    );
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn szz_hunk_before_introduction_consecutive_lines_different_commits() {
    // Two adjacent vulnerable lines, each introduced by a different commit
    let file1 = b"line1\norigA\norigB\nline4\n";
    let file2 = b"line1\nbadA\norigB\nline4\n";
    let file3 = b"line1\nbadA\nbadB\nline4\n";
    let file4 = b"line1\ngoodA\ngoodB\nline4\n";

    let graph = Arc::new(get_10rev_linear_graph());
    let content_retriever = get_content_retriever!(&graph, [file1, file2, file3, file4]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = NaiveStrategyFactory::new(graph.clone());
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        NaiveStrategyFactory::new(graph.clone()),
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let mut buf = Vec::new();
    let _result = processor
        .szz(&differ, &mut strategy, 4, &mut Some(&mut buf))
        .await
        .expect("SZZ failed");

    let records: Vec<SzzLineDetailsRecord> = parse_ndjson(&buf);
    let rev1 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000001),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev2 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000002),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev3 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000003),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let rev4 = RevisionRecord {
        swhid: swhid!(swh:1:rev:0000000000000000000000000000000000000004),
        author_timestamp: None,
        committer_timestamp: None,
    };
    let file_creation: rapidhash::RapidHashSet<RevisionRecord> = set![rev1];
    assert_eq!(
        records,
        vec![
            // badA introduced in rev2, replacing origA from rev1
            SzzLineDetailsRecord {
                fix_rev: rev4.clone(),
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "badA\n".to_string(),
                    line_range: LineRange(1..2),
                },
                introduction_rev: rev2,
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "badA\n".to_string(),
                    line_range: LineRange(1..2),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "origA\n".to_string(),
                    line_range: LineRange(1..2),
                }),
                file_creation: file_creation.clone(),
                intro_to_fix_rev_num_blame_steps: 2,
                intro_to_fix_rev_distance: 2,
                creation_to_intro_rev_distance: 1,
                creation_to_fix_rev_distance: 3,
            },
            // badB introduced in rev3, replacing origB from rev2
            SzzLineDetailsRecord {
                fix_rev: rev4,
                path: "test.txt".to_string(),
                vulnerable_hunk_before_fix: HunkRecord {
                    hunk: "badB\n".to_string(),
                    line_range: LineRange(2..3),
                },
                introduction_rev: rev3,
                vulnerable_hunk_after_introduction: HunkRecord {
                    hunk: "badB\n".to_string(),
                    line_range: LineRange(2..3),
                },
                hunk_before_introduction: Some(HunkRecord {
                    hunk: "origB\n".to_string(),
                    line_range: LineRange(2..3),
                }),
                file_creation,
                intro_to_fix_rev_num_blame_steps: 2,
                intro_to_fix_rev_distance: 1,
                creation_to_intro_rev_distance: 2,
                creation_to_fix_rev_distance: 3,
            },
        ]
    );
}

#[test]
fn file_creation_cache_avoids_redundant_graph_calls() {
    let graph = get_10rev_linear_graph();
    let spy = GraphSpy::new(&graph);
    let path = &[LabelNameId(0)]; // "test.txt"

    // Node IDs from builder: snp=0, rev1=1..rev10=10
    let rev1 = 1;
    let rev4 = 4;
    let rev6 = 6;

    let mut cache = FileCreationCache::new(&spy);
    cache.caching_interval = 3;

    let labeled_successors = |spy: &GraphSpy<_>| -> Vec<String> {
        spy.history
            .lock()
            .unwrap()
            .iter()
            .filter(|(method, _)| *method == "untyped_labeled_successors")
            .map(|(_, args)| args.clone())
            .collect()
    };

    // First call: traverses the full chain rev6→…→rev1.
    // With caching_interval=3, DFS order caches rev1 (index 0) and rev4 (index 3).
    let result = cache.find(rev6, path).unwrap();
    assert_eq!(result, std::collections::HashMap::from([(rev1, 5)]));
    // Resolves file path in directories of rev5, rev4, rev3, rev2, rev1.
    assert_eq!(
        labeled_successors(&spy),
        vec!["(15,)", "(14,)", "(13,)", "(12,)", "(11,)"]
    );

    // Second call on rev6: not itself cached, but hits rev4 (cached) early.
    spy.history.lock().unwrap().clear();
    let result2 = cache.find(rev6, path).unwrap();
    assert_eq!(result2, std::collections::HashMap::from([(rev1, 5)]));
    // Only resolves rev5 and rev4's directories before hitting the cache.
    assert_eq!(labeled_successors(&spy), vec!["(15,)", "(14,)"]);

    // Call on rev4 (cached during first traversal): zero graph calls.
    spy.history.lock().unwrap().clear();
    let result4 = cache.find(rev4, path).unwrap();
    assert_eq!(result4, std::collections::HashMap::from([(rev1, 3)]));
    assert_eq!(labeled_successors(&spy), Vec::<String>::new());
}
