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

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

use crate::{
    Entry, EntryRef, entry,
    entry::PathspecMatch,
    walk::{Context, Error, ForDeletionMode, Options},
};

/// Classify the `worktree_relative_root` path and return the first `PathKind` that indicates that
/// it isn't a directory, leaving `buf` with the path matching the returned `PathKind`,
/// which is at most equal to `worktree_relative_root`.
pub fn root(
    worktree_root: &Path,
    buf: &mut BString,
    worktree_relative_root: &Path,
    options: Options<'_>,
    ctx: &mut Context<'_>,
) -> Result<(Outcome, bool), Error> {
    buf.clear();
    let mut last_length = None;
    let mut path_buf = worktree_root.to_owned();
    // These initial values kick in if worktree_relative_root.is_empty();
    let file_kind = path_buf.symlink_metadata().ok().map(|m| m.file_type().into());
    let mut out = path(&mut path_buf, buf, 0, file_kind, || None, options, ctx)?;
    let worktree_root_is_repository = out
        .disk_kind
        .is_some_and(|kind| matches!(kind, entry::Kind::Repository));

    for component in worktree_relative_root.components() {
        if last_length.is_some() {
            buf.push(b'/');
        }
        path_buf.push(component);
        buf.extend_from_slice(gix_path::os_str_into_bstr(component.as_os_str()).expect("no illformed UTF8"));
        let file_kind = path_buf.symlink_metadata().ok().map(|m| m.file_type().into());

        out = path(
            &mut path_buf,
            buf,
            last_length.map(|l| l + 1 /* slash */).unwrap_or_default(),
            file_kind,
            || None,
            options,
            ctx,
        )?;
        if !out.status.can_recurse(
            out.disk_kind,
            out.pathspec_match,
            options.for_deletion,
            worktree_root_is_repository,
        ) {
            break;
        }
        last_length = Some(buf.len());
    }
    Ok((out, worktree_root_is_repository))
}
/// The product of [`path()`] calls.
#[derive(Debug, Copy, Clone)]
pub struct Outcome {
    /// The computed status of an entry. It can be seen as aggregate of things we know about an entry.
    pub status: entry::Status,
    /// An additional property.
    pub property: Option<entry::Property>,
    /// What the entry is on disk, or `None` if we aborted the classification early or an IO-error occurred
    /// when querying the disk.
    ///
    /// Note that the index is used to avoid disk access provided its entries are marked uptodate
    /// (possibly by a prior call to update the status).
    pub disk_kind: Option<entry::Kind>,
    /// What the entry looks like in the index, or `None` if we aborted early.
    pub index_kind: Option<entry::Kind>,
    /// If a pathspec matched, this is how it matched. Maybe `None` if computation didn't see the need to evaluate it.
    pub pathspec_match: Option<PathspecMatch>,
}

impl Outcome {
    fn with_status(mut self, status: entry::Status) -> Self {
        self.status = status;
        self
    }

    fn with_kind(mut self, disk_kind: Option<entry::Kind>, index_kind: Option<entry::Kind>) -> Self {
        self.disk_kind = disk_kind;
        self.index_kind = index_kind;
        self
    }
}

impl From<&Entry> for Outcome {
    fn from(e: &Entry) -> Self {
        Outcome {
            status: e.status,
            property: e.property,
            disk_kind: e.disk_kind,
            index_kind: e.index_kind,
            pathspec_match: e.pathspec_match,
        }
    }
}

impl<'a> EntryRef<'a> {
    pub(super) fn from_outcome(rela_path: Cow<'a, BStr>, info: crate::walk::classify::Outcome) -> Self {
        EntryRef {
            rela_path,
            property: info.property,
            status: info.status,
            disk_kind: info.disk_kind,
            index_kind: info.index_kind,
            pathspec_match: info.pathspec_match,
        }
    }
}

/// Figure out what to do with `rela_path`, provided as worktree-relative path, with `disk_file_type` if it is known already
/// as it helps to match pathspecs correctly, which can be different for directories.
/// `path` is a disk-accessible variant of `rela_path` which is within the `worktree_root`, and will be modified temporarily but remain unchanged.
///
/// Note that `rela_path` is used as buffer for convenience, but will be left as is when this function returns.
/// `filename_start_idx` is the index at which the filename begins, i.e. `a/b` has `2` as index.
/// It may resemble a directory on the way to a leaf (like a file)
///
/// Returns `(status, file_kind, pathspec_matches_how)` to identify the `status` on disk, along with a classification `file_kind`,
/// and if `file_kind` is not a directory, the way the pathspec matched with `pathspec_matches_how`.
///
/// Note that non-files are pruned by default.
pub fn path(
    path: &mut PathBuf,
    rela_path: &mut BString,
    filename_start_idx: usize,
    disk_kind: Option<entry::Kind>,
    on_demand_disk_kind: impl FnOnce() -> Option<entry::Kind>,
    Options {
        ignore_case,
        recurse_repositories,
        emit_ignored,
        for_deletion,
        classify_untracked_bare_repositories,
        symlinks_to_directories_are_ignored_like_directories,
        worktree_relative_worktree_dirs,
        ..
    }: Options<'_>,
    ctx: &mut Context<'_>,
) -> Result<Outcome, Error> {
    let mut out = Outcome {
        status: entry::Status::Pruned,
        property: None,
        disk_kind,
        index_kind: None,
        pathspec_match: None,
    };
    if is_eq(rela_path[filename_start_idx..].as_bstr(), ".git", ignore_case) {
        out.pathspec_match = ctx
            .pathspec
            .pattern_matching_relative_path(
                rela_path.as_bstr(),
                disk_kind.map(|ft| ft.is_dir()),
                ctx.pathspec_attributes,
            )
            .map(Into::into);
        if for_deletion.is_some() {
            if let Some(excluded) = ctx
                .excludes
                .as_mut()
                .map_or(Ok(None), |stack| {
                    stack
                        .at_entry(
                            rela_path.as_bstr(),
                            disk_kind.map(|ft| is_dir_to_mode(ft.is_dir())),
                            ctx.objects,
                        )
                        .map(|platform| platform.excluded_kind())
                })
                .map_err(Error::ExcludesAccess)?
                .filter(|_| filename_start_idx > 0)
            {
                out.status = entry::Status::Ignored(excluded);
            }
        }
        out.property = entry::Property::DotGit.into();
        return Ok(out);
    }
    let pathspec_could_match = ctx
        .pathspec
        .can_match_relative_path(rela_path.as_bstr(), disk_kind.map(|ft| ft.is_dir()));
    if !pathspec_could_match {
        return Ok(out.with_status(entry::Status::Pruned));
    }

    let (uptodate_index_kind, index_kind, property) = resolve_file_type_with_index(
        rela_path,
        ctx.index,
        ctx.ignore_case_index_lookup.filter(|_| ignore_case),
    );
    let mut kind = uptodate_index_kind.or(disk_kind).or_else(on_demand_disk_kind);

    // We always check the pathspec to have the value filled in reliably.
    out.pathspec_match = ctx
        .pathspec
        .pattern_matching_relative_path(rela_path.as_bstr(), kind.map(|ft| ft.is_dir()), ctx.pathspec_attributes)
        .map(Into::into);

    if worktree_relative_worktree_dirs.is_some_and(|worktrees| worktrees.contains(&*rela_path)) {
        return Ok(out
            .with_kind(Some(entry::Kind::Repository), None)
            .with_status(entry::Status::Tracked));
    }

    let maybe_status = if property.is_none() {
        (index_kind.map(|k| k.is_dir()) == kind.map(|k| k.is_dir())).then_some(entry::Status::Tracked)
    } else {
        out.property = property;
        Some(entry::Status::Pruned)
    };

    let is_dir = if symlinks_to_directories_are_ignored_like_directories
        && ctx.excludes.is_some()
        && kind == Some(entry::Kind::Symlink)
    {
        path.metadata().ok().map(|md| is_dir_to_mode(md.is_dir()))
    } else {
        kind.map(|ft| is_dir_to_mode(ft.is_dir()))
    };

    let mut maybe_upgrade_to_repository = |current_kind, find_harder: bool| {
        maybe_upgrade_to_repository(
            current_kind,
            find_harder,
            recurse_repositories,
            path,
            ctx.current_dir,
            ctx.git_dir_realpath,
        )
    };
    if let Some(status) = maybe_status {
        if kind == Some(entry::Kind::Directory) && index_kind == Some(entry::Kind::Repository) {
            kind = maybe_upgrade_to_repository(kind, false);
        }
        return Ok(out.with_status(status).with_kind(kind, index_kind));
    }

    debug_assert!(maybe_status.is_none(), "It only communicates a single state right now");
    if let Some(excluded) = ctx
        .excludes
        .as_mut()
        .filter(|_| !rela_path.is_empty())
        .map_or(Ok(None), |stack| {
            stack
                .at_entry(rela_path.as_bstr(), is_dir, ctx.objects)
                .map(|platform| platform.excluded_kind())
        })
        .map_err(Error::ExcludesAccess)?
    {
        if emit_ignored.is_some() {
            if matches!(
                for_deletion,
                Some(
                    ForDeletionMode::FindNonBareRepositoriesInIgnoredDirectories
                        | ForDeletionMode::FindRepositoriesInIgnoredDirectories
                )
            ) {
                kind = maybe_upgrade_to_repository(
                    kind,
                    matches!(
                        for_deletion,
                        Some(ForDeletionMode::FindRepositoriesInIgnoredDirectories)
                    ),
                );
            }
            if kind.is_some_and(|d| d.is_recursable_dir())
                && (out.pathspec_match.is_none()
                    || worktree_relative_worktree_dirs.is_some_and(|worktrees| {
                        for_deletion.is_some()
                            && worktrees
                                .iter()
                                .any(|dir| dir.starts_with_str(&*rela_path) && dir.get(rela_path.len()) == Some(&b'/'))
                    }))
            {
                // We have patterns that didn't match at all, *yet*, or there are contained worktrees.
                // We want to look inside.
                out.pathspec_match = Some(PathspecMatch::Prefix);
            }
        }
        return Ok(out
            .with_status(entry::Status::Ignored(excluded))
            .with_kind(kind, index_kind));
    }

    debug_assert!(maybe_status.is_none());
    let mut status = entry::Status::Untracked;

    if kind.is_some_and(|ft| ft.is_dir()) {
        kind = maybe_upgrade_to_repository(kind, classify_untracked_bare_repositories);
    } else if out.pathspec_match.is_none() {
        status = entry::Status::Pruned;
    }
    Ok(out.with_status(status).with_kind(kind, index_kind))
}

pub fn maybe_upgrade_to_repository(
    current_kind: Option<entry::Kind>,
    find_harder: bool,
    recurse_repositories: bool,
    path: &mut PathBuf,
    current_dir: &Path,
    git_dir_realpath: &Path,
) -> Option<entry::Kind> {
    if recurse_repositories {
        return current_kind;
    }
    if find_harder {
        let mut is_nested_repo = gix_discover::is_git(path).is_ok();
        if is_nested_repo {
            let git_dir_is_our_own = gix_path::realpath_opts(path, current_dir, gix_path::realpath::MAX_SYMLINKS)
                .ok()
                .is_some_and(|realpath_candidate| realpath_candidate == git_dir_realpath);
            is_nested_repo = !git_dir_is_our_own;
        }
        if is_nested_repo {
            return Some(entry::Kind::Repository);
        }
    }
    path.push(gix_discover::DOT_GIT_DIR);
    let mut is_nested_nonbare_repo = gix_discover::is_git(path).is_ok();
    if is_nested_nonbare_repo {
        let git_dir_is_our_own = gix_path::realpath_opts(path, current_dir, gix_path::realpath::MAX_SYMLINKS)
            .ok()
            .is_some_and(|realpath_candidate| realpath_candidate == git_dir_realpath);
        is_nested_nonbare_repo = !git_dir_is_our_own;
    }
    path.pop();

    if is_nested_nonbare_repo {
        Some(entry::Kind::Repository)
    } else {
        current_kind
    }
}

/// Note that `rela_path` is used as buffer for convenience, but will be left as is when this function returns.
/// Also note `maybe_file_type` will be `None` for entries that aren't up-to-date and files, for directories at least one entry must be uptodate.
/// Returns `(maybe_file_type, Option<index_file_type>, flags)`, with the last option being a flag set only for sparse directories in the index.
/// `index_file_type` is the type of `rela_path` as available in the index.
///
/// ### Shortcoming
///
/// In case-insensitive mode, if there is an entry `d` and a `D/a` both in the index, we will always find the file `d` first, and always consider
/// the entry as not uptodate, while classifying it as file (the first one we found). As quite a huge exception, this isn't properly represented
/// in the data model, and we emit a trace to make it more obvious when it happens, in case this leads to less expected results.
fn resolve_file_type_with_index(
    rela_path: &mut BString,
    index: &gix_index::State,
    ignore_case: Option<&gix_index::AccelerateLookup<'_>>,
) -> (Option<entry::Kind>, Option<entry::Kind>, Option<entry::Property>) {
    // TODO: either get this to work for icase as well, or remove the need for it. Logic is different in both branches.
    let mut special_property = None;

    fn entry_to_kinds(entry: &gix_index::Entry) -> (Option<entry::Kind>, Option<entry::Kind>) {
        let kind = if entry.mode.is_submodule() {
            entry::Kind::Repository.into()
        } else if entry.mode.contains(gix_index::entry::Mode::FILE) {
            entry::Kind::File.into()
        } else if entry.mode.contains(gix_index::entry::Mode::SYMLINK) {
            entry::Kind::Symlink.into()
        } else {
            None
        };
        (
            kind.filter(|_| {
                entry
                    .flags
                    .intersects(gix_index::entry::Flags::UPTODATE | gix_index::entry::Flags::ASSUME_VALID)
            }),
            kind,
        )
    }

    fn icase_directory_to_kinds(dir: Option<&gix_index::Entry>) -> (Option<entry::Kind>, Option<entry::Kind>) {
        let index_kind = dir.map(|_| entry::Kind::Directory);
        let uptodate_kind = dir
            .filter(|entry| {
                entry
                    .flags
                    .intersects(gix_index::entry::Flags::UPTODATE | gix_index::entry::Flags::ASSUME_VALID)
            })
            .map(|_| entry::Kind::Directory);
        (uptodate_kind, index_kind)
    }

    // TODO(perf): multi-threaded hash-table so it's always used, even for case-sensitive lookups, just like Git does it.
    let (uptodate_kind, index_kind) = if let Some(accelerate) = ignore_case {
        match index.entry_by_path_icase(rela_path.as_bstr(), true, accelerate) {
            None => icase_directory_to_kinds(index.entry_closest_to_directory_or_directory_icase(
                rela_path.as_bstr(),
                true,
                accelerate,
            )),
            Some(entry) => {
                if entry.mode.is_submodule() || entry.mode.is_sparse() {
                    entry_to_kinds(entry)
                } else {
                    let icase_dir =
                        index.entry_closest_to_directory_or_directory_icase(rela_path.as_bstr(), true, accelerate);
                    let directory_matches_exactly = icase_dir.is_some_and(|dir| {
                        let path = dir.path(index);
                        let slash_idx = path.rfind_byte(b'/').expect("dir");
                        path[..slash_idx].as_bstr() == rela_path
                    });
                    if directory_matches_exactly {
                        icase_directory_to_kinds(icase_dir)
                    } else {
                        entry_to_kinds(entry)
                    }
                }
            }
        }
    } else {
        match index.entry_by_path(rela_path.as_bstr()) {
            None => {
                rela_path.push(b'/');
                let res = index.prefixed_entries_range(rela_path.as_bstr());
                rela_path.pop();

                let mut one_index_signalling_with_cone = None;
                let mut all_excluded_from_worktree_non_cone = false;
                let is_tracked = res.is_some();
                let kind = res
                    .filter(|range| {
                        if range.len() == 1 {
                            one_index_signalling_with_cone = range.start.into();
                        }
                        let entries = &index.entries()[range.clone()];
                        let any_up_to_date = entries
                            .iter()
                            .any(|e| e.flags.contains(gix_index::entry::Flags::UPTODATE));
                        if !any_up_to_date && one_index_signalling_with_cone.is_none() {
                            all_excluded_from_worktree_non_cone = entries
                                .iter()
                                .all(|e| e.flags.contains(gix_index::entry::Flags::SKIP_WORKTREE));
                        }
                        any_up_to_date
                    })
                    .map(|_| entry::Kind::Directory);

                if all_excluded_from_worktree_non_cone
                    || one_index_signalling_with_cone
                        .filter(|_| kind.is_none())
                        .is_some_and(|idx| index.entries()[idx].mode.is_sparse())
                {
                    special_property = Some(entry::Property::TrackedExcluded);
                }
                (kind, is_tracked.then_some(entry::Kind::Directory))
            }
            Some(entry) => entry_to_kinds(entry),
        }
    };
    (uptodate_kind, index_kind, special_property)
}

fn is_eq(lhs: &BStr, rhs: impl AsRef<BStr>, ignore_case: bool) -> bool {
    if ignore_case {
        lhs.eq_ignore_ascii_case(rhs.as_ref().as_ref())
    } else {
        lhs == rhs.as_ref()
    }
}

fn is_dir_to_mode(is_dir: bool) -> gix_index::entry::Mode {
    if is_dir {
        gix_index::entry::Mode::DIR
    } else {
        gix_index::entry::Mode::FILE
    }
}
