use std::{
    borrow::Cow,
    path::{Path, PathBuf},
    sync::atomic::Ordering,
};

use bstr::{BStr, BString, ByteSlice};

use crate::{
    Entry, EntryRef, entry,
    entry::{PathspecMatch, Status},
    walk,
    walk::{
        Action, CollapsedEntriesEmissionMode, Context, Delegate,
        EmissionMode::CollapseDirectory,
        Error, ForDeletionMode, Options, Outcome, classify,
        function::{can_recurse, emit_entry},
    },
};

/// ### Deviation
///
/// Git mostly silently ignores IO errors and stops iterating seemingly quietly, while we error loudly.
#[expect(clippy::too_many_arguments)]
pub(super) fn recursive(
    may_collapse: bool,
    current: &mut PathBuf,
    current_bstr: &mut BString,
    current_info: classify::Outcome,
    ctx: &mut Context<'_>,
    opts: Options<'_>,
    delegate: &mut dyn Delegate,
    out: &mut Outcome,
    state: &mut State,
) -> Result<(Action, bool), Error> {
    if ctx.should_interrupt.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
        return Err(Error::Interrupted);
    }
    out.read_dir_calls += 1;
    let entries = gix_fs::read_dir(current, opts.precompose_unicode).map_err(|err| Error::ReadDir {
        path: current.to_owned(),
        source: err,
    })?;

    let mut num_entries = 0;
    let mark = state.mark(may_collapse);
    let mut prevent_collapse = false;
    for entry in entries {
        let entry = entry.map_err(|err| Error::DirEntry {
            parent_directory: current.to_owned(),
            source: err,
        })?;
        // Important to count right away, otherwise the directory could be seen as empty even though it's not.
        // That is, this should be independent of the kind.
        num_entries += 1;

        let prev_len = current_bstr.len();
        if prev_len != 0 {
            current_bstr.push(b'/');
        }
        let file_name = entry.file_name();
        current_bstr.extend_from_slice(
            gix_path::try_os_str_into_bstr(Cow::Borrowed(file_name.as_ref()))
                .expect("no illformed UTF-8")
                .as_ref(),
        );
        current.push(file_name);

        let mut info = classify::path(
            current,
            current_bstr,
            if prev_len == 0 { 0 } else { prev_len + 1 },
            None,
            || entry.file_type().ok().map(Into::into),
            opts,
            ctx,
        )?;

        if can_recurse(
            current_bstr.as_bstr(),
            info,
            opts.for_deletion,
            false, /* is root */
            delegate,
        ) {
            let subdir_may_collapse = state.may_collapse(current);
            let (action, subdir_prevent_collapse) = recursive(
                subdir_may_collapse,
                current,
                current_bstr,
                info,
                ctx,
                opts,
                delegate,
                out,
                state,
            )?;
            prevent_collapse |= subdir_prevent_collapse;
            if action.is_break() {
                return Ok((action, prevent_collapse));
            }
        } else {
            if opts.for_deletion == Some(ForDeletionMode::IgnoredDirectoriesCanHideNestedRepositories)
                && info.disk_kind == Some(entry::Kind::Directory)
                && matches!(info.status, Status::Ignored(_))
            {
                info.disk_kind = classify::maybe_upgrade_to_repository(
                    info.disk_kind,
                    true,
                    false,
                    current,
                    ctx.current_dir,
                    ctx.git_dir_realpath,
                );
            }
            if !state.held_for_directory_collapse(current_bstr.as_bstr(), info, &opts) {
                let action = emit_entry(Cow::Borrowed(current_bstr.as_bstr()), info, None, opts, out, delegate);
                if action.is_break() {
                    return Ok((action, prevent_collapse));
                }
            }
        }
        current_bstr.truncate(prev_len);
        current.pop();
    }

    let res = mark.reduce_held_entries(
        num_entries,
        state,
        &mut prevent_collapse,
        current,
        current_bstr.as_bstr(),
        current_info,
        opts,
        out,
        ctx,
        delegate,
    );
    Ok((res, prevent_collapse))
}

pub(super) struct State {
    /// The entries to hold back until it's clear what to do with them.
    pub on_hold: Vec<Entry>,
    /// The path the user is currently in, as seen from the workdir root.
    worktree_relative_current_dir: Option<PathBuf>,
}

impl State {
    /// Hold the entry with the given `status` if it's a candidate for collapsing the containing directory.
    fn held_for_directory_collapse(&mut self, rela_path: &BStr, info: classify::Outcome, opts: &Options<'_>) -> bool {
        if opts.should_hold(info.status) {
            self.on_hold
                .push(EntryRef::from_outcome(Cow::Borrowed(rela_path), info).into_owned());
            true
        } else {
            false
        }
    }

    /// Keep track of state we need to later resolve the state.
    /// Top-level directories are special, as they don't fold.
    fn mark(&self, may_collapse: bool) -> Mark {
        Mark {
            start_index: self.on_hold.len(),
            may_collapse,
        }
    }

    pub(super) fn new(worktree_root: &Path, current_dir: &Path, is_delete_mode: bool) -> Self {
        let worktree_relative_current_dir = if is_delete_mode {
            gix_path::realpath_opts(worktree_root, current_dir, gix_path::realpath::MAX_SYMLINKS)
                .ok()
                .and_then(|real_worktree_root| current_dir.strip_prefix(real_worktree_root).ok().map(ToOwned::to_owned))
                .map(|relative_cwd| worktree_root.join(relative_cwd))
        } else {
            None
        };
        Self {
            on_hold: Vec::new(),
            worktree_relative_current_dir,
        }
    }

    /// Returns `true` if the worktree-relative `directory_to_traverse` is not the current working directory.
    /// This is only the case when
    pub(super) fn may_collapse(&self, directory_to_traverse: &Path) -> bool {
        self.worktree_relative_current_dir
            .as_ref()
            .is_none_or(|cwd| cwd != directory_to_traverse)
    }

    pub(super) fn emit_remaining(
        &mut self,
        may_collapse: bool,
        opts: Options<'_>,
        out: &mut walk::Outcome,
        delegate: &mut dyn walk::Delegate,
    ) {
        if self.on_hold.is_empty() {
            return;
        }

        _ = Mark {
            start_index: 0,
            may_collapse,
        }
        .emit_all_held(self, opts, out, delegate);
    }
}

struct Mark {
    start_index: usize,
    may_collapse: bool,
}

impl Mark {
    #[expect(clippy::too_many_arguments)]
    fn reduce_held_entries(
        mut self,
        num_entries: usize,
        state: &mut State,
        prevent_collapse: &mut bool,
        dir_path: &Path,
        dir_rela_path: &BStr,
        dir_info: classify::Outcome,
        opts: Options<'_>,
        out: &mut walk::Outcome,
        ctx: &mut Context<'_>,
        delegate: &mut dyn walk::Delegate,
    ) -> walk::Action {
        if num_entries == 0 {
            let empty_info = classify::Outcome {
                property: {
                    assert_ne!(
                        dir_info.disk_kind,
                        Some(entry::Kind::Repository),
                        "BUG: it shouldn't be possible to classify an empty dir as repository"
                    );
                    if !state.may_collapse(dir_path) {
                        *prevent_collapse = true;
                        entry::Property::EmptyDirectoryAndCWD.into()
                    } else if dir_info.property.is_none() {
                        entry::Property::EmptyDirectory.into()
                    } else {
                        dir_info.property
                    }
                },
                pathspec_match: ctx
                    .pathspec
                    .pattern_matching_relative_path(dir_rela_path, Some(true), ctx.pathspec_attributes)
                    .map(|m| m.kind.into()),
                ..dir_info
            };
            if opts.should_hold(empty_info.status) {
                state
                    .on_hold
                    .push(EntryRef::from_outcome(Cow::Borrowed(dir_rela_path), empty_info).into_owned());
                std::ops::ControlFlow::Continue(())
            } else {
                emit_entry(Cow::Borrowed(dir_rela_path), empty_info, None, opts, out, delegate)
            }
        } else if *prevent_collapse {
            self.emit_all_held(state, opts, out, delegate)
        } else if let Some(action) = self.try_collapse(dir_rela_path, dir_info, state, out, opts, ctx, delegate) {
            action
        } else {
            *prevent_collapse = true;
            self.emit_all_held(state, opts, out, delegate)
        }
    }

    fn emit_all_held(
        &mut self,
        state: &mut State,
        opts: Options<'_>,
        out: &mut walk::Outcome,
        delegate: &mut dyn walk::Delegate,
    ) -> Action {
        for entry in state.on_hold.drain(self.start_index..) {
            let info = classify::Outcome::from(&entry);
            let action = emit_entry(Cow::Owned(entry.rela_path), info, None, opts, out, delegate);
            if action.is_break() {
                return action;
            }
        }
        std::ops::ControlFlow::Continue(())
    }

    #[expect(clippy::too_many_arguments)]
    fn try_collapse(
        &self,
        dir_rela_path: &BStr,
        dir_info: classify::Outcome,
        state: &mut State,
        out: &mut walk::Outcome,
        opts: Options<'_>,
        ctx: &mut Context<'_>,
        delegate: &mut dyn walk::Delegate,
    ) -> Option<Action> {
        if !self.may_collapse {
            return None;
        }
        let (mut expendable, mut precious, mut untracked, mut entries, mut matching_entries) = (0, 0, 0, 0, 0);
        for (kind, status, pathspec_match) in state.on_hold[self.start_index..]
            .iter()
            .map(|e| (e.disk_kind, e.status, e.pathspec_match))
        {
            entries += 1;
            if kind == Some(entry::Kind::Repository) {
                return None;
            }
            if pathspec_match.is_some_and(|m| matches!(m, PathspecMatch::Verbatim | PathspecMatch::Excluded)) {
                return None;
            }
            matching_entries += usize::from(pathspec_match.is_some_and(|m| !m.should_ignore()));
            match status {
                Status::Pruned => {
                    unreachable!("BUG: pruned aren't ever held, check `should_hold()`")
                }
                Status::Tracked => { /* causes the folder not to be collapsed */ }
                Status::Ignored(gix_ignore::Kind::Expendable) => expendable += 1,
                Status::Ignored(gix_ignore::Kind::Precious) => precious += 1,
                Status::Untracked => untracked += 1,
            }
        }

        if matching_entries != 0 && matching_entries != entries {
            return None;
        }

        let dir_status = if opts.emit_untracked == CollapseDirectory
            && untracked != 0
            && untracked + expendable + precious == entries
            && (opts.for_deletion.is_none()
                || (precious == 0 && expendable == 0)
                || (precious == 0 && opts.emit_ignored.is_some()))
        {
            entry::Status::Untracked
        } else if opts.emit_ignored == Some(CollapseDirectory) {
            if expendable != 0 && expendable == entries {
                entry::Status::Ignored(gix_ignore::Kind::Expendable)
            } else if precious != 0 && precious == entries {
                entry::Status::Ignored(gix_ignore::Kind::Precious)
            } else {
                return None;
            }
        } else {
            return None;
        };

        if !matches!(dir_status, entry::Status::Untracked | entry::Status::Ignored(_)) {
            return None;
        }

        if !ctx.pathspec.directory_matches_prefix(dir_rela_path, false) {
            return None;
        }

        // Pathspecs affect the collapse of the next level, hence find the highest-value one.
        let dir_pathspec_match = state.on_hold[self.start_index..]
            .iter()
            .filter_map(|e| e.pathspec_match)
            .max()
            .or_else(|| {
                // Only take directory matches as value if they are above the 'guessed' ones.
                // Otherwise we end up with seemingly matched entries in the parent directory which
                // affects proper folding.
                filter_dir_pathspec(dir_info.pathspec_match)
            });

        // If every collapsed entry is itself an empty directory, the collapsed directory has no files
        // to track. Keep the `EmptyDirectory` property so callers that skip empty directories by
        // default (such as `gix status`) behave like Git, which treats a tree of only empty
        // directories as clean. See https://github.com/GitoxideLabs/gitoxide/issues/2490.
        //
        // Restricted to untracked collapses in non-deletion walks: deletion has its own handling of
        // empty directories, and an explicitly requested *ignored* directory must stay observable even
        // when empty (it would otherwise be filtered out where empty directories aren't emitted).
        let collapsed_property = (opts.for_deletion.is_none()
            && matches!(dir_status, entry::Status::Untracked)
            && state.on_hold[self.start_index..]
                .iter()
                .all(|entry| entry.property == Some(entry::Property::EmptyDirectory)))
        .then_some(entry::Property::EmptyDirectory);

        let mut removed_without_emitting = 0;
        let mut action = std::ops::ControlFlow::Continue(());
        for entry in state.on_hold.drain(self.start_index..) {
            if action.is_break() {
                removed_without_emitting += 1;
                continue;
            }
            match opts.emit_collapsed {
                Some(mode) => {
                    if mode == CollapsedEntriesEmissionMode::All || entry.status != dir_status {
                        let info = classify::Outcome::from(&entry);
                        action = emit_entry(Cow::Owned(entry.rela_path), info, Some(dir_status), opts, out, delegate);
                    } else {
                        removed_without_emitting += 1;
                    }
                }
                None => removed_without_emitting += 1,
            }
        }
        out.seen_entries += removed_without_emitting as u32;

        state.on_hold.push(
            EntryRef::from_outcome(
                Cow::Borrowed(dir_rela_path),
                classify::Outcome {
                    status: dir_status,
                    property: dir_info.property.or(collapsed_property),
                    pathspec_match: dir_pathspec_match,
                    ..dir_info
                },
            )
            .into_owned(),
        );
        Some(action)
    }
}

fn filter_dir_pathspec(current: Option<PathspecMatch>) -> Option<PathspecMatch> {
    current.filter(|m| {
        matches!(
            m,
            PathspecMatch::Always | PathspecMatch::WildcardMatch | PathspecMatch::Verbatim
        )
    })
}

impl Options<'_> {
    fn should_hold(&self, status: entry::Status) -> bool {
        if status.is_pruned() {
            return false;
        }
        self.emit_ignored == Some(CollapseDirectory) || self.emit_untracked == CollapseDirectory
    }
}
