//! Input registry and associated types.

use std::{
    collections::{BTreeMap, HashSet, btree_map},
    io::Read as _,
    path::PathBuf,
    str::FromStr as _,
    sync::LazyLock,
};

use camino::{Utf8Path, Utf8PathBuf};
use itertools::Itertools as _;
use serde::Serialize;
use thiserror::Error;

use crate::{
    CollectionOptions,
    audit::AuditInput,
    config::{Config, ConfigError},
    github::{Client, ClientError},
    models::{
        action::Action,
        dependabot::Dependabot,
        pre_commit::{PreCommitConfig, PreCommitHooks},
        workflow::Workflow,
    },
};

/// Errors that can occur while collecting inputs.
#[derive(Debug, Error)]
pub(crate) enum CollectionError {
    /// The input's syntax is invalid.
    /// This typically indicates a user error.
    #[error("invalid YAML syntax: {0}")]
    Syntax(#[source] anyhow::Error),

    /// The input doesn't match the schema for the expected model.
    /// This typically indicates a user error.
    #[error("input does not match expected validation schema")]
    Schema(#[source] anyhow::Error),

    /// The input couldn't be converted into the expected model.
    /// This typically indicates a bug in `github-actions-models`.
    #[error("couldn't turn input into a an appropriate model")]
    Model(#[from] yaml_serde::Error),

    /// The input couldn't be loaded into an internal yamlpath document.
    /// This typically indicates a bug in `yamlpath`.
    #[error("failed to load internal pathing document")]
    Yamlpath(#[from] yamlpath::QueryError),

    /// An error in a group or global configuration.
    #[error(transparent)]
    Config(#[from] ConfigError),

    /// The input couldn't be parsed as one of our known input sources
    /// (file, directory, or GitHub repo).
    #[error("invalid input: {0}")]
    InvalidInput(String),

    /// The user provided the same input in the same group more than once.
    #[error("can't register the same input more than once: {0}")]
    DuplicateInput(InputKey),

    /// The user wants us to fetch a remote repo, but we don't have a
    /// functional GitHub client (maybe because we're offline, or
    /// because no token was provided).
    #[error("can't fetch remote repository: {0}")]
    NoGitHubClient(RepoSlug),

    /// An error occurred while processing ignore rules.
    #[error("error while processing ignore rules")]
    Ignore(#[from] ignore::Error),

    /// A single input file failed to load as a specific kind.
    #[error("failed to load {1} as {2}")]
    Inner(#[source] Box<CollectionError>, String, InputKind),

    /// The input doesn't have a `.yml` or `.yaml` extension.
    #[error("invalid input: must have .yml or .yaml extension")]
    InvalidExtension,

    /// Workflow-specific collection was requested, but the remote
    /// input doesn't contain any workflows. This typically means the remote
    /// repository doesn't have a `.github` or `.github/workflows` directory.
    #[error("input {1} doesn't contain any workflows")]
    RemoteWithoutWorkflows(#[source] ClientError, String),

    /// A GitHub API error occurred while fetching a remote input.
    #[error("GitHub API error while fetching remote input")]
    Client(#[from] ClientError),

    /// An I/O error occurred while loading the input.
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),

    /// The input path isn't valid UTF-8.
    #[error("invalid path (not UTF-8): {1:?}")]
    InvalidPath(#[source] camino::FromPathError, PathBuf),

    /// No inputs were collected.
    #[error("no inputs collected")]
    NoInputs,

    /// The (remote) input has an ambiguous ref.
    ///
    /// For example, `foo/bar@v1` is ambiguous if `v1` is both a tag
    /// and a branch.
    #[error(
        "remote input has an ambiguous Git reference ({0:?} is both a tag and a branch)",
        .slug.git_ref.as_deref().unwrap_or("HEAD"))
    ]
    AmbiguousRemoteRef { slug: RepoSlug },
}

impl CollectionError {
    /// Returns the "innermost" variant of this [`CollectionError`].
    ///
    /// In practice this is always `&self` *unless* this is an
    /// `Inner` variant, in which case it recurses into the inner error.
    pub(crate) fn inner(&self) -> &Self {
        match self {
            CollectionError::Inner(inner, _, _) => inner.inner(),
            _ => self,
        }
    }
}

#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
pub(crate) enum InputKind {
    /// A workflow file.
    Workflow,
    /// An action definition.
    Action,
    /// A Dependabot configuration file.
    Dependabot,
    /// A `.pre-commit-config.yml` file.
    PreCommitConfig,
    /// A `.pre-commit-hooks.yml` file.
    PreCommitHooks,
}

impl std::fmt::Display for InputKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            InputKind::Workflow => write!(f, "workflow"),
            InputKind::Action => write!(f, "action"),
            InputKind::Dependabot => write!(f, "dependabot config"),
            InputKind::PreCommitConfig => write!(f, "pre-commit config"),
            InputKind::PreCommitHooks => write!(f, "pre-commit hooks definition"),
        }
    }
}

/// A GitHub repository slug, i.e. `owner/repo[@ref]`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
pub(crate) struct RepoSlug {
    /// The owner of the repository.
    pub(crate) owner: String,
    /// The name of the repository.
    pub(crate) repo: String,
    /// An optional Git reference, e.g. a branch or tag name.
    ///
    /// Note: intentionally not exposed, so that consumers get
    /// a reasonable default through [`RepoSlug::git_ref()`] instead.
    git_ref: Option<String>,
}

impl RepoSlug {
    /// Returns a Git reference for this slug.
    ///
    /// This reference is the one provided by the slug if present,
    /// or the default `HEAD` reference if not provided.
    pub(crate) fn git_ref(&self) -> &str {
        self.git_ref.as_deref().unwrap_or("HEAD")
    }
}

impl std::str::FromStr for RepoSlug {
    type Err = CollectionError;

    /// NOTE: This is almost exactly the same as
    /// [`github_actions_models::common::RepositoryUses`],
    /// except that we don't require a git ref and we forbid subpaths.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (path, git_ref) = match s.rsplit_once('@') {
            Some((path, git_ref)) => (path, Some(git_ref)),
            None => (s, None),
        };

        let components = path.split('/').collect::<Vec<_>>();

        match components.len() {
            2 => Ok(Self {
                owner: components[0].into(),
                repo: components[1].into(),
                git_ref: git_ref.map(|s| s.into()),
            }),
            x if x < 2 => Err(CollectionError::InvalidInput(s.into())),
            _ => Err(CollectionError::InvalidInput(s.into())),
        }
    }
}

impl std::fmt::Display for RepoSlug {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if let Some(ref git_ref) = self.git_ref {
            write!(f, "{}/{}@{}", self.owner, self.repo, git_ref)
        } else {
            write!(f, "{}/{}", self.owner, self.repo)
        }
    }
}

#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
pub(crate) struct LocalKey {
    /// The group this input belongs to.
    #[serde(skip)]
    group: Group,

    /// The verbatim path to the input, exactly as the user supplied it.
    /// This can be absolute or relative.
    verbatim_path: Utf8PathBuf,

    /// The "native" path to the input. This is the same as [`Self::verbatim_path`],
    /// but normalized for the host's default separator. For example, if the user
    /// supplies a verbatim path of `./foo.yml`, this will be `.\foo.yml` on Windows.
    ///
    /// This can be absolute or relative.
    #[serde(skip)]
    native_path: Utf8PathBuf,

    /// The "best" identifier for this input.
    ///
    /// This will always be a relative path (unless the input itself was absolute),
    /// and is the "best" in the sense that it attempts to be relative to the repository
    /// root (if present), rather than whatever relative path the user actually supplied.
    ///
    /// This identifier always uses Unix-style path separators.
    ///
    /// See [`InputKey::best_identifier`] for more information.
    #[serde(skip)]
    best_identifier: String,
}

impl LocalKey {
    /// Returns a real path to this [`LocalKey`]'s input, on disk.
    ///
    /// This path may be relative or absolute.
    pub(crate) fn path(&self) -> &Utf8Path {
        &self.verbatim_path
    }

    /// Produce the "best" relative path for a given path.
    ///
    /// This path is the "best" in the sense that it's intended to be maximally
    /// compatible with the assumptions that consumers make. Specifically, many
    /// consumers (like GitHub's "Advanced Security") expect paths to be relative
    /// to the root of the repository, even if the user supplied them to the tool
    /// as absolute or relative to some other directory.
    ///
    /// NOTE: The path returned by this API is *not* guaranteed to be relative to
    /// the current directory, if the current directory is not the same as the
    /// repository root. As such, consumers of this API *must not* assume that they
    /// can naively test these paths for existence, etc. without first resolving
    /// them against the repository root.
    fn best_relative_path<P: AsRef<Utf8Path>>(
        given_path: P,
        prefix: Option<P>,
        root: Option<P>,
    ) -> Utf8PathBuf {
        // Happy path: we have a root directory and the input
        // is relative to it once canonicalized.
        if let Some(root) = root
            && let Ok(canonical) = given_path.as_ref().canonicalize_utf8()
            && let Ok(relative) = canonical.strip_prefix(root.as_ref())
        {
            return relative.to_owned();
        }

        // Semi-happy path: we don't have a root directory,
        // but we have a known prefix that we can strip from the
        // input path.
        if let Some(prefix) = prefix
            && let Ok(stripped) = given_path.as_ref().strip_prefix(prefix.as_ref())
        {
            return stripped.to_owned();
        }

        // Sad path: no root or known prefix, so we return the
        // given path as-is and hope for the best.
        given_path.as_ref().to_owned()
    }
}

#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
pub(crate) struct RemoteKey {
    /// The group this input belongs to.
    #[serde(skip)]
    group: Group,
    slug: RepoSlug,
    /// The path to the input file within the repository.
    path: Utf8PathBuf,
}

#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
pub(crate) struct StdinKey {
    /// The group this input belongs to.
    #[serde(skip)]
    group: Group,
}

/// A unique identifying "key" for an input in a given run of zizmor.
///
/// zizmor currently knows three different kinds of keys: local keys
/// are canonical paths to files on disk, remote keys are relative
/// paths within a referenced GitHub repository, and stdin keys
/// represent input read from standard input.
#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, PartialOrd, Ord)]
pub(crate) enum InputKey {
    Local(LocalKey),
    Remote(RemoteKey),
    Stdin(StdinKey),
}

impl std::fmt::Display for InputKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            InputKey::Local(local) => write!(f, "file://{path}", path = local.verbatim_path),
            InputKey::Remote(remote) => {
                // No ref means assume HEAD, i.e. whatever's on the default branch.
                let git_ref = remote.slug.git_ref.as_deref().unwrap_or("HEAD");
                write!(
                    f,
                    "https://github.com/{owner}/{repo}/blob/{git_ref}/{path}",
                    owner = remote.slug.owner,
                    repo = remote.slug.repo,
                    path = remote.path
                )
            }
            InputKey::Stdin(_) => write!(f, "<stdin>"),
        }
    }
}

impl InputKey {
    /// Constructs a local InputKey.
    ///
    /// `prefix` and `root` are used to derive the input's
    /// "best" relative path for output rendering purposes.
    pub(crate) fn local<P: AsRef<Utf8Path>>(
        group: Group,
        verbatim_path: P,
        prefix: Option<P>,
        root: Option<P>,
    ) -> Self {
        let verbatim_path = verbatim_path.as_ref();

        let best_identifier = {
            let best_relative_path = LocalKey::best_relative_path(
                verbatim_path,
                prefix.as_ref().map(P::as_ref),
                root.as_ref().map(P::as_ref),
            );

            if best_relative_path.is_relative() {
                best_relative_path.components().join("/")
            } else {
                // Stupid edge case: if user supplied an absolute path and
                // we couldn't make it relative, then there's no sane normalization
                // we can perform. Just return it as-is.
                best_relative_path.into()
            }
        };

        Self::Local(LocalKey {
            group,
            verbatim_path: verbatim_path.to_path_buf(),
            native_path: verbatim_path.components().collect(),
            best_identifier,
        })
    }

    pub(crate) fn remote(slug: &RepoSlug, path: String) -> Self {
        Self::Remote(RemoteKey {
            group: slug.into(),
            slug: slug.clone(),
            path: path.into(),
        })
    }

    pub(crate) fn stdin() -> Self {
        Self::Stdin(StdinKey {
            group: Group::from("-"),
        })
    }

    /// Returns the "best" identifier for this [`InputKey`].
    ///
    /// This returns an arbitrary identifier for the input which,
    /// depending on the input kind, may or may resemble a userful path
    /// on disk.
    pub(crate) fn best_identifier(&self) -> &str {
        match self {
            // Local keys: always use the "best" relative path,
            // which is opportunistically relative to the repo root
            // if possible.
            InputKey::Local(local) => local.best_identifier.as_str(),
            // Remote keys: always use the path within the repository,
            // which is always relative.
            InputKey::Remote(remote) => remote.path.as_str(),
            // Standard input uses an arbitrary identifier.
            InputKey::Stdin(_) => "<stdin>",
        }
    }

    /// Return a "presentation" path for this [`InputKey`].
    ///
    /// This will always be a relative path for remote keys,
    /// and will be the native path for local keys.
    pub(crate) fn presentation_path(&self) -> &str {
        match self {
            InputKey::Local(local) => local.native_path.as_str(),
            InputKey::Remote(remote) => remote.path.as_str(),
            InputKey::Stdin(_) => "<stdin>",
        }
    }

    /// Returns the filename component of this [`InputKey`].
    pub(crate) fn filename(&self) -> &str {
        // NOTE: Safe unwraps, since the presence of a filename component
        // is a construction invariant of all `InputKey` variants.
        match self {
            InputKey::Local(local) => local
                .verbatim_path
                .file_name()
                .expect("expected input key to have a filename component"),
            InputKey::Remote(remote) => remote
                .path
                .file_name()
                .expect("expected input key to have a filename component"),
            InputKey::Stdin(_) => "<stdin>",
        }
    }

    /// Returns the group this input belongs to.
    pub(crate) fn group(&self) -> &Group {
        match self {
            InputKey::Local(local) => &local.group,
            InputKey::Remote(remote) => &remote.group,
            InputKey::Stdin(stdin) => &stdin.group,
        }
    }
}

/// An opaque identifier for a group of inputs.
#[derive(Debug, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)]
pub(crate) struct Group(String);

impl From<&str> for Group {
    fn from(value: &str) -> Self {
        Self(value.to_string())
    }
}

impl From<&RepoSlug> for Group {
    fn from(value: &RepoSlug) -> Self {
        Self(value.to_string())
    }
}

/// A group of inputs collected from the same source.
pub(crate) struct InputGroup {
    /// The configuration for this group.
    config: Config,
    /// The group's root directory (as an absolute path), if applicable and inferable.
    root: Option<Utf8PathBuf>,
    /// The collected inputs.
    inputs: BTreeMap<InputKey, AuditInput>,
}

impl InputGroup {
    pub(crate) fn new(config: Config, root: Option<Utf8PathBuf>) -> Self {
        Self {
            config,
            root,
            inputs: Default::default(),
        }
    }

    pub(crate) fn register_input(&mut self, input: AuditInput) -> Result<(), CollectionError> {
        if self.inputs.contains_key(input.key()) {
            return Err(CollectionError::DuplicateInput(input.key().clone()));
        }

        self.inputs.insert(input.key().clone(), input);

        Ok(())
    }

    /// Given a path to an input file, attempt to discover the Git repository root that it belongs
    /// to, if any.
    ///
    /// This is a rough approximation of what `git rev-parse --show-toplevel` does.
    ///
    /// Returns `None` if the path is not within a Git repository or if the root can't be determined for any reason.
    pub(crate) fn discover_root(path: &Utf8Path) -> Option<Utf8PathBuf> {
        Self::discover_root_with_ceilings(path, &GIT_CEILING_DIRECTORIES)
    }

    fn discover_root_with_ceilings(
        path: &Utf8Path,
        ceilings: &HashSet<Utf8PathBuf>,
    ) -> Option<Utf8PathBuf> {
        // Canonicalize first; this also avoids a `parent()` of `Some("")`
        // for inputs like `foo.yml`.
        let canonical = match path.canonicalize_utf8() {
            Ok(canonical) => canonical,
            Err(_) => {
                tracing::trace!("failed to find a canonical path for {path}");
                return None;
            }
        };

        let mut candidate = if canonical.is_file() {
            canonical.parent()?.to_path_buf()
        } else {
            canonical
        };

        loop {
            if ceilings.contains(&candidate) {
                tracing::trace!("hit a GIT_CEILING_DIRECTORIES entry at {candidate}");
                break;
            }

            // TODO: Handle worktrees?
            tracing::trace!("checking if {candidate} is a Git repository root");
            if candidate.join(".git").is_dir() {
                return Some(candidate);
            }

            if let Some(parent) = candidate.parent() {
                candidate = parent.to_path_buf();
            } else {
                break;
            }
        }

        tracing::trace!("no Git repository root found for {path}");
        None
    }

    pub(crate) fn register(
        &mut self,
        kind: InputKind,
        contents: String,
        key: InputKey,
        strict: bool,
    ) -> Result<(), CollectionError> {
        tracing::debug!("registering {kind} input as with key {key}");

        let input: Result<AuditInput, CollectionError> = match kind {
            InputKind::Workflow => Workflow::from_string(contents, key.clone()).map(Into::into),
            InputKind::Action => Action::from_string(contents, key.clone()).map(Into::into),
            InputKind::Dependabot => Dependabot::from_string(contents, key.clone()).map(Into::into),
            InputKind::PreCommitConfig => {
                PreCommitConfig::from_string(contents, key.clone()).map(Into::into)
            }
            InputKind::PreCommitHooks => {
                PreCommitHooks::from_string(contents, key.clone()).map(Into::into)
            }
        };

        match input {
            Ok(input) => self.register_input(input),
            Err(CollectionError::Syntax(e)) if !strict => {
                tracing::warn!("failed to parse input: {e}");
                Ok(())
            }
            Err(e @ CollectionError::Schema { .. }) if !strict => {
                tracing::warn!("failed to validate {key} as {kind}: {e}");
                Ok(())
            }
            Err(e) => Err(CollectionError::Inner(e.into(), key.to_string(), kind)),
        }
    }

    async fn collect_from_file(
        path: &Utf8Path,
        options: &CollectionOptions,
    ) -> Result<Self, CollectionError> {
        let root = Self::discover_root(path);
        let config =
            Config::discover(options, || Config::discover_local(path, root.as_deref())).await?;

        // Workflows can be named anything, including `dependabot.yml`
        // (overlapping with Dependabot configs) and `action.yml` (overlapping
        // with action definitions). Consequently, we make a best effort
        // disambiguate them by looking at their parent path.
        // See: https://github.com/zizmorcore/zizmor/issues/1341
        let is_workflow_path = camino::absolute_utf8(path)?
            .parent()
            .is_some_and(|parent| parent.ends_with(".github/workflows"));

        let mut group = Self::new(config, root);
        let root = group.root.as_deref();

        // When collecting individual files, we don't know which part
        // of the input path is the prefix.
        let (key, kind) = match (path.file_stem(), path.extension()) {
            // TODO: Do we need the `is_workflow_path` disambiguation here?
            // The only way this could be wrong is if the user does something
            // bizarre like `.github/workflows/.pre-commit-{config,hooks}.yml`.
            (Some(".pre-commit-config"), Some("yml" | "yaml")) if !is_workflow_path => (
                InputKey::local(Group(path.as_str().into()), path, None, root),
                InputKind::PreCommitConfig,
            ),
            (Some(".pre-commit-hooks"), Some("yml" | "yaml")) if !is_workflow_path => (
                InputKey::local(Group(path.as_str().into()), path, None, root),
                InputKind::PreCommitHooks,
            ),
            (Some("dependabot"), Some("yml" | "yaml")) if !is_workflow_path => (
                InputKey::local(Group(path.as_str().into()), path, None, root),
                InputKind::Dependabot,
            ),
            (Some("action"), Some("yml" | "yaml")) if !is_workflow_path => (
                InputKey::local(Group(path.as_str().into()), path, None, root),
                InputKind::Action,
            ),
            (Some(_), Some("yml" | "yaml")) => (
                InputKey::local(Group(path.as_str().into()), path, None, root),
                InputKind::Workflow,
            ),
            _ => return Err(CollectionError::InvalidExtension),
        };

        let contents = std::fs::read_to_string(path).map_err(|e| {
            CollectionError::Inner(CollectionError::Io(e).into(), key.to_string(), kind)
        })?;
        group.register(kind, contents, key, options.strict)?;

        Ok(group)
    }

    async fn collect_from_dir(
        path: &Utf8Path,
        options: &CollectionOptions,
    ) -> Result<Self, CollectionError> {
        let root = Self::discover_root(path);
        let config =
            Config::discover(options, || Config::discover_local(path, root.as_deref())).await?;

        let mut group = Self::new(config, root);

        // Start with all filters disabled, i.e. walk everything.
        let mut walker = ignore::WalkBuilder::new(path);
        let walker = walker.standard_filters(false);

        // If the user wants to respect `.gitignore` files, then we need to
        // explicitly enable it. This also enables filtering by a global
        // `.gitignore` file and the `.git/info/exclude` file, since these
        // typically align with the user's expectations.
        //
        // We honor `.gitignore` and similar files even if `.git/` is not
        // present, since users may retrieve or reconstruct a source archive
        // without a `.git/` directory. In particular, this snares some
        // zizmor integrators.
        //
        // See: https://github.com/zizmorcore/zizmor/issues/596
        if options.mode_set.respects_gitignore() {
            walker
                .require_git(false)
                .git_ignore(true)
                .git_global(true)
                .git_exclude(true);
        }

        let root = group.root.clone();
        for entry in walker.build() {
            let entry = entry?;
            let entry = <&Utf8Path>::try_from(entry.path())
                .map_err(|e| CollectionError::InvalidPath(e, entry.path().into()))?;
            // Pre-compute file status so we don't call `stat()` once per mode
            // check below.
            let entry_is_file = entry.is_file();
            let root = root.as_deref();

            if options.mode_set.workflows()
                && entry_is_file
                && matches!(entry.extension(), Some("yml" | "yaml"))
                && camino::absolute_utf8(entry)?
                    .parent()
                    .is_some_and(|dir| dir.ends_with(".github/workflows"))
            {
                let key = InputKey::local(Group(path.as_str().into()), entry, Some(path), root);
                let contents = std::fs::read_to_string(entry).map_err(|e| {
                    CollectionError::Inner(
                        CollectionError::Io(e).into(),
                        key.to_string(),
                        InputKind::Workflow,
                    )
                })?;
                group.register(InputKind::Workflow, contents, key, options.strict)?;
            }

            if options.mode_set.actions()
                && entry_is_file
                && matches!(entry.file_name(), Some("action.yml" | "action.yaml"))
            {
                let key = InputKey::local(Group(path.as_str().into()), entry, Some(path), root);
                let contents = std::fs::read_to_string(entry).map_err(|e| {
                    CollectionError::Inner(
                        CollectionError::Io(e).into(),
                        key.to_string(),
                        InputKind::Action,
                    )
                })?;
                group.register(InputKind::Action, contents, key, options.strict)?;
            }

            if options.mode_set.dependabot()
                && entry_is_file
                && matches!(
                    entry.file_name(),
                    Some("dependabot.yml" | "dependabot.yaml")
                )
            {
                let key = InputKey::local(Group(path.as_str().into()), entry, Some(path), root);
                let contents = std::fs::read_to_string(entry).map_err(|e| {
                    CollectionError::Inner(
                        CollectionError::Io(e).into(),
                        key.to_string(),
                        InputKind::Dependabot,
                    )
                })?;
                group.register(InputKind::Dependabot, contents, key, options.strict)?;
            }

            if options.mode_set.pre_commit() && entry_is_file {
                if matches!(
                    entry.file_name(),
                    Some(".pre-commit-config.yml" | ".pre-commit-config.yaml")
                ) {
                    let key = InputKey::local(Group(path.as_str().into()), entry, Some(path), root);
                    let contents = std::fs::read_to_string(entry).map_err(|e| {
                        CollectionError::Inner(
                            CollectionError::Io(e).into(),
                            key.to_string(),
                            InputKind::PreCommitConfig,
                        )
                    })?;
                    group.register(InputKind::PreCommitConfig, contents, key, options.strict)?;
                } else if matches!(
                    entry.file_name(),
                    Some(".pre-commit-hooks.yml" | ".pre-commit-hooks.yaml")
                ) {
                    let key = InputKey::local(Group(path.as_str().into()), entry, Some(path), root);
                    let contents = std::fs::read_to_string(entry).map_err(|e| {
                        CollectionError::Inner(
                            CollectionError::Io(e).into(),
                            key.to_string(),
                            InputKind::PreCommitHooks,
                        )
                    })?;
                    group.register(InputKind::PreCommitHooks, contents, key, options.strict)?;
                }
            }
        }

        Ok(group)
    }

    async fn collect_from_repo_slug(
        slug: RepoSlug,
        options: &CollectionOptions,
        gh_client: Option<&Client>,
    ) -> Result<Self, CollectionError> {
        let client = gh_client.ok_or_else(|| CollectionError::NoGitHubClient(slug.clone()))?;

        let config = Config::discover(options, || Config::discover_remote(client, &slug)).await?;
        let mut group = Self::new(config, None);

        if options.mode_set.workflows_only() {
            // Performance: if we're *only* collecting workflows, then we
            // can save ourselves a full repo download and only fetch the
            // repo's workflow files.
            client.fetch_workflows(&slug, options, &mut group).await?;
        } else {
            let before = group.len();
            client
                .fetch_audit_inputs(&slug, options, &mut group)
                .await?;
            let after = group.len();
            let len = after - before;

            tracing::info!(
                "collected {len} inputs from {owner}/{repo}",
                owner = slug.owner,
                repo = slug.repo
            );
        }

        Ok(group)
    }

    async fn collect_from_stdin() -> Result<Self, CollectionError> {
        let mut contents = String::new();
        std::io::stdin()
            .read_to_string(&mut contents)
            .map_err(CollectionError::Io)?;

        // TODO: This should probably honor the global config, if passed by the user?
        let mut group = Self::new(Config::default(), None);
        let key = InputKey::stdin();

        // Infer the input type by trying each parser in order.
        // Workflow is tried first since it's the most common stdin use case.
        match group.register(InputKind::Workflow, contents.clone(), key.clone(), true) {
            Ok(()) => return Ok(group),
            // YAML itself is invalid; no point trying other types.
            Err(e) if matches!(e.inner(), CollectionError::Syntax(_)) => return Err(e),
            // Valid YAML but not a valid workflow; fall through to the next type.
            Err(_) => (),
        };

        if let Ok(()) = group.register(InputKind::Action, contents.clone(), key.clone(), true) {
            return Ok(group);
        }

        if let Ok(()) = group.register(InputKind::Dependabot, contents.clone(), key.clone(), true) {
            return Ok(group);
        }

        if let Ok(()) = group.register(
            InputKind::PreCommitConfig,
            contents.clone(),
            key.clone(),
            true,
        ) {
            return Ok(group);
        }

        if let Ok(()) = group.register(InputKind::PreCommitHooks, contents, key, true) {
            return Ok(group);
        }

        // If we get here, then the input isn't in the right shape for any of our
        // known types. We return an empty group; the CLI will subsequently
        // produce an error since no inputs were collected.
        tracing::warn!("stdin: could not parse as any known input type");
        Ok(group)
    }

    pub(crate) async fn collect(
        request: &str,
        options: &CollectionOptions,
        gh_client: Option<&Client>,
    ) -> Result<Self, CollectionError> {
        if request == "-" {
            return Self::collect_from_stdin().await;
        }

        let path = Utf8Path::new(request);

        if path.is_file() {
            Self::collect_from_file(path, options).await
        } else if path.is_dir() {
            Self::collect_from_dir(path, options).await
        } else {
            let slug = RepoSlug::from_str(request)?;
            Self::collect_from_repo_slug(slug, options, gh_client).await
        }
    }

    pub(crate) fn len(&self) -> usize {
        self.inputs.len()
    }
}

/// Cached parse of the process's `GIT_CEILING_DIRECTORIES`.
static GIT_CEILING_DIRECTORIES: LazyLock<HashSet<Utf8PathBuf>> = LazyLock::new(|| {
    let Ok(raw) = std::env::var("GIT_CEILING_DIRECTORIES") else {
        return HashSet::new();
    };

    let separator = if cfg!(windows) { ';' } else { ':' };
    let mut resolve = true;
    let mut ceilings = HashSet::new();
    for entry in raw.split(separator) {
        // Per `git` docs, an empty entry means that all subsequent
        // entries are not resolved.
        // See: <https://git-scm.com/docs/git#Documentation/git.txt-GITCEILINGDIRECTORIES>
        if entry.is_empty() {
            resolve = false;
            continue;
        }
        let path = Utf8Path::new(entry);
        if !path.is_absolute() {
            continue;
        }
        let resolved = if resolve {
            path.canonicalize_utf8().ok()
        } else {
            Some(path.to_path_buf())
        };
        if let Some(p) = resolved {
            ceilings.insert(p);
        }
    }
    ceilings
});

pub(crate) struct InputRegistry {
    // NOTE: We use a BTreeMap here to ensure that registered inputs
    // iterate in a deterministic order. This saves us a lot of pain
    // while snapshot testing across multiple input files, and makes
    // the user experience more predictable.
    pub(crate) groups: BTreeMap<Group, InputGroup>,
}

impl InputRegistry {
    pub(crate) fn new() -> Self {
        Self {
            groups: Default::default(),
        }
    }

    /// Return the total number of inputs registered across all groups
    /// in this registry.
    pub(crate) fn len(&self) -> usize {
        self.groups.values().map(|g| g.len()).sum()
    }

    pub(crate) async fn register_group(
        &mut self,
        name: &str,
        options: &CollectionOptions,
        gh_client: Option<&Client>,
    ) -> Result<(), CollectionError> {
        // If the group has already been registered, then the user probably
        // duplicated the input multiple times on the command line by accident.
        // We just ignore any duplicate registrations.
        if let btree_map::Entry::Vacant(e) = self.groups.entry(Group(name.into())) {
            e.insert(InputGroup::collect(name, options, gh_client).await?);
        }

        Ok(())
    }

    /// Return an iterator over all inputs in all groups in this registry.
    pub(crate) fn iter_inputs(&self) -> impl Iterator<Item = (&InputKey, &AuditInput)> {
        self.groups.values().flat_map(|group| group.inputs.iter())
    }

    /// Get a reference to a registered input by its key.
    pub(crate) fn get_input(&self, key: &InputKey) -> &AuditInput {
        self.groups
            .get(key.group())
            .and_then(|group| group.inputs.get(key))
            .expect("API misuse: requested an un-registered input")
    }

    /// Get a reference to the configuration for a given input group.
    pub(crate) fn get_config(&self, group: &Group) -> &Config {
        &self
            .groups
            .get(group)
            .expect("API misuse: requested an un-registered input group")
            .config
    }
}

#[cfg(test)]
mod tests {
    use std::{collections::HashSet, str::FromStr as _};

    use camino::{Utf8Path, Utf8PathBuf};

    use crate::registry::input::InputGroup;

    use super::{InputKey, RepoSlug};

    #[test]
    fn test_input_key_display() {
        let local = InputKey::local("fakegroup".into(), "/foo/bar/baz.yml", None, None);
        assert_eq!(local.to_string(), "file:///foo/bar/baz.yml");

        // No ref
        let slug = RepoSlug::from_str("foo/bar").unwrap();
        let remote = InputKey::remote(&slug, ".github/workflows/baz.yml".into());
        assert_eq!(
            remote.to_string(),
            "https://github.com/foo/bar/blob/HEAD/.github/workflows/baz.yml"
        );

        // With a git ref
        let slug = RepoSlug::from_str("foo/bar@v1").unwrap();
        let remote = InputKey::remote(&slug, ".github/workflows/baz.yml".into());
        assert_eq!(
            remote.to_string(),
            "https://github.com/foo/bar/blob/v1/.github/workflows/baz.yml"
        );
    }

    /// Tests that [`InputKey::presentation_path`] returns the exact path that the user
    /// supplied (regardless of prefix or root), but normalized for the host's default
    /// separator.
    #[test]
    fn test_input_key_local_presentation_path() {
        let local = InputKey::local("fakegroup".into(), "/foo/bar/baz.yml", None, None);
        if cfg!(target_os = "windows") {
            assert_eq!(local.presentation_path(), "\\foo\\bar\\baz.yml");
        } else {
            assert_eq!(local.presentation_path(), "/foo/bar/baz.yml");
        }

        let local = InputKey::local("fakegroup".into(), "/foo/bar/baz.yml", Some("/foo"), None);
        if cfg!(target_os = "windows") {
            assert_eq!(local.presentation_path(), "\\foo\\bar\\baz.yml");
        } else {
            assert_eq!(local.presentation_path(), "/foo/bar/baz.yml");
        }
    }

    #[test]
    fn test_input_key_local_best_identifier() {
        // "Rootless" cases: with no group root, best_identifier falls back to
        // stripping the input's own prefix (if any), else returns the path
        // as-is.
        let local = InputKey::local("fakegroup".into(), "bar/baz.yml", None, None);
        assert_eq!(local.best_identifier(), "bar/baz.yml");

        // Rootless with an absolute input passes through as-is.
        let absolute = if cfg!(windows) {
            Utf8Path::new(r"C:\foo\bar\baz.yml")
        } else {
            Utf8Path::new("/foo/bar/baz.yml")
        };
        let local = InputKey::local("fakegroup".into(), absolute, None, None);
        assert_eq!(local.best_identifier(), absolute);

        let local = InputKey::local("fakegroup".into(), "/foo/bar/baz.yml", Some("/foo"), None);
        assert_eq!(local.best_identifier(), "bar/baz.yml");

        let local = InputKey::local(
            "fakegroup".into(),
            "/foo/bar/baz.yml",
            Some("/foo/bar/"),
            None,
        );
        assert_eq!(local.best_identifier(), "baz.yml");

        let local = InputKey::local(
            "fakegroup".into(),
            "/home/runner/work/repo/repo/.github/workflows/baz.yml",
            Some("/home/runner/work/repo/repo"),
            None,
        );
        assert_eq!(local.best_identifier(), ".github/workflows/baz.yml");

        let local = InputKey::local(
            "fakegroup".into(),
            "./.github/workflows/baz.yml",
            Some("."),
            None,
        );
        assert_eq!(local.best_identifier(), ".github/workflows/baz.yml");

        // "Rooted" case: with a real root, best_identifier is canonical-then-strip.
        let temp_dir = tempfile::TempDir::new().unwrap();
        let temp_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())
            .unwrap()
            .canonicalize_utf8()
            .unwrap();

        let child = temp_path.join("foo/bar/baz.yml");
        std::fs::create_dir_all(child.parent().unwrap()).unwrap();
        std::fs::write(&child, "contents").unwrap();

        let local = InputKey::local("fakegroup".into(), child, None, Some(temp_path));
        assert_eq!(local.best_identifier(), "foo/bar/baz.yml");
    }

    #[test]
    fn test_discover_root_respects_ceiling() {
        let temp_dir = tempfile::TempDir::new().unwrap();
        let temp_path = Utf8PathBuf::try_from(temp_dir.path().to_path_buf())
            .unwrap()
            .canonicalize_utf8()
            .unwrap();

        std::fs::create_dir(temp_path.join(".git")).unwrap();
        let child = temp_path.join("project/subdir");
        std::fs::create_dir_all(&child).unwrap();

        // No ceiling: the walk finds the fake repo.
        assert_eq!(
            InputGroup::discover_root_with_ceilings(&child, &HashSet::new()),
            Some(temp_path.clone()),
        );

        // The repo itself is a ceiling: the walk stops before examining it.
        assert_eq!(
            InputGroup::discover_root_with_ceilings(&child, &HashSet::from([temp_path.clone()]),),
            None,
        );

        // An ancestor above the repo is the ceiling, so the repo is still found.
        let parent_ceiling = temp_path.parent().unwrap().to_path_buf();
        assert_eq!(
            InputGroup::discover_root_with_ceilings(&child, &HashSet::from([parent_ceiling]),),
            Some(temp_path.clone()),
        );

        // File inputs: canonicalize then walk from the file's parent.
        let workflow_file = temp_path.join(".github/workflows/test.yml");
        std::fs::create_dir_all(workflow_file.parent().unwrap()).unwrap();
        std::fs::write(&workflow_file, "").unwrap();
        assert_eq!(
            InputGroup::discover_root_with_ceilings(&workflow_file, &HashSet::new()),
            Some(temp_path),
        );
    }
}
