// Copyright (C) 2026  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::sync::{Arc, Mutex};

use swh_contents::blame::blame::Blamer;
use swh_contents::diff::differ::RevDiffer;
use swh_graph::graph::SwhGraphWithProperties;
use swh_graph::graph_builder::BuiltGraph;
use swh_graph::{NodeType, SWHID};

use swh_osv::szz::strategies::StrategyFactory;
use swh_osv::szz::{IntroductionFile, IntroductionFiles};

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()
    }};
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct IgnoreCall {
    rev: swh_graph::graph::NodeId,
    path: Vec<swh_graph::labels::LabelNameId>,
    old_cnt: swh_graph::graph::NodeId,
    new_cnt: swh_graph::graph::NodeId,
}

/// A test strategy that ignores specific file paths
struct IgnoringStrategy<'s> {
    graph: Arc<BuiltGraph>,
    stats: &'s swh_osv::szz::SzzStats,
    paths_to_ignore: Vec<Vec<u8>>,
    calls: Arc<Mutex<Vec<IgnoreCall>>>,
}

impl<'s> swh_osv::szz::strategies::Strategy for IgnoringStrategy<'s> {
    fn get_fix_revs(
        &mut self,
        range: &osv::schema::Range,
    ) -> anyhow::Result<Option<swh_graph_stdlib::collections::SmallNodeSet>> {
        swh_osv::szz::strategies::parse_range_revs(&self.graph, range, self.stats)
            .map(|option| option.map(|(_intro_at_zero, _intro_revs, fix_rev)| fix_rev))
    }

    fn get_introduction_revs(
        &mut self,
        range: &osv::schema::Range,
    ) -> anyhow::Result<Option<(bool, swh_graph_stdlib::collections::SmallNodeSet)>> {
        swh_osv::szz::strategies::parse_range_revs(&self.graph, range, self.stats).map(|option| {
            option.map(|(intro_at_zero, intro_revs, _fix_rev)| (intro_at_zero, intro_revs))
        })
    }

    fn ignore_changed_content(
        &mut self,
        rev: swh_graph::graph::NodeId,
        path: &[swh_graph::labels::LabelNameId],
        old_cnt: swh_graph::graph::NodeId,
        new_cnt: swh_graph::graph::NodeId,
    ) -> anyhow::Result<bool> {
        self.calls.lock().unwrap().push(IgnoreCall {
            rev,
            path: path.to_vec(),
            old_cnt,
            new_cnt,
        });

        for ignore_path in &self.paths_to_ignore {
            for &label_id in path {
                let label_name = self.graph.properties().label_name(label_id);
                if label_name == ignore_path.as_slice() {
                    return Ok(true);
                }
            }
        }

        Ok(false)
    }
}

struct IgnoringStrategyFactory {
    graph: Arc<BuiltGraph>,
    paths_to_ignore: Vec<Vec<u8>>,
    calls: Arc<Mutex<Vec<IgnoreCall>>>,
}

impl IgnoringStrategyFactory {
    fn new(graph: Arc<BuiltGraph>, paths_to_ignore: Vec<Vec<u8>>) -> Self {
        Self {
            graph,
            paths_to_ignore,
            calls: Arc::new(Mutex::new(Vec::new())),
        }
    }

    fn get_calls(&self) -> Vec<IgnoreCall> {
        self.calls.lock().unwrap().clone()
    }
}

impl swh_osv::szz::strategies::StrategyFactory for IgnoringStrategyFactory {
    type Strategy<'s>
        = IgnoringStrategy<'s>
    where
        Self: 's;

    fn get_instance<'s>(
        &'s self,
        _vulnerability_filename: &'s str,
        stats: &'s swh_osv::szz::SzzStats,
    ) -> anyhow::Result<Self::Strategy<'s>> {
        Ok(IgnoringStrategy {
            graph: self.graph.clone(),
            stats,
            paths_to_ignore: self.paths_to_ignore.clone(),
            calls: self.calls.clone(),
        })
    }
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_ignore_changed_content_ignores_changes() {
    // ignored.txt changes (will be ignored)
    let ignored1 = b"version 1.0\n";
    let ignored2 = b"version 2.0\n";
    let ignored3 = b"version 2.1\n";

    // code.rs changes (will not be ignored, but finding bug here)
    let code1 = b"fn foo() { }\n";
    let code2 = b"fn foo() { bug }\n";
    let code3 = b"fn foo() { fixed }\n";

    let graph = Arc::new(get_linear_2files_graph());
    let content_retriever =
        get_content_retriever!(&graph, [ignored1, ignored2, ignored3, code1, code2, code3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    // Create strategy that ignores changes to "ignored.txt" only
    let stats = swh_osv::szz::SzzStats::default();
    let factory = IgnoringStrategyFactory::new(graph.clone(), vec![b"ignored.txt".to_vec()]);
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        IgnoringStrategyFactory::new(graph.clone(), vec![b"ignored.txt".to_vec()]),
        blamer,
    );
    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 code_rs = graph.properties().label_name_id(b"code.rs").unwrap();
    let ignored_txt = graph.properties().label_name_id(b"ignored.txt").unwrap();

    // changes to ignored.txt are ignored, but code.rs changes are still traced
    let expected = map!(
        3 => IntroductionFiles(map!(
            Box::from([code_rs]) => IntroductionFile { line_ranges: set!{ 0..1 } },
        )),
    );
    assert_eq!(result, expected);

    let calls = factory.get_calls();
    let expected_calls = vec![
        IgnoreCall {
            rev: 4,
            path: vec![code_rs],
            old_cnt: 13, // cnt_code2
            new_cnt: 14, // cnt_code3
        },
        IgnoreCall {
            rev: 4,
            path: vec![ignored_txt],
            old_cnt: 10, // cnt_ignored2
            new_cnt: 11, // cnt_ignored3
        },
    ];
    assert_eq!(calls, expected_calls);
}

#[tokio::test(flavor = "multi_thread")]
#[test_log::test]
async fn test_ignore_changed_content_processes_non_ignored_changes() {
    // ignored.txt changes
    let ignored1 = b"version 1.0\n";
    let ignored2 = b"version 2.0\n";
    let ignored3 = b"version 2.1\n";

    // code.rs changes
    let code1 = b"fn foo() { }\n";
    let code2 = b"fn foo() { bug }\n";
    let code3 = b"fn foo() { fixed }\n";

    let graph = Arc::new(get_linear_2files_graph());
    let content_retriever =
        get_content_retriever!(&graph, [ignored1, ignored2, ignored3, code1, code2, code3]);
    let differ = RevDiffer::new(graph.clone(), content_retriever.clone());

    let stats = swh_osv::szz::SzzStats::default();
    let factory = IgnoringStrategyFactory::new(graph.clone(), vec![]);
    let blamer = Blamer::new(graph.clone(), content_retriever.clone());
    let processor = swh_osv::szz::SzzProcessor::new(
        graph.clone(),
        content_retriever,
        IgnoringStrategyFactory::new(graph.clone(), vec![]), // don't ignore anything
        blamer,
    );
    let mut strategy = factory.get_instance("TEST-0001.json", &stats).unwrap();

    let code_rs = graph.properties().label_name_id(b"code.rs").unwrap();
    let ignored_txt = graph.properties().label_name_id(b"ignored.txt").unwrap();

    // Should find the introduction revision normally (based on both files)
    let result = processor
        .szz(&differ, &mut strategy, 4, &mut None::<NoWriter>)
        .await
        .expect("SZZ failed");

    // the revision changing ignored.txt is attributed some of the blame because we didn't ignore
    // .txt files.
    let expected = map!(
        2 => IntroductionFiles(map!(
            Box::from([ignored_txt]) => IntroductionFile { line_ranges: set!{ 0..1 } },
        )),
        3 => IntroductionFiles(map!(
            Box::from([code_rs]) => IntroductionFile { line_ranges: set!{ 0..1 } },
        )),
    );
    assert_eq!(result, expected);

    let calls = factory.get_calls();
    let expected_calls = vec![
        IgnoreCall {
            rev: 4,
            path: vec![code_rs],
            old_cnt: 13, // cnt_code2
            new_cnt: 14, // cnt_code3
        },
        IgnoreCall {
            rev: 4,
            path: vec![ignored_txt],
            old_cnt: 10, // cnt_ignored2
            new_cnt: 11, // cnt_ignored3
        },
    ];
    assert_eq!(calls, expected_calls);
}
