//! A module to hold all common file system functionality.

use std::{fmt::Debug, fs, ops::RangeInclusive, path::PathBuf};

use gix_imara_diff::Hunk;

use crate::{
    clang_tools::{
        MakeSuggestions, ReviewComments, Suggestion, clang_format::FormatAdvice,
        clang_tidy::TidyAdvice, make_patch,
    },
    cli::LinesChangedOnly,
    error::FileObjError,
};

/// A structure to represent a file's path and line changes.
#[derive(Debug, Clone)]
pub struct FileObj {
    /// The path to the file.
    pub name: PathBuf,

    /// The list of lines with additions.
    pub added_lines: Vec<u32>,

    /// The list of ranges that span only lines with additions.
    pub added_ranges: Vec<RangeInclusive<u32>>,

    /// The list of ranges that span the lines present in diff chunks.
    pub diff_chunks: Vec<RangeInclusive<u32>>,

    /// The collection of clang-format advice for this file.
    pub format_advice: Option<FormatAdvice>,

    /// The collection of clang-format advice for this file.
    pub tidy_advice: Option<TidyAdvice>,
}

impl FileObj {
    /// Instantiate a rudimentary object with only file name information.
    ///
    /// To instantiate an object with line information, use [`FileObj::from`].
    pub fn new(name: PathBuf) -> Self {
        FileObj {
            name,
            added_lines: Vec::<u32>::new(),
            added_ranges: Vec::<RangeInclusive<u32>>::new(),
            diff_chunks: Vec::<RangeInclusive<u32>>::new(),
            format_advice: None,
            tidy_advice: None,
        }
    }

    /// Instantiate an object with file name and changed lines information.
    pub fn from(
        name: PathBuf,
        added_lines: Vec<u32>,
        diff_chunks: Vec<RangeInclusive<u32>>,
    ) -> Self {
        let added_ranges = FileObj::consolidate_numbers_to_ranges(&added_lines);
        FileObj {
            name,
            added_lines,
            added_ranges,
            diff_chunks,
            format_advice: None,
            tidy_advice: None,
        }
    }

    /// A helper function to consolidate a [Vec<u32>] of line numbers into a
    /// [Vec<RangeInclusive<u32>>] in which each range describes the beginning and
    /// ending of a group of consecutive line numbers.
    fn consolidate_numbers_to_ranges(lines: &[u32]) -> Vec<RangeInclusive<u32>> {
        let mut range_start = None;
        let mut ranges: Vec<RangeInclusive<u32>> = Vec::new();
        for (index, number) in lines.iter().enumerate() {
            if index == 0 {
                range_start = Some(*number);
            } else if number - 1 != lines[index - 1] {
                ranges.push(RangeInclusive::new(range_start.unwrap(), lines[index - 1]));
                range_start = Some(*number);
            }
            if index == lines.len() - 1 {
                ranges.push(RangeInclusive::new(range_start.unwrap(), *number));
            }
        }
        ranges
    }

    pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Vec<RangeInclusive<u32>> {
        match lines_changed_only {
            LinesChangedOnly::Diff => self.diff_chunks.to_vec(),
            LinesChangedOnly::On => self.added_ranges.to_vec(),
            _ => Vec::new(),
        }
    }

    /// Is the range from `start_line` to `end_line` contained in a single item of
    /// [`FileObj::diff_chunks`]?
    pub fn is_hunk_in_diff(&self, hunk: &Hunk) -> Option<(u32, u32)> {
        let (start_line, end_line) = if !hunk.before.is_empty() {
            // if old hunk's total lines is > 0
            let start = hunk.before.start;
            (start, start + hunk.before.len() as u32 - 1)
        } else {
            // old hunk's total lines is 0, meaning changes were only added
            let start = hunk.after.start;
            // make old hunk's range span 1 line
            (start, start)
        };
        for range in &self.diff_chunks {
            if range.contains(&start_line) && range.contains(&end_line) {
                return Some((start_line, end_line));
            }
        }
        None
    }

    /// Similar to [`FileObj::is_hunk_in_diff()`] but looks for a single line instead of
    /// an entire [`DiffHunk`].
    ///
    /// This is a private function because it is only used in
    /// [`FileObj::make_suggestions_from_patch()`].
    fn is_line_in_diff(&self, line: &u32) -> bool {
        for range in &self.diff_chunks {
            if range.contains(line) {
                return true;
            }
        }
        false
    }

    /// Create a list of [`Suggestion`](struct@crate::clang_tools::Suggestion) from a
    /// generated [`Patch`](struct@git2::Patch) and store them in the given
    /// [`ReviewComments`](struct@crate::clang_tools::ReviewComments).
    ///
    /// The suggestions will also include diagnostics from clang-tidy that
    /// did not have a fix applied in the patch.
    pub fn make_suggestions_from_patch(
        &self,
        review_comments: &mut ReviewComments,
        summary_only: bool,
    ) -> Result<(), FileObjError> {
        let original_content = fs::read_to_string(&self.name).map_err(FileObjError::ReadFile)?;
        let file_name = self.name.to_str().unwrap_or_default().replace("\\", "/");
        if let Some(advice) = &self.format_advice
            && let Some(patched) = &advice.patched
        {
            let patched = String::from_utf8(patched.to_vec())
                .map_err(|e| FileObjError::FromUtf8Error(file_name.clone(), e))?;
            let (diff, input) = make_patch(patched.as_str(), &original_content);
            advice.get_suggestions(review_comments, self, &diff, &input, summary_only);
        }

        if let Some(advice) = &self.tidy_advice {
            if let Some(patched) = &advice.patched {
                let patched = String::from_utf8(patched.to_vec())
                    .map_err(|e| FileObjError::FromUtf8Error(file_name.clone(), e))?;
                let (diff, input) = make_patch(patched.as_str(), &original_content);
                advice.get_suggestions(review_comments, self, &diff, &input, summary_only);
            }

            if summary_only {
                return Ok(());
            }

            // now check for clang-tidy warnings with no fixes applied
            let file_ext = self
                .name
                .extension()
                .unwrap_or_default()
                .to_str()
                .unwrap_or_default();
            // Count of clang-tidy diagnostics that had no fixes applied
            let mut total = 0;
            for note in &advice.notes {
                if note.fixed_lines.is_empty() && self.is_line_in_diff(&note.line) {
                    // notification had no suggestion applied in `patched`
                    let mut suggestion = format!(
                        "### clang-tidy diagnostic\n**{file_name}:{}:{}** {}: [{}]\n\n> {}\n",
                        &note.line,
                        &note.cols,
                        &note.severity,
                        note.diagnostic_link(),
                        &note.rationale
                    );
                    if !note.suggestion.is_empty() {
                        suggestion.push_str(
                            format!("\n```{file_ext}\n{}\n```\n", &note.suggestion.join("\n"))
                                .as_str(),
                        );
                    }
                    total += 1;
                    let mut is_merged = false;
                    for s in &mut review_comments.comments {
                        if s.path == file_name
                            && s.line_end >= note.line
                            && s.line_start <= note.line
                        {
                            s.suggestion.push_str(suggestion.as_str());
                            is_merged = true;
                            break;
                        }
                    }
                    if !is_merged {
                        review_comments.comments.push(Suggestion {
                            line_start: note.line,
                            line_end: note.line,
                            suggestion,
                            path: file_name.to_owned(),
                        });
                    }
                }
            }
            review_comments.tool_total[1] =
                Some(review_comments.tool_total[1].unwrap_or_default() + total);
        }
        Ok(())
    }
}

/// Gets the line number for a given `offset` (of bytes) from the given
/// buffer `contents`.
///
/// The `offset` given to this function is expected to originate from
/// diagnostic information provided by clang-format. Any `offset` out of
/// bounds is clamped to the given `contents` buffer's length.
pub fn get_line_count_from_offset(contents: &[u8], offset: u32) -> u32 {
    let offset = (offset as usize).min(contents.len());
    let lines = contents[0..offset].split(|byte| byte == &b'\n');
    lines.count() as u32
}

#[cfg(test)]
mod test {
    use std::{fs, path::PathBuf};

    use super::{FileObj, get_line_count_from_offset};
    use crate::cli::LinesChangedOnly;

    // *********************** tests for translating byte offset into line/column

    #[test]
    fn translate_byte_offset() {
        let contents = fs::read(PathBuf::from("tests/demo/demo.cpp")).unwrap();
        let lines = get_line_count_from_offset(&contents, 144);
        assert_eq!(lines, 13);
    }

    #[test]
    fn get_line_count_edge_cases() {
        // Empty content
        assert_eq!(get_line_count_from_offset(&[], 0), 1);

        // No newlines
        assert_eq!(get_line_count_from_offset(b"abc", 3), 1);

        // Consecutive newlines
        assert_eq!(get_line_count_from_offset(b"a\n\nb", 3), 3);

        // Offset beyond content length
        assert_eq!(get_line_count_from_offset(b"a\nb\n", 10), 3);
    }
    // *********************** tests for FileObj::get_ranges()

    #[test]
    fn get_ranges_none() {
        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
        assert!(ranges.is_empty());
    }

    #[test]
    fn get_ranges_diff() {
        let diff_chunks = vec![1..=10];
        let added_lines = vec![4, 5, 9];
        let file_obj = FileObj::from(
            PathBuf::from("tests/demo/demo.cpp"),
            added_lines,
            diff_chunks.clone(),
        );
        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
        assert_eq!(ranges, diff_chunks);
    }

    #[test]
    fn get_ranges_added() {
        let diff_chunks = vec![1..=10];
        let added_lines = vec![4, 5, 9];
        let file_obj = FileObj::from(
            PathBuf::from("tests/demo/demo.cpp"),
            added_lines,
            diff_chunks,
        );
        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
        assert_eq!(ranges, vec![4..=5, 9..=9]);
    }

    #[test]
    fn line_not_in_diff() {
        let file_obj = FileObj::new(PathBuf::from("tests/demo/demo.cpp"));
        assert!(!file_obj.is_line_in_diff(&42));
    }
}
