use std::path::Path;

use bstr::{BStr, ByteSlice};
use gix_glob::pattern::Case;
use gix_object::FindExt;

use crate::{
    stack::state::{Ignore, IgnoreMatchGroup},
    PathIdMapping,
};

/// Decide where to read `.gitignore` files from.
#[derive(Default, Debug, Clone, Copy)]
pub enum Source {
    /// Retrieve ignore files from id mappings, see
    /// [State::id_mappings_from_index()][crate::stack::State::id_mappings_from_index()].
    ///
    /// These mappings are typically produced from an index.
    /// If a tree should be the source, build an attribute list from a tree instead, or convert a tree to an index.
    ///
    /// Use this when no worktree checkout is available, like in bare repositories or when accessing blobs from other parts
    /// of the history which aren't checked out.
    IdMapping,
    /// Read from the worktree and if not present, read them from the id mappings *if* these don't have the skip-worktree bit set.
    #[default]
    WorktreeThenIdMappingIfNotSkipped,
}

impl Source {
    /// Returns non-worktree variants of `self` if `is_bare` is true.
    pub fn adjust_for_bare(self, is_bare: bool) -> Self {
        if is_bare {
            Source::IdMapping
        } else {
            self
        }
    }
}

/// Various aggregate numbers related [`Ignore`].
#[derive(Default, Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Statistics {
    /// Amount of patterns buffers read from the index.
    pub patterns_buffers: usize,
    /// Amount of pattern files read from disk.
    pub pattern_files: usize,
    /// Amount of pattern files we tried to find on disk.
    pub tried_pattern_files: usize,
}

impl Ignore {
    /// Configure gitignore file matching by providing the immutable groups being `overrides` and `globals`, while letting the directory
    /// stack be dynamic.
    ///
    /// The `exclude_file_name_for_directories` is an optional override for the filename to use when checking per-directory
    /// ignore files within the repository, defaults to`.gitignore`.
    pub fn new(
        overrides: IgnoreMatchGroup,
        globals: IgnoreMatchGroup,
        exclude_file_name_for_directories: Option<&BStr>,
        source: Source,
    ) -> Self {
        Ignore {
            overrides,
            globals,
            stack: Default::default(),
            matched_directory_patterns_stack: Vec::with_capacity(6),
            exclude_file_name_for_directories: exclude_file_name_for_directories
                .map_or_else(|| ".gitignore".into(), ToOwned::to_owned),
            source,
        }
    }
}

impl Ignore {
    pub(crate) fn pop_directory(&mut self) {
        self.matched_directory_patterns_stack.pop().expect("something to pop");
        self.stack.patterns.pop().expect("something to pop");
    }
    /// The match groups from lowest priority to highest.
    pub(crate) fn match_groups(&self) -> [&IgnoreMatchGroup; 3] {
        [&self.globals, &self.stack, &self.overrides]
    }

    pub(crate) fn matching_exclude_pattern(
        &self,
        relative_path: &BStr,
        is_dir: Option<bool>,
        case: Case,
    ) -> Option<gix_ignore::search::Match<'_>> {
        let groups = self.match_groups();
        let mut dir_match = None;
        if let Some((source, mapping)) = self
            .matched_directory_patterns_stack
            .iter()
            .rev()
            .filter_map(|v| *v)
            .map(|(gidx, plidx, pidx)| {
                let list = &groups[gidx].patterns[plidx];
                (list.source.as_deref(), &list.patterns[pidx])
            })
            .next()
        {
            let match_ = gix_ignore::search::Match {
                pattern: &mapping.pattern,
                sequence_number: mapping.sequence_number,
                kind: mapping.value,
                source,
            };
            if mapping.pattern.is_negative() {
                dir_match = Some(match_);
            } else {
                // Note that returning here is wrong if this pattern _was_ preceded by a negative pattern that
                // didn't match the directory, but would match now.
                // Git does it similarly so we do too even though it's incorrect.
                // To fix this, one would probably keep track of whether there was a preceding negative pattern, and
                // if so we check the path in full and only use the dir match if there was no match, similar to the negative
                // case above whose fix fortunately won't change the overall result.
                return match_.into();
            }
        }
        groups
            .iter()
            .rev()
            .find_map(|group| group.pattern_matching_relative_path(relative_path, is_dir, case))
            .or(dir_match)
    }

    /// Like `matching_exclude_pattern()` but without checking if the current directory is excluded.
    /// It returns a triple-index into our data structure from which a match can be reconstructed.
    pub(crate) fn matching_exclude_pattern_no_dir(
        &self,
        relative_path: &BStr,
        is_dir: Option<bool>,
        case: Case,
    ) -> Option<(usize, usize, usize)> {
        let groups = self.match_groups();
        groups.iter().enumerate().rev().find_map(|(gidx, group)| {
            let basename_pos = relative_path.rfind(b"/").map(|p| p + 1);
            group
                .patterns
                .iter()
                .enumerate()
                .rev()
                .find_map(|(plidx, pl)| {
                    gix_ignore::search::pattern_idx_matching_relative_path(
                        pl,
                        relative_path,
                        basename_pos,
                        is_dir,
                        case,
                    )
                    .map(|idx| (plidx, idx))
                })
                .map(|(plidx, pidx)| (gidx, plidx, pidx))
        })
    }

    #[allow(clippy::too_many_arguments)]
    pub(crate) fn push_directory(
        &mut self,
        root: &Path,
        dir: &Path,
        rela_dir: &BStr,
        buf: &mut Vec<u8>,
        id_mappings: &[PathIdMapping],
        objects: &dyn gix_object::Find,
        case: Case,
        stats: &mut Statistics,
    ) -> std::io::Result<()> {
        self.matched_directory_patterns_stack
            .push(self.matching_exclude_pattern_no_dir(rela_dir, Some(true), case));

        let ignore_path_relative = gix_path::join_bstr_unix_pathsep(rela_dir, ".gitignore");
        let ignore_file_in_index = id_mappings.binary_search_by(|t| t.0.as_bstr().cmp(ignore_path_relative.as_ref()));
        match self.source {
            Source::IdMapping => {
                match ignore_file_in_index {
                    Ok(idx) => {
                        let ignore_blob = objects
                            .find_blob(&id_mappings[idx].1, buf)
                            .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
                        let ignore_path = gix_path::from_bstring(ignore_path_relative.into_owned());
                        self.stack
                            .add_patterns_buffer(ignore_blob.data, ignore_path, Some(Path::new("")));
                        stats.patterns_buffers += 1;
                    }
                    Err(_) => {
                        // Need one stack level per component so push and pop matches.
                        self.stack.patterns.push(Default::default())
                    }
                }
            }
            Source::WorktreeThenIdMappingIfNotSkipped => {
                let follow_symlinks = ignore_file_in_index.is_err();
                let added = gix_glob::search::add_patterns_file(
                    &mut self.stack.patterns,
                    dir.join(".gitignore"),
                    follow_symlinks,
                    Some(root),
                    buf,
                )?;
                stats.pattern_files += usize::from(added);
                stats.tried_pattern_files += 1;
                if !added {
                    match ignore_file_in_index {
                        Ok(idx) => {
                            let ignore_blob = objects
                                .find_blob(&id_mappings[idx].1, buf)
                                .map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))?;
                            let ignore_path = gix_path::from_bstring(ignore_path_relative.into_owned());
                            self.stack
                                .add_patterns_buffer(ignore_blob.data, ignore_path, Some(Path::new("")));
                            stats.patterns_buffers += 1;
                        }
                        Err(_) => {
                            // Need one stack level per component so push and pop matches.
                            self.stack.patterns.push(Default::default())
                        }
                    }
                }
            }
        }
        Ok(())
    }
}
