"""
This module computes data for GitHub Repositories
"""

import ast
import configparser
import importlib
import json
import logging
import pathlib
import shutil
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urlparse

# tomllib was added to the standard library in Python 3.11; for 3.10 we fall
# back to the third-party tomli backport which exposes the same API.
if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

import defusedxml.ElementTree as ET
import pandas as pd
import pygit2
import yaml

from almanack.git import (
    clone_repository,
    count_files,
    file_exists_in_repo,
    find_file,
    get_commits,
    get_edited_files,
    get_remote_url,
    read_file,
    repo_dir_exists,
)
from almanack.metrics.entropy.calculate_entropy import (
    calculate_aggregate_entropy,
    calculate_history_complexity_with_decay,
    calculate_normalized_entropy,
)
from almanack.metrics.garden_lattice.connectedness import (
    count_unique_contributors,
    default_branch_is_not_master,
    detect_social_media_links,
    find_doi_citation_data,
    find_openalex_citing_works_funding,
    find_software_mentions_openalex,
    is_citable,
)
from almanack.metrics.garden_lattice.practicality import (
    count_repo_tags,
    get_ecosystems_package_metrics,
)
from almanack.metrics.garden_lattice.understanding import includes_common_docs
from almanack.metrics.notebooks import (
    check_ipynb_code_exec_order,
    get_nb_contents,
)
from almanack.metrics.remote import get_api_data

LOGGER = logging.getLogger(__name__)

METRICS_TABLE = f"{pathlib.Path(__file__).parent!s}/metrics.yml"
DATETIME_NOW = datetime.now(timezone.utc)

# Pinned to a specific commit to ensure reproducible extension data.
# Update this hash intentionally when pulling in upstream Linguist changes.
_LINGUIST_LANGUAGES_URL = "https://raw.githubusercontent.com/github-linguist/linguist/240bf9233bbfe82209e9c5437ab8ec06ba648c91/lib/linguist/languages.yml"

_PROGRAMMING_EXTENSIONS_FILE = (
    pathlib.Path(__file__).parent / "programming_extensions.yml"
)

# Fallback set loaded from programming_extensions.yml. Used when the live
# Linguist languages.yml cannot be fetched or parsed.
with open(_PROGRAMMING_EXTENSIONS_FILE, "r") as _f:
    _FALLBACK_PROGRAMMING_EXTENSIONS: frozenset[str] = frozenset(
        yaml.safe_load(_f).get("extensions", [])
    )

# Module-level cache; populated on first call to _get_programming_extensions().
_programming_extensions_cache: Optional[frozenset[str]] = None


def _get_programming_extensions() -> frozenset[str]:
    """Return file extensions that belong to programming languages per GitHub Linguist.

    Fetches and parses the Linguist ``languages.yml`` on the first call, then
    caches the result for the lifetime of the process. Falls back to
    ``_FALLBACK_PROGRAMMING_EXTENSIONS`` if the fetch or parse fails.
    """
    global _programming_extensions_cache  # noqa: PLW0603
    if _programming_extensions_cache is not None:
        return _programming_extensions_cache

    try:
        import requests as _requests  # noqa: PLC0415

        # fetch the upstream Linguist languages.yml
        response = _requests.get(_LINGUIST_LANGUAGES_URL, timeout=10)
        response.raise_for_status()
        languages = yaml.safe_load(response.text) or {}
        # collect file extensions for programming languages only
        exts: set[str] = set()
        for lang_meta in languages.values():
            if not isinstance(lang_meta, dict):
                continue
            if lang_meta.get("type") == "programming":
                for ext in lang_meta.get("extensions", []):
                    if isinstance(ext, str):
                        exts.add(ext)
        # cache and return if we got a non-empty result
        if exts:
            _programming_extensions_cache = frozenset(exts)
            return _programming_extensions_cache
    except Exception:
        LOGGER.debug(
            "Unable to fetch Linguist languages.yml; falling back to built-in extension list.",
            exc_info=True,
        )

    _programming_extensions_cache = _FALLBACK_PROGRAMMING_EXTENSIONS
    return _programming_extensions_cache


def _walk_tree_measure_size_of_noncode_files(
    tree: Union["pygit2.Tree", "pygit2.Blob"],
    repo: "pygit2.Repository",
    prefix: str = "",
) -> Dict[str, int]:
    """Recursively walk a git tree and count lines per non-code file extension.

    The function iterates through each blob (file) in the tree, and counts new lines for
    non-code files. Specifically, the function will skip a file if the extension
    appears in ``_get_programming_extensions()`` or if it lives inside a ``.git/``
    directory. The function counts non-code, text-based files by newline and counts
    binary files based on byte length. Lastly, the function computes a sum per file
    extension and writes into a ``{extension: line_count}`` dict where files missing an extension
    use the key ``"<no_ext>"``.

    Args:
        tree: A pygit2 Tree or Blob to walk. Top-level callers pass the root
            tree; recursive calls pass subtrees or blobs.
        repo: The pygit2 Repository used to dereference object IDs when
            traversing subtrees.
        prefix: Relative file path accumulated during recursion. Defaults to
            an empty string for the root call.

    Returns:
        A dictionary mapping each non-code file extension (for example,
        ``.md``, ``.csv``) to its approximate line or byte count. Files
        without an extension are keyed as ``"<no_ext>"``.
    """
    counts: Dict[str, int] = {}

    if isinstance(tree, pygit2.Blob):
        path = prefix
        suffix = pathlib.Path(path).suffix or ""

        # Skip obvious VCS/config internals.
        if "/.git/" in path or path.startswith(".git/"):
            return counts

        # Skip programming language files; we only want non-code here.
        if suffix in _get_programming_extensions():
            return counts

        # Try to treat as text and count lines; fall back to byte size.
        try:
            blob_data: bytes = tree.data  # type: ignore[assignment]
            try:
                text = blob_data.decode("utf-8")
                value = text.count("\n") + (
                    1 if text and not text.endswith("\n") else 0
                )
            except UnicodeDecodeError:
                value = len(blob_data)
        except Exception:
            # If anything goes wrong, conservatively use zero.
            LOGGER.debug(
                "Unable to read blob data for %s; defaulting size to 0.",
                prefix,
                exc_info=True,
            )
            value = 0

        ext_key = suffix or "<no_ext>"
        counts[ext_key] = counts.get(ext_key, 0) + int(value)
        return counts

    if isinstance(tree, pygit2.Tree):
        for entry in tree:
            entry_path = f"{prefix}/{entry.name}" if prefix else entry.name
            try:
                subtree_or_blob = repo[entry.id]
            except (KeyError, pygit2.GitError):
                LOGGER.debug(
                    "Unable to resolve git object for %s; skipping.", entry_path
                )
                continue
            child_counts = _walk_tree_measure_size_of_noncode_files(
                subtree_or_blob,
                repo=repo,
                prefix=entry_path,
            )
            for ext, val in child_counts.items():
                counts[ext] = counts.get(ext, 0) + val
        return counts

    return {}


def _normalize_exclude_paths(
    exclude_paths: Optional[Union[str, List[str], Tuple[str, ...]]],
) -> Optional[List[str]]:
    """Normalize exclude path inputs into a list of strings.

    Args:
        exclude_paths: A string, list, or tuple of exclude paths. Accepts
            comma-separated strings and strips surrounding quotes.

    Returns:
        A list of normalized exclude paths, or None if no paths are provided.
    """
    if exclude_paths is None:
        return None
    if isinstance(exclude_paths, str):
        parts = [exclude_paths]
    else:
        parts = list(exclude_paths)
    normalized = []
    for part in parts:
        if not part:
            continue
        part_str = part if isinstance(part, str) else str(part)
        for raw_candidate in part_str.split(","):
            candidate = raw_candidate.strip()
            if not candidate:
                continue
            if (candidate.startswith("'") and candidate.endswith("'")) or (
                candidate.startswith('"') and candidate.endswith('"')
            ):
                candidate = candidate[1:-1]
            if candidate:
                normalized.append(candidate)
    return normalized or None


def get_table(
    repo_path: str,
    ignore: Optional[List[str]] = None,
    exclude_paths: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    """Gather metrics on a repository and return the results in a structured format.

    This function reads a metrics table from a predefined YAML file, computes relevant
    data from the specified repository, and associates the computed results with
    the metrics defined in the metrics table. If an error occurs during data
    computation, an exception is raised.

    Args:
        repo_path: The file path to the repository for which the Almanack runs metrics.
        ignore: A list of metric IDs to ignore when running the checks.
        exclude_paths: Repository-relative paths or glob patterns to exclude from checks.

    Returns:
        A list of dictionaries containing the metrics and their associated results.
        Each dictionary includes the original metrics data along with the computed
        result under the key "result".

    Raises:
        ReferenceError: If there is an error encountered while processing the data,
            providing context in the error message.
    """

    exclude_paths = _normalize_exclude_paths(exclude_paths)

    # read the metrics table
    with open(METRICS_TABLE, "r") as f:
        metrics_table = yaml.safe_load(f)["metrics"]

    # check that our ignore codes exist within the table
    if ignore is not None:
        # Raise an error if there are any invalid ignore keys
        if invalid_ignore_keys := [
            # Collect all ignore keys that do not exist in metrics_data
            ignore_metric_id
            for ignore_metric_id in ignore
            if ignore_metric_id
            not in {metric_data["id"] for metric_data in metrics_table}
        ]:
            raise ValueError(f"Invalid ignore keys: {invalid_ignore_keys}")

    required_metric_names = {
        metric["name"]
        for metric in metrics_table
        if ignore is None or metric["id"] not in ignore
    }

    # gather data for use in the metrics table
    metrics_data = compute_repo_data(
        repo_path=repo_path,
        exclude_paths=exclude_paths,
        required_metric_names=required_metric_names,
    )

    if "error" in metrics_data.keys():
        raise ReferenceError(
            "Encountered an error with processing the data.", metrics_data
        )

    # return metrics table (list of dictionaries as records of metrics)
    metrics_table_with_data = [
        {
            **metric,
            # add the data results for the metrics to the table
            "result": metrics_data[metric["name"]],
        }
        # for each metric, gather the related process data and add to a dictionary
        # related to that metric along with others in a list.
        for metric in metrics_table
        if ignore is None or metric["id"] not in ignore
    ]

    # calculate almanack score (the function modifies the placeholder)
    return [
        (
            {
                **entry,
                "result": compute_almanack_score(
                    almanack_table=metrics_table_with_data
                ),
            }
            if entry["name"] == "repo-almanack-score"
            else entry
        )
        for entry in metrics_table_with_data
    ]


def gather_failed_almanack_metric_checks(
    repo_path: str,
    ignore: Optional[List[str]] = None,
    exclude_paths: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    """Gather checks on the repository metrics and return a list of failed checks.

    Args:
        repo_path: The file path to the repository which will have metrics
            calculated and includes boolean checks.
        ignore: A list of metric IDs to ignore when running the checks.
        exclude_paths: Repository-relative paths or glob patterns to exclude from checks.

    Returns:
        A list of dictionaries containing the metrics and their associated results.
        Each dictionary includes the name, id, and guidance on how to fix each
        failed check. The dictionary also includes data about the almanack score
        for use in summarizing the results.
    """

    return [
        {
            metric_key: metric_val
            for metric_key, metric_val in metric.items()
            # iterate through the metric keys and only gather the following
            # (we need only these for the output)
            if metric_key in ["name", "id", "correction_guidance", "result"]
        }
        for metric in get_table(
            repo_path=repo_path,
            ignore=ignore,
            exclude_paths=exclude_paths,
        )
        if
        # gathers the almanack score
        (metric["name"] == "repo-almanack-score") or
        # gathers failed checks
        (
            (
                # bool results with a non-zero sustainability correlation
                metric["result-type"] == "bool"
                and metric["sustainability_correlation"] != 0
            )
            and (
                # failures for positive sustainability correlation
                # (good things missing)
                (
                    (metric["result"] == False or metric["result"] == None)
                    and metric["sustainability_correlation"] == 1
                )
                # failures for negative sustainability correlation
                # (bad things present)
                or (
                    (metric["result"] == True or metric["result"] == None)
                    and metric["sustainability_correlation"] == -1
                )
            )
        )
    ]


def days_of_development(repo: pygit2.Repository) -> float:
    """


    Args:
        repo (pygit2.Repository): Path to the git repository.

    Returns:
        float: The average number of commits per day over the period of time.
    """
    try:
        # Try to get the HEAD commit. If it raises an error, there are no commits.
        repo.revparse_single("HEAD")
    except KeyError:
        # If HEAD doesn't exist (repo is empty), return 0 commits.
        return 0

    # Traverse the commit history and collect commit dates
    commit_dates = [
        datetime.fromtimestamp(commit.commit_time).date()
        for commit in repo.walk(repo.head.target, pygit2.GIT_SORT_TIME)
    ]

    # If no commits, return 0
    if not commit_dates:
        return 0

    # Calculate the number of days between the first and last commit
    # +1 to include the first day
    total_days = (max(commit_dates) - min(commit_dates)).days + 1

    # Return the average commits per day
    return total_days


def _get_repository_languages_data(
    remote_repo_data: Dict[str, Any], remote_url: Optional[str]
) -> Dict[str, int]:
    """Return repository language line counts from remote metadata or GitHub.

    Args:
        remote_repo_data: Metadata dict from a hosting platform or ecosyste.ms
            mirror. May contain a ``languages_lines``, ``languages_loc``, or
            ``languages`` key with per-language counts.
        remote_url: Remote URL of the repository, used to fall back to the
            GitHub languages API when hosting metadata is unavailable.

    Returns:
        A dictionary mapping programming language name to an approximate line
        or byte count. Returns an empty dict if no language data is found.
    """
    languages_data: Dict[str, int] = {}

    # Prefer ecosyste.ms or hosting metadata if it already exposes language stats.
    if remote_repo_data:
        for key in ("languages_lines", "languages_loc", "languages"):
            value = remote_repo_data.get(key)
            if isinstance(value, dict) and value:
                languages_data = {
                    str(lang): int(count) if count is not None else 0
                    for lang, count in value.items()
                    if isinstance(lang, str)
                }
                break

    # Fallback to GitHub languages API when the repository is hosted on GitHub.
    if not languages_data and remote_url:
        parsed = urlparse(remote_url)
        if parsed.netloc == "github.com":
            parts = parsed.path.strip("/").split("/")
            if len(parts) >= 2:  # noqa: PLR2004
                owner, repo_name = parts[0], parts[1]
                github_languages = get_api_data(
                    api_endpoint=f"https://api.github.com/repos/{owner}/{repo_name}/languages"
                )
                if isinstance(github_languages, dict) and github_languages:
                    languages_data = {
                        str(lang): int(count) if count is not None else 0
                        for lang, count in github_languages.items()
                        if isinstance(lang, str)
                    }

    return languages_data


def _get_software_description(
    repo: pygit2.Repository,
    remote_repo_data: Optional[Dict[str, Any]],
    readme_exists: bool,
    readme_file: Optional[pygit2.Object],
) -> Optional[str]:
    """Return the best available plain-text description for the repository.

    The function uses the following priority order:
    1. The description field from remote hosting metadata (e.g. ecosyste.ms / GitHub).
    2. The ``abstract`` field from a ``CITATION.cff`` file in the repo root.
    3. The first non-badge paragraph of the README.

    Returns the first non-empty candidate, or ``None`` if the repo doesn't include any priority entry.

    Args:
        repo: The pygit2 Repository to read local files from.
        remote_repo_data: Metadata dict from a hosting platform or
            ecosyste.ms mirror, used to retrieve the remote description field.
        readme_exists: Whether a README file was detected in the repository.
        readme_file: The pygit2 object for the README file, used to read its
            contents when falling back to the first paragraph.

    Returns:
        The best available plain-text description string, or ``None`` if no
        description could be found.
    """
    candidates: list[str] = []

    # Prefer remote host description when available.
    remote_description = (
        remote_repo_data.get("description") if remote_repo_data else None
    )
    if isinstance(remote_description, str) and remote_description.strip():
        candidates.append(remote_description.strip())

    # CITATION.cff abstract from repository root (if present).
    citation_text = read_file(
        repo=repo, filepath="CITATION.cff", case_insensitive=False
    )
    if citation_text:
        try:
            citation_data = yaml.safe_load(citation_text) or {}
            abstract = citation_data.get("abstract")
            if isinstance(abstract, str) and abstract.strip():
                candidates.append(abstract.strip())
        except yaml.YAMLError:
            LOGGER.debug(
                "Unable to parse CITATION.cff when deriving software description."
            )

    # README first paragraph as a fallback.
    if readme_exists:
        readme_content = read_file(repo=repo, entry=readme_file)
        if isinstance(readme_content, str):
            for block in readme_content.split("\n\n"):
                candidate = block.strip()
                if candidate:
                    # Avoid capturing badges / pure markdown image blocks;
                    # keep scanning paragraphs until a real one is found.
                    if not candidate.lower().startswith("!["):
                        candidates.append(candidate)
                        break

    return next((c for c in candidates if c), None)


def _get_pyproject_python_version(content: str) -> Optional[str]:
    """Return the declared Python version constraint from a pyproject.toml string.

    Checks ``tool.poetry.dependencies.python`` first (Poetry convention), then
    ``project.requires-python`` (PEP 621). Returns ``None`` if neither is present
    or if the TOML cannot be parsed.

    Args:
        content: The raw string contents of a ``pyproject.toml`` file.

    Returns:
        The Python version constraint string (for example, ``">=3.9"``), or
        ``None`` if no constraint is declared or the file cannot be parsed.
    """
    try:
        pyproject = tomllib.loads(content)
    except tomllib.TOMLDecodeError:
        LOGGER.debug(
            "Unable to parse pyproject.toml for Python version.", exc_info=True
        )
        return None

    poetry_python = (
        pyproject.get("tool", {})
        .get("poetry", {})
        .get("dependencies", {})
        .get("python")
    )
    if isinstance(poetry_python, str) and poetry_python.strip():
        return poetry_python.strip()

    requires_python = pyproject.get("project", {}).get("requires-python")
    if isinstance(requires_python, str) and requires_python.strip():
        return requires_python.strip()

    return None


def _get_conda_python_version(content: str) -> Optional[str]:
    """Return the Python version constraint declared in a conda environment YAML string.

    Scans the ``dependencies`` list for an entry that starts with ``python``
    followed by a version separator (``=``, ``>``, ``<``, ``~``).
    Returns ``None`` if no Python dependency is found or the YAML cannot be parsed.

    Args:
        content: The raw string contents of a conda ``environment.yml`` file.

    Returns:
        The Python version string extracted from the dependency entry (for
        example, ``"3.11"``), or ``None`` if no Python version is declared or
        the file cannot be parsed.
    """
    try:
        env_data = yaml.safe_load(content)
    except yaml.YAMLError:
        LOGGER.debug(
            "Unable to parse conda environment YAML for Python version.", exc_info=True
        )
        return None

    if not isinstance(env_data, dict):
        return None

    for dep in env_data.get("dependencies", []):
        if isinstance(dep, str) and dep.startswith("python"):
            for sep in ("=", ">", "<", "~"):
                if sep in dep:
                    v = dep.split(sep, 1)[1].strip()
                    if v:
                        return v
                    break
    return None


def is_conda_environment_yaml(content: str) -> bool:
    """Return True if *content* looks like a conda environment YAML file.

    A conda environment file is a YAML document whose top-level value is a
    mapping that contains at least one of the keys ``name``, ``channels``, or
    ``dependencies``. This heuristic avoids false positives from arbitrary YAML
    files that happen to share the same filename.

    Args:
        content: The raw string contents of a YAML file to inspect.

    Returns:
        ``True`` if the content matches the conda environment YAML heuristic,
        ``False`` otherwise.
    """
    try:
        data = yaml.safe_load(content)
    except yaml.YAMLError:
        return False
    if not isinstance(data, dict):
        return False
    return bool({"name", "channels", "dependencies"} & data.keys())


def _parse_setup_py_console_scripts(content: str) -> set[str]:  # noqa: C901, PLR0912
    """Extract console_scripts command names from a setup.py string.

    Parses the content as a Python Abstract Syntax Tree (AST) and looks for a
    ``setup()`` or ``setuptools.setup()`` call whose ``entry_points`` keyword
    argument contains a ``console_scripts`` list. Returns an empty set if the
    file cannot be parsed or contains no matching entries.

    Args:
        content: The raw string contents of a ``setup.py`` file.

    Returns:
        A set of CLI command names declared in ``console_scripts``. Returns an
        empty set if none are found or the file cannot be parsed.
    """
    try:
        tree = ast.parse(content)
    except (SyntaxError, MemoryError, RecursionError, Exception):
        LOGGER.debug("Unable to parse setup.py for CLI entrypoints.", exc_info=True)
        return set()

    commands: set[str] = set()
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        func = node.func
        is_setup_call = (isinstance(func, ast.Name) and func.id == "setup") or (
            isinstance(func, ast.Attribute) and func.attr == "setup"
        )
        if not is_setup_call:
            continue
        for keyword in node.keywords:
            if keyword.arg != "entry_points":
                continue
            if not isinstance(keyword.value, ast.Dict):
                continue
            for key, value in zip(keyword.value.keys, keyword.value.values):
                if (
                    isinstance(key, ast.Constant)
                    and key.value == "console_scripts"
                    and isinstance(value, ast.List)
                ):
                    for elt in value.elts:
                        if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
                            # Expect entries like "name = pkg.mod:func"
                            if "=" in elt.value:
                                cmd = elt.value.split("=", 1)[0].strip()
                                if cmd:
                                    commands.add(cmd)
    return commands


def _get_cli_entrypoints(repo: pygit2.Repository) -> List[str]:  # noqa: C901
    """Return sorted CLI entrypoint names discovered from pyproject.toml, setup.cfg, and setup.py.

    Args:
        repo: The pygit2 Repository to read packaging files from.

    Returns:
        A sorted list of unique CLI command names. Returns an empty list if no
        entrypoints are found.
    """
    discovered_commands: set[str] = set()

    # Inspect pyproject.toml for [project.scripts] and [tool.poetry.scripts]
    pyproject_content = read_file(
        repo=repo, filepath="pyproject.toml", case_insensitive=False
    )
    if isinstance(pyproject_content, str):
        try:
            pyproject = tomllib.loads(pyproject_content)
            for scripts_table in (
                pyproject.get("project", {}).get("scripts", {}),
                pyproject.get("tool", {}).get("poetry", {}).get("scripts", {}),
            ):
                discovered_commands.update(scripts_table.keys())
        except tomllib.TOMLDecodeError:
            LOGGER.debug(
                "Unable to parse pyproject.toml for CLI entrypoints.", exc_info=True
            )

    # Inspect setup.cfg for console_scripts entry points
    setup_cfg_content = read_file(
        repo=repo, filepath="setup.cfg", case_insensitive=False
    )
    if isinstance(setup_cfg_content, str):
        parser = configparser.ConfigParser()
        try:
            parser.read_string(setup_cfg_content)
            if parser.has_section("options.entry_points") and parser.has_option(
                "options.entry_points", "console_scripts"
            ):
                entries = parser.get("options.entry_points", "console_scripts")
                for raw_entry in entries.splitlines():
                    entry = raw_entry.strip()
                    if not entry or entry.startswith("#"):
                        continue
                    # Expect lines like: name = package.module:func
                    if "=" in entry:
                        name_part, _target_part = entry.split("=", 1)
                        cmd = name_part.strip()
                        if cmd:
                            discovered_commands.add(cmd)
        except (configparser.Error, ValueError):
            # If parsing fails, we silently ignore setup.cfg-based detection.
            LOGGER.debug(
                "Unable to parse setup.cfg for CLI entrypoints.", exc_info=True
            )

    # Inspect setup.py entry_points for console_scripts
    setup_py_content = read_file(repo=repo, filepath="setup.py", case_insensitive=False)
    if isinstance(setup_py_content, str):
        discovered_commands.update(_parse_setup_py_console_scripts(setup_py_content))

    return sorted(discovered_commands)


def _get_python_environment_data(  # noqa: C901, PLR0912
    repo: pygit2.Repository,
) -> Dict[str, Any]:
    """Detect Python environment and dependency management tools in the repository.

    Scans common Python packaging and environment configuration files to
    identify which tools are in use and what Python versions are declared.
    This function is intended for Python-primary repositories; call sites
    should guard invocation with a primary-language check.

    Args:
        repo: The pygit2 Repository to scan for environment configuration files.

    Returns:
        A dictionary with the following keys:

        - ``environment_managers``: sorted list of detected environment manager
          names (for example, ``["conda", "poetry"]``), or ``None`` if none
          are found.
        - ``has_managed_environment``: ``True`` if at least one environment
          manager is detected, ``False`` otherwise.
        - ``dependency_managers``: sorted list of detected dependency manager
          names (for example, ``["pip"]``), or ``None`` if none are found.
        - ``has_declared_dependencies``: ``True`` if at least one dependency
          manager is detected, ``False`` otherwise.
        - ``declared_python_versions``: sorted list of declared Python version
          strings, or ``None`` if none are found.
    """
    managers: set[str] = set()
    dep_managers: set[str] = set()
    py_versions: set[str] = set()

    # Detect Poetry / generic pyproject-managed environments and Python version.
    pyproject_content = read_file(
        repo=repo, filepath="pyproject.toml", case_insensitive=False
    )
    if isinstance(pyproject_content, str):
        # Always record that a pyproject-based configuration exists.
        managers.add("pyproject")
        try:
            pyproject = tomllib.loads(pyproject_content)
            if "poetry" in pyproject.get("tool", {}):
                managers.add("poetry")
        except tomllib.TOMLDecodeError:
            LOGGER.debug(
                "Unable to parse pyproject.toml for environment managers.",
                exc_info=True,
            )
        pyproject_python_ver = _get_pyproject_python_version(pyproject_content)
        if pyproject_python_ver:
            py_versions.add(pyproject_python_ver)

    # Detect Conda environment files and Python version.
    # Conda env files can use several common names; try each in turn.
    _conda_candidate_names = (
        "environment.yml",
        "environment.yaml",
        "conda.yml",
        "conda.yaml",
        "conda-environment.yml",
        "conda-environment.yaml",
    )
    for _conda_fname in _conda_candidate_names:
        env_yml = read_file(repo=repo, filepath=_conda_fname, case_insensitive=True)
        if not isinstance(env_yml, str) or not is_conda_environment_yaml(env_yml):
            continue
        managers.add("conda")
        conda_python_ver = _get_conda_python_version(env_yml)
        if conda_python_ver:
            py_versions.add(conda_python_ver)
        break  # stop after the first valid conda env file is found

    # Detect Pipenv.
    pipfile_content = read_file(repo=repo, filepath="Pipfile", case_insensitive=False)
    if isinstance(pipfile_content, str):
        managers.add("pipenv")

    # Detect pip-based dependency management (requirements files).
    if read_file(repo=repo, filepath="requirements.txt", case_insensitive=True):
        dep_managers.add("pip")

    # Detect setup.py / setup.cfg install_requires as dependency management.
    if read_file(repo=repo, filepath="setup.py", case_insensitive=False):
        dep_managers.add("pip")
    if read_file(repo=repo, filepath="setup.cfg", case_insensitive=False):
        dep_managers.add("pip")

    # Detect Nix.
    if read_file(repo=repo, filepath="flake.nix", case_insensitive=False):
        managers.add("nix")
    if read_file(repo=repo, filepath="default.nix", case_insensitive=False):
        managers.add("nix")

    # Detect Python runtime hints (Heroku-style runtime.txt).
    runtime_txt = read_file(repo=repo, filepath="runtime.txt", case_insensitive=False)
    if isinstance(runtime_txt, str):
        for raw_line in runtime_txt.splitlines():
            line = raw_line.strip()
            if line.lower().startswith("python-"):
                v = line.split("-", 1)[1].strip()
                if v:
                    py_versions.add(v)

    return {
        "environment_managers": sorted(managers) if managers else None,
        "has_managed_environment": bool(managers),
        "dependency_managers": sorted(dep_managers) if dep_managers else None,
        "has_declared_dependencies": bool(dep_managers),
        "declared_python_versions": sorted(py_versions) if py_versions else None,
    }


def compute_repo_data(  # noqa: C901, PLR0912, PLR0915
    repo_path: str,
    exclude_paths: Optional[List[str]] = None,
    required_metric_names: Optional[set[str]] = None,
) -> Dict[str, Any]:
    """Compute comprehensive data for a GitHub repository.

    Args:
        repo_path: The local path to the Git repository.
        exclude_paths: Repository-relative paths or glob patterns to exclude from checks.
        required_metric_names: Optional set of metric names that will be used by
            downstream reporting. If provided, expensive data-collection blocks
            are skipped unless one of their dependent metrics is requested.

    Returns:
        A dictionary containing data key-pairs.
    """

    exclude_paths = _normalize_exclude_paths(exclude_paths)

    # Check if we need to download the repo because it's a link
    if str(repo_path).startswith("http"):
        # Clone the repository to a temporary directory
        repo_path = clone_repository(repo_path)

    # Convert repo_path to an absolute path and initialize the repository
    repo_path = pathlib.Path(repo_path).resolve()
    repo = pygit2.Repository(str(repo_path))

    def needs(*metric_names: str) -> bool:
        """Return whether any of the given metric names should be collected."""
        if required_metric_names is None:
            return True
        return any(metric_name in required_metric_names for metric_name in metric_names)

    remote_url = get_remote_url(repo=repo)

    remote_repo_data: Dict[str, Any] = {}
    if needs(
        "repo-primary-language",
        "repo-primary-license",
        "repo-stargazers-count",
        "repo-uses-issues",
        "repo-issues-open-count",
        "repo-pull-requests-enabled",
        "repo-forks-count",
        "repo-subscribers-count",
        "repo-software-description",
        "repo-docs-homepage-url",
        "repo-source-code-url",
        "repo-issue-tracker-url",
        "repo-topics",
        "repo-topics-count",
        "repo-cost-model",
    ):
        # gather data from ecosystems repo api
        remote_repo_data = get_api_data(
            params={"url": remote_url} if remote_url is not None else None
        )

    gh_workflows_data: Dict[str, Any] = {}
    if needs(
        "repo-gh-workflow-success-ratio",
        "repo-gh-workflow-succeeding-runs",
        "repo-gh-workflow-failing-runs",
        "repo-gh-workflow-queried-total",
    ):
        # gather data from github repo workflows api
        gh_workflows_data = get_github_build_metrics(
            repo_url=remote_url, branch=repo.head.shorthand, max_runs=100
        )

    code_coverage: Dict[str, Any] = {}
    if needs(
        "repo-code-coverage-percent",
        "repo-date-of-last-coverage-run",
        "repo-days-between-last-coverage-run-latest-commit",
        "repo-code-coverage-total-lines",
        "repo-code-coverage-executed-lines",
    ):
        if not remote_repo_data:
            remote_repo_data = get_api_data(
                params={"url": remote_url} if remote_url is not None else None
            )
        # gather data on code coverage
        code_coverage = measure_coverage(
            repo=repo, primary_language=remote_repo_data.get("language", None)
        )

    packages_data: Dict[str, Any] = {}
    if needs(
        "repo-packages-ecosystems",
        "repo-packages-ecosystems-count",
        "repo-packages-versions-count",
    ):
        # gather data from ecosystems packages api
        packages_data = get_ecosystems_package_metrics(repo_url=remote_url)

    # Retrieve the list of commits from the repository
    commits = get_commits(repo)
    most_recent_commit = commits[0]
    first_commit = commits[-1]

    normalized_total_entropy = None
    file_entropy = None
    file_history_complexity_decay = None
    aggregate_history_complexity_decay = None
    if needs(
        "repo-agg-info-entropy",
        "repo-file-info-entropy",
        "repo-agg-history-complexity-decay",
        "repo-file-history-complexity-decay",
    ):
        # Get a list of files that have been edited between the first and most recent commit
        edited_file_names = get_edited_files(repo, first_commit, most_recent_commit)

        if needs("repo-agg-info-entropy"):
            # Calculate the normalized total entropy for the repository
            normalized_total_entropy = calculate_aggregate_entropy(
                repo_path,
                str(first_commit.id),
                str(most_recent_commit.id),
                edited_file_names,
            )

        if needs("repo-file-info-entropy"):
            # Calculate the normalized entropy for the changes between the first and most recent commits
            file_entropy = calculate_normalized_entropy(
                repo_path,
                str(first_commit.id),
                str(most_recent_commit.id),
                edited_file_names,
            )

        if needs(
            "repo-agg-history-complexity-decay", "repo-file-history-complexity-decay"
        ):
            file_history_complexity_decay = calculate_history_complexity_with_decay(
                repo_path,
                str(first_commit.id),
                str(most_recent_commit.id),
                edited_file_names,
            )
            aggregate_history_complexity_decay = (
                sum(file_history_complexity_decay.values()) / len(edited_file_names)
                if edited_file_names
                else 0.0
            )
    # Convert commit times to UTC datetime objects, then format as date strings.
    first_commit_date, most_recent_commit_date = (
        datetime.fromtimestamp(commit.commit_time).date()
        for commit in (first_commit, most_recent_commit)
    )

    # date of last code coverage run
    date_of_last_coverage_run = code_coverage.get("date_of_last_coverage_run", None)
    readme_file = find_file(repo=repo, filepath="readme", case_insensitive=True)
    readme_exists = True if readme_file is not None else False

    language_line_counts: Optional[Dict[str, int]] = None
    language_total_lines: Optional[int] = None
    language_count: Optional[int] = None
    noncode_extension_line_counts: Optional[Dict[str, int]] = None
    noncode_total_lines: Optional[int] = None
    noncode_extensions_count: Optional[int] = None
    has_cli: Optional[bool] = None
    cli_entrypoints: Optional[List[str]] = None
    topics: Optional[List[str]] = None
    topics_count: Optional[int] = None
    cost_model: Optional[str] = None
    environment_managers: Optional[List[str]] = None
    has_managed_environment: Optional[bool] = None
    dependency_managers: Optional[List[str]] = None
    has_declared_dependencies: Optional[bool] = None
    declared_python_versions: Optional[List[str]] = None
    has_edam_owl: Optional[bool] = None
    has_biotools_json: Optional[bool] = None
    has_codemeta_json: Optional[bool] = None
    has_ro_crate_metadata_json: Optional[bool] = None
    software_metadata_files_count: Optional[int] = None

    if needs(
        "repo-languages-line-counts",
        "repo-languages-total-lines",
        "repo-languages-count",
    ):
        languages_data = _get_repository_languages_data(
            remote_repo_data=remote_repo_data,
            remote_url=remote_url,
        )

        if languages_data:
            language_line_counts = languages_data
            language_total_lines = int(sum(languages_data.values()))
            language_count = len(languages_data)

    if needs(
        "repo-noncode-extension-line-counts",
        "repo-noncode-total-lines",
        "repo-noncode-extensions-count",
    ):
        root_tree = repo.head.peel().tree
        ext_counts = _walk_tree_measure_size_of_noncode_files(root_tree, repo=repo)
        if ext_counts:
            noncode_extension_line_counts = ext_counts
            noncode_total_lines = int(sum(ext_counts.values()))
            noncode_extensions_count = len(ext_counts)

    if needs("repo-has-cli", "repo-cli-entrypoints"):
        discovered = _get_cli_entrypoints(repo=repo)
        if discovered:
            cli_entrypoints = discovered
            has_cli = True
        else:
            has_cli = False

    if needs("repo-topics", "repo-topics-count"):
        remote_topics: List[str] = []
        if remote_repo_data:
            # ecosyste.ms and GitHub both commonly expose a 'topics' field.
            raw_topics = remote_repo_data.get("topics")
            if isinstance(raw_topics, list):
                remote_topics = [
                    str(t).strip()
                    for t in raw_topics
                    if isinstance(t, str) and str(t).strip()
                ]
        if remote_topics:
            topics = sorted(set(remote_topics))
            topics_count = len(topics)

    if needs("repo-cost-model"):
        if remote_repo_data:
            raw_cost = (
                remote_repo_data.get("cost_model")
                or remote_repo_data.get("pricing_model")
                or remote_repo_data.get("pricing")
            )
            if isinstance(raw_cost, str) and raw_cost.strip():
                cost_model = raw_cost.strip()
            elif isinstance(raw_cost, list) and raw_cost:
                first = raw_cost[0]
                if isinstance(first, str) and first.strip():
                    cost_model = first.strip()
            elif isinstance(raw_cost, dict):
                # Prefer explicit "model" key if present.
                model_val = raw_cost.get("model")
                if isinstance(model_val, str) and model_val.strip():
                    cost_model = model_val.strip()

    if needs(
        "repo-environment-managers",
        "repo-has-managed-environment",
        "repo-dependency-managers",
        "repo-has-declared-dependencies",
        "repo-declared-python-versions",
    ):
        primary_language = remote_repo_data.get("language", None)
        # Only run Python-specific environment detection for Python repos.
        # When primary language is unknown (None), run detection by default.
        if primary_language is None or primary_language.lower() == "python":
            env_data = _get_python_environment_data(repo=repo)
            environment_managers = env_data["environment_managers"]
            has_managed_environment = env_data["has_managed_environment"]
            dependency_managers = env_data["dependency_managers"]
            has_declared_dependencies = env_data["has_declared_dependencies"]
            declared_python_versions = env_data["declared_python_versions"]

    if needs(
        "repo-has-edam-owl",
        "repo-has-biotools-json",
        "repo-has-codemeta-json",
        "repo-has-ro-crate-metadata-json",
        "repo-software-metadata-files-count",
    ):
        has_edam_owl = bool(
            find_file(repo=repo, filepath="edam.owl", case_insensitive=False)
            is not None
        )
        has_biotools_json = bool(
            find_file(repo=repo, filepath="biotools.json", case_insensitive=False)
            is not None
        )
        has_codemeta_json = bool(
            find_file(repo=repo, filepath="codemeta.json", case_insensitive=False)
            is not None
        )
        has_ro_crate_metadata_json = bool(
            find_file(
                repo=repo,
                filepath="ro-crate-metadata.json",
                case_insensitive=False,
            )
            is not None
        )

        software_metadata_files_count = int(
            sum(
                int(flag)
                for flag in (
                    has_edam_owl,
                    has_biotools_json,
                    has_codemeta_json,
                    has_ro_crate_metadata_json,
                )
                if flag is not None
            )
        )

    software_description: Optional[str] = None
    docs_homepage_url: Optional[str] = None
    source_code_url: Optional[str] = None
    issue_tracker_url: Optional[str] = None

    if needs(
        "repo-software-description",
        "repo-docs-homepage-url",
        "repo-source-code-url",
        "repo-issue-tracker-url",
    ):
        software_description = _get_software_description(
            repo=repo,
            remote_repo_data=remote_repo_data,
            readme_exists=readme_exists,
            readme_file=readme_file,
        )

        # documentation / homepage URL from remote metadata where possible
        if remote_repo_data:
            for key in ("homepage", "homepage_url", "website"):
                value = remote_repo_data.get(key)
                if isinstance(value, str) and value.strip():
                    docs_homepage_url = value.strip()
                    break

            # source code URL: prefer HTML URL, then generic URL, then remote_url
            for key in ("html_url", "url"):
                value = remote_repo_data.get(key)
                if isinstance(value, str) and value.strip():
                    source_code_url = value.strip()
                    break

        if source_code_url is None:
            source_code_url = remote_url

        # issue tracker URL: use API metadata when present, otherwise infer common patterns
        if remote_repo_data:
            issues_value = remote_repo_data.get("issues_url")
            if isinstance(issues_value, str) and issues_value.strip():
                issue_tracker_url = issues_value.strip()

        if issue_tracker_url is None and remote_url:
            parsed = urlparse(remote_url)
            netloc = parsed.netloc.lower()
            base = remote_url.rstrip("/")
            if "github.com" in netloc:
                issue_tracker_url = f"{base}/issues"
            elif "gitlab.com" in netloc:
                issue_tracker_url = f"{base}/-/issues"
            elif "gitea.com" in netloc:
                issue_tracker_url = f"{base}/issues"

    social_media_metrics: Dict[str, Any] = {}
    if needs("repo-social-media-platforms", "repo-social-media-platforms-count"):
        # gather social media metrics
        social_media_metrics = (
            detect_social_media_links(content=read_file(repo=repo, entry=readme_file))
            if readme_exists
            else {}
        )

    doi_citation_data: Dict[str, Any] = {
        "doi": None,
        "openalex_work_id": None,
        "publication_date": None,
        "valid_format_doi": None,
        "https_resolvable_doi": None,
        "cited_by_count": None,
        "fwci": None,
        "is_not_retracted": None,
        "funding_records_count": None,
        "funding_records": None,
        "funding_amount_usd_total": None,
        "funding_sources_count": None,
        "unique_funders_count": None,
        "unique_funders": None,
    }
    if needs(
        "repo-doi",
        "repo-doi-publication-date",
        "repo-doi-valid-format",
        "repo-doi-https-resolvable",
        "repo-doi-cited-by-count",
        "repo-doi-fwci",
        "repo-doi-is-not-retracted",
        "repo-doi-grants-count",
        "repo-doi-grants",
        "repo-days-between-doi-publication-date-and-latest-commit",
        "repo-funding-details",
        "repo-funding-count",
        "repo-funding-amount-usd",
        "repo-funder-references-count",
        "repo-unique-funders-count",
        "repo-funding-details-of-citing-works",
        "repo-funding-count-of-citing-works",
        "repo-funding-amount-usd-of-citing-works",
        "repo-funder-references-count-of-citing-works",
        "repo-unique-funders-count-of-citing-works",
        "repo-funding-amount-usd-combined",
        "repo-funder-references-count-combined",
        "repo-unique-funders-count-combined",
    ):
        # gather doi citation data
        doi_citation_data = find_doi_citation_data(repo=repo)

    openalex_doi_work_funding: Dict[str, Any] = {
        "doi": doi_citation_data["doi"],
        "source_work_id": doi_citation_data["openalex_work_id"],
        "doi_work_funding_records_count": doi_citation_data["funding_records_count"],
        "doi_work_funding_amount_usd_total": doi_citation_data[
            "funding_amount_usd_total"
        ],
        "doi_work_funding_sources_count": doi_citation_data["funding_sources_count"],
        "doi_work_unique_funders_count": doi_citation_data["unique_funders_count"],
        "doi_work_unique_funders": doi_citation_data["unique_funders"],
        "funding_records": doi_citation_data["funding_records"],
    }
    openalex_citing_works_funding: Dict[str, Any] = {
        "source_work_id": doi_citation_data["openalex_work_id"],
        "citing_works_count_total": None,
        "citing_works_count_sampled": None,
        "citing_works_with_funding_count": None,
        "citing_works_funding_records_count_sampled": None,
        "citing_works_funding_amount_usd_total_sampled": None,
        "citing_works_funding_sources_count_sampled": None,
        "citing_works_unique_funders_count_sampled": None,
        "citing_works_unique_funders_sampled": None,
        "sample_limit": None,
        "references": None,
    }
    if needs(
        "repo-funding-details-of-citing-works",
        "repo-funding-count-of-citing-works",
        "repo-funding-amount-usd-of-citing-works",
        "repo-funder-references-count-of-citing-works",
        "repo-unique-funders-count-of-citing-works",
        "repo-funding-amount-usd-combined",
        "repo-funder-references-count-combined",
        "repo-unique-funders-count-combined",
    ):
        openalex_citing_works_funding = find_openalex_citing_works_funding(
            openalex_work_id=doi_citation_data["openalex_work_id"],
        )

    software_mentions_openalex: Dict[str, Any] = {
        "query": None,
        "mentions_count": None,
        "references": None,
    }
    if needs(
        "repo-software-mentions-count",
        "repo-software-mentions",
    ):
        software_mentions_openalex = find_software_mentions_openalex(
            repo=repo,
            remote_url=remote_url,
        )

    # collect notebook cell data
    ignore_dirs = [".venv"]

    failed_notebook_exec_order = []
    notebook_exec_order_checked = False
    if needs("repo-check-notebook-exec-order"):
        notebook_exec_order_checked = True
        notebook_cells = get_nb_contents(
            repo_path=repo_path,
            ignore_dirs=ignore_dirs,
            ignore_paths=exclude_paths,
        )
        for notebook_path, cells in notebook_cells.items():
            if check_ipynb_code_exec_order(nb_cells=cells):
                continue
            try:
                failed_notebook_exec_order.append(
                    str(notebook_path.relative_to(repo_path))
                )
            except ValueError:
                failed_notebook_exec_order.append(str(notebook_path))
        if failed_notebook_exec_order:
            LOGGER.debug(
                "Notebook execution order failures: %s", failed_notebook_exec_order
            )

    # Return the data structure
    return {
        "repo-path": str(repo_path),
        "repo-commits": (commits_count := len(commits)),
        "repo-file-count": count_files(tree=most_recent_commit.tree),
        "repo-commit-time-range": (
            first_commit_date.isoformat(),
            most_recent_commit_date.isoformat(),
        ),
        "repo-days-of-development": (
            days_of_development := (most_recent_commit_date - first_commit_date).days
            + 1
        ),
        "repo-commits-per-day": commits_count / days_of_development,
        "almanack-table-datetime": DATETIME_NOW.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
        "repo-software-description": software_description,
        "repo-docs-homepage-url": docs_homepage_url,
        "repo-source-code-url": source_code_url,
        "repo-issue-tracker-url": issue_tracker_url,
        "repo-languages-line-counts": language_line_counts,
        "repo-languages-total-lines": language_total_lines,
        "repo-languages-count": language_count,
        "repo-noncode-extension-line-counts": noncode_extension_line_counts,
        "repo-noncode-total-lines": noncode_total_lines,
        "repo-noncode-extensions-count": noncode_extensions_count,
        "repo-has-cli": has_cli,
        "repo-cli-entrypoints": cli_entrypoints,
        "repo-topics": topics,
        "repo-topics-count": topics_count,
        "repo-environment-managers": environment_managers,
        "repo-has-managed-environment": has_managed_environment,
        "repo-dependency-managers": dependency_managers,
        "repo-has-declared-dependencies": has_declared_dependencies,
        "repo-declared-python-versions": declared_python_versions,
        "repo-cost-model": cost_model,
        "repo-has-edam-owl": has_edam_owl,
        "repo-has-biotools-json": has_biotools_json,
        "repo-has-codemeta-json": has_codemeta_json,
        "repo-has-ro-crate-metadata-json": has_ro_crate_metadata_json,
        "repo-software-metadata-files-count": software_metadata_files_count,
        "repo-includes-readme": readme_exists,
        "repo-includes-contributing": any(
            [
                file_exists_in_repo(
                    repo=repo,
                    expected_file_name="contributing",
                ),
                file_exists_in_repo(
                    repo=repo, expected_file_name="contributing", subdir=".github"
                ),
            ]
        ),
        "repo-includes-code-of-conduct": file_exists_in_repo(
            repo=repo,
            expected_file_name="code_of_conduct",
        ),
        "repo-includes-license": file_exists_in_repo(
            repo=repo,
            expected_file_name="license",
        ),
        "repo-is-citable": is_citable(repo=repo),
        "repo-default-branch-not-master": default_branch_is_not_master(repo=repo),
        "repo-includes-common-docs": includes_common_docs(repo=repo),
        "almanack-version": _get_almanack_version(),
        "repo-primary-language": remote_repo_data.get("language", None),
        "repo-primary-license": remote_repo_data.get("license", None),
        "repo-doi": doi_citation_data["doi"],
        "repo-doi-publication-date": (
            doi_citation_data["publication_date"].strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            if doi_citation_data["publication_date"] is not None
            else None
        ),
        # placeholders for almanack score metrics
        "repo-almanack-score": None,
        "repo-unique-contributors": count_unique_contributors(repo=repo),
        "repo-unique-contributors-past-year": count_unique_contributors(
            repo=repo, since=(one_year_ago := DATETIME_NOW - timedelta(days=365))
        ),
        "repo-unique-contributors-past-182-days": count_unique_contributors(
            repo=repo, since=(half_year_ago := DATETIME_NOW - timedelta(days=182))
        ),
        "repo-tags-count": count_repo_tags(repo=repo),
        "repo-tags-count-past-year": count_repo_tags(repo=repo, since=one_year_ago),
        "repo-tags-count-past-182-days": count_repo_tags(
            repo=repo, since=half_year_ago
        ),
        "repo-stargazers-count": remote_repo_data.get("stargazers_count", None),
        "repo-uses-issues": remote_repo_data.get("has_issues", None),
        "repo-issues-open-count": remote_repo_data.get("open_issues_count", None),
        "repo-pull-requests-enabled": remote_repo_data.get(
            "pull_requests_enabled", None
        ),
        "repo-forks-count": remote_repo_data.get("forks_count", None),
        "repo-subscribers-count": remote_repo_data.get("subscribers_count", None),
        "repo-packages-ecosystems": packages_data.get("ecosystems_names", None),
        "repo-packages-ecosystems-count": packages_data.get("ecosystems_count", None),
        "repo-packages-versions-count": packages_data.get("versions_count", None),
        "repo-social-media-platforms": social_media_metrics.get(
            "social_media_platforms", None
        ),
        "repo-social-media-platforms-count": social_media_metrics.get(
            "social_media_platforms_count", None
        ),
        "repo-doi-valid-format": doi_citation_data["valid_format_doi"],
        "repo-doi-https-resolvable": doi_citation_data["https_resolvable_doi"],
        "repo-days-between-doi-publication-date-and-latest-commit": (
            (most_recent_commit_date - doi_citation_data["publication_date"]).days
            if doi_citation_data["publication_date"] is not None
            else None
        ),
        "repo-doi-cited-by-count": doi_citation_data["cited_by_count"],
        "repo-doi-fwci": doi_citation_data["fwci"],
        "repo-doi-is-not-retracted": doi_citation_data["is_not_retracted"],
        "repo-doi-grants-count": doi_citation_data["funding_records_count"],
        "repo-doi-grants": doi_citation_data["funding_records"],
        "repo-funding-details": openalex_doi_work_funding,
        "repo-funding-count": openalex_doi_work_funding[
            "doi_work_funding_records_count"
        ],
        "repo-funding-amount-usd": openalex_doi_work_funding[
            "doi_work_funding_amount_usd_total"
        ],
        "repo-funder-references-count": openalex_doi_work_funding[
            "doi_work_funding_sources_count"
        ],
        "repo-unique-funders-count": openalex_doi_work_funding[
            "doi_work_unique_funders_count"
        ],
        "repo-funding-details-of-citing-works": openalex_citing_works_funding,
        "repo-funding-count-of-citing-works": openalex_citing_works_funding[
            "citing_works_funding_records_count_sampled"
        ],
        "repo-funding-amount-usd-of-citing-works": openalex_citing_works_funding[
            "citing_works_funding_amount_usd_total_sampled"
        ],
        "repo-funder-references-count-of-citing-works": openalex_citing_works_funding[
            "citing_works_funding_sources_count_sampled"
        ],
        "repo-unique-funders-count-of-citing-works": openalex_citing_works_funding[
            "citing_works_unique_funders_count_sampled"
        ],
        "repo-funding-amount-usd-combined": (
            openalex_doi_work_funding["doi_work_funding_amount_usd_total"]
            + openalex_citing_works_funding[
                "citing_works_funding_amount_usd_total_sampled"
            ]
            if openalex_doi_work_funding["doi_work_funding_amount_usd_total"]
            is not None
            and openalex_citing_works_funding[
                "citing_works_funding_amount_usd_total_sampled"
            ]
            is not None
            else None
        ),
        "repo-funder-references-count-combined": (
            openalex_doi_work_funding["doi_work_funding_sources_count"]
            + openalex_citing_works_funding[
                "citing_works_funding_sources_count_sampled"
            ]
            if openalex_doi_work_funding["doi_work_funding_sources_count"] is not None
            and openalex_citing_works_funding[
                "citing_works_funding_sources_count_sampled"
            ]
            is not None
            else None
        ),
        "repo-unique-funders-count-combined": (
            len(
                set(openalex_doi_work_funding["doi_work_unique_funders"] or [])
                | set(
                    openalex_citing_works_funding["citing_works_unique_funders_sampled"]
                    or []
                )
            )
            if openalex_doi_work_funding["doi_work_unique_funders"] is not None
            and openalex_citing_works_funding["citing_works_unique_funders_sampled"]
            is not None
            else None
        ),
        "repo-software-mentions-count": software_mentions_openalex["mentions_count"],
        "repo-software-mentions": software_mentions_openalex,
        "repo-gh-workflow-success-ratio": gh_workflows_data.get("success_ratio", None),
        "repo-gh-workflow-succeeding-runs": gh_workflows_data.get("total_runs", None),
        "repo-gh-workflow-failing-runs": gh_workflows_data.get("successful_runs", None),
        "repo-gh-workflow-queried-total": gh_workflows_data.get("failing_runs", None),
        "repo-code-coverage-percent": code_coverage.get("code_coverage_percent", None),
        "repo-date-of-last-coverage-run": (
            date_of_last_coverage_run.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
            if date_of_last_coverage_run is not None
            else None
        ),
        "repo-days-between-last-coverage-run-latest-commit": (
            (most_recent_commit_date - date_of_last_coverage_run).days
            if date_of_last_coverage_run is not None
            else None
        ),
        "repo-code-coverage-total-lines": code_coverage.get("total_lines", None),
        "repo-code-coverage-executed-lines": code_coverage.get("executed_lines", None),
        "repo-agg-info-entropy": normalized_total_entropy,
        "repo-file-info-entropy": file_entropy,
        "repo-agg-history-complexity-decay": aggregate_history_complexity_decay,
        "repo-file-history-complexity-decay": file_history_complexity_decay,
        "repo-check-notebook-dir": repo_dir_exists(
            repo=repo, directory_name="notebooks"
        ),
        "repo-check-notebook-exec-order": (
            (not failed_notebook_exec_order) if notebook_exec_order_checked else None
        ),
    }


def compute_pr_data(repo_path: str, pr_branch: str, main_branch: str) -> Dict[str, Any]:
    """
    Computes entropy data for a PR compared to the main branch.

    Args:
        repo_path (str): The local path to the Git repository.
        pr_branch (str): The branch name for the PR.
        main_branch (str): The branch name for the main branch.

    Returns:
        dict: A dictionary containing the following key-value pairs:
            - "pr_branch": The PR branch being analyzed.
            - "main_branch": The main branch being compared.
            - "total_entropy_introduced": The total entropy introduced by the PR.
            - "number_of_files_changed": The number of files changed in the PR.
            - "entropy_per_file": A dictionary of entropy values for each changed file.
            - "commits": A tuple containing the most recent commits on the PR and main branches.
    """
    try:
        # Convert repo_path to an absolute path and initialize the repository
        repo_path = pathlib.Path(repo_path).resolve()
        repo = pygit2.Repository(str(repo_path))

        # Get the PR and main branch references
        pr_ref = repo.branches.local.get(pr_branch)
        main_ref = repo.branches.local.get(main_branch)

        # Get the most recent commits on each branch
        pr_commit = repo.get(pr_ref.target)
        main_commit = repo.get(main_ref.target)

        # Get the list of files that have been edited between the two commits
        changed_files = get_edited_files(repo, main_commit, pr_commit)

        # Calculate the total entropy introduced by the PR
        total_entropy_introduced = calculate_aggregate_entropy(
            repo_path,
            str(main_commit.id),
            str(pr_commit.id),
            changed_files,
        )

        # Calculate the entropy for each file changed in the PR
        file_entropy = calculate_normalized_entropy(
            repo_path,
            str(main_commit.id),
            str(pr_commit.id),
            changed_files,
        )

        # Convert commit times to UTC datetime objects, then format as date strings
        pr_commit_date = (
            datetime.fromtimestamp(pr_commit.commit_time, tz=timezone.utc)
            .date()
            .isoformat()
        )
        main_commit_date = (
            datetime.fromtimestamp(main_commit.commit_time, tz=timezone.utc)
            .date()
            .isoformat()
        )

        # Return the data structure
        return {
            "pr_branch": pr_branch,
            "main_branch": main_branch,
            "total_entropy_introduced": total_entropy_introduced,
            "number_of_files_changed": len(changed_files),
            "file_level_entropy": file_entropy,
            "commits": (main_commit_date, pr_commit_date),
        }

    except Exception as e:
        # If processing fails, return an informative error
        return {"pr_branch": pr_branch, "main_branch": main_branch, "error": str(e)}


def process_repo_for_analysis(
    repo_url: str,
) -> Tuple[Optional[float], Optional[str], Optional[str], Optional[int]]:
    """
    Processes GitHub repository URL's to calculate entropy and other metadata.
    This is used to prepare data for analysis, particularly for the seedbank notebook
    that process PUBMED repositories.

    Args:
        repo_url (str): The URL of the GitHub repository.

    Returns:
        tuple: A tuple containing the normalized total entropy, the date of the first commit,
               the date of the most recent commit, and the total time of existence in days.
    """
    temp_dir = tempfile.mkdtemp()
    try:
        repo_path = clone_repository(repo_url)
        # Load the cloned repo
        repo = pygit2.Repository(str(repo_path))

        # Retrieve the list of commits from the repo
        commits = get_commits(repo)
        # Select the first and most recent commits from the list
        first_commit = commits[-1]
        most_recent_commit = commits[0]

        # Calculate the time span of existence between the first and most recent commits in days
        time_of_existence = (
            most_recent_commit.commit_time - first_commit.commit_time
        ) // (24 * 3600)
        # Calculate the time span between commits in days. Using UTC for date conversion ensures uniformity
        # and avoids issues related to different time zones and daylight saving changes.
        first_commit_date = (
            datetime.fromtimestamp(first_commit.commit_time, tz=timezone.utc)
            .date()
            .isoformat()
        )
        most_recent_commit_date = (
            datetime.fromtimestamp(most_recent_commit.commit_time, tz=timezone.utc)
            .date()
            .isoformat()
        )
        # Get a list of all files that have been edited between the commits
        file_names = get_edited_files(repo, commits)
        # Calculate the normalized entropy for the changes between the first and most recent commits
        normalized_total_entropy = calculate_aggregate_entropy(
            repo_path, str(first_commit.id), str(most_recent_commit.id), file_names
        )

        return (
            normalized_total_entropy,
            first_commit_date,
            most_recent_commit_date,
            time_of_existence,
        )

    except Exception as e:
        return (
            None,
            None,
            None,
            None,
            f"An error occurred while processing the repository: {e!s}",
        )

    finally:
        shutil.rmtree(temp_dir)


def table_to_wide(table_rows: list[dict]) -> Dict[str, Any]:
    """
    Transpose Almanack table (name->result), compute checks summary, flatten nested.
    `repo-file-info-entropy` and `repo-file-history-complexity-decay` are omitted from
    the wide output because they are large, file-level metrics that are out of scope
    for this flattened summary representation.

    Args:
        table_rows (list[dict]):
            The Almanack metrics table as a list of dictionaries,
            each containing metric metadata and a "result" field.

    Returns:
        dict:
            A flattened dictionary mapping metric names to their results,
            including computed summary fields:
              - "checks_total": total number of sustainability-related checks
              - "checks_passed": number of checks passed
              - "checks_pct": percentage of checks passed

    """
    if not table_rows:
        return {"checks_total": 0, "checks_passed": 0, "checks_pct": None}

    df = pd.DataFrame(table_rows)

    # Dynamic sustainability checks: bool + positive correlation
    mask = (df["result-type"] == "bool") & (df["sustainability_correlation"] == 1)
    checks_total = int(mask.sum())
    checks_passed = int((df.loc[mask, "result"] == True).sum())
    checks_pct = (100.0 * checks_passed / checks_total) if checks_total else None

    # name->result (wide format)
    wide: Dict[str, Any] = (
        df[["name", "result"]]
        .set_index("name")
        .T.reset_index(drop=True)
        .iloc[0]
        .to_dict()
    )

    wide.pop("repo-file-info-entropy", None)
    wide.pop("repo-file-history-complexity-decay", None)

    # Flatten repo-almanack-score if present (avoid nested dict in parquet)
    score = wide.get("repo-almanack-score")
    if isinstance(score, dict):
        wide["repo-almanack-score_json"] = json.dumps(score, default=str)
        del wide["repo-almanack-score"]

    # Preserve nested types by JSON-encoding them for parquet safety
    for key, value in list(wide.items()):
        if isinstance(value, (dict, list)) and not key.endswith("_json"):
            wide[f"{key}_json"] = json.dumps(value, default=str)
            del wide[key]

    # Attach computed check summaries
    wide["checks_total"] = checks_total
    wide["checks_passed"] = checks_passed
    wide["checks_pct"] = checks_pct

    return wide


def process_repo_for_almanack(
    repo_url: str, exclude_paths: Optional[List[str]] = None
) -> Dict[str, Any]:
    """Process a GitHub repository URL into a flat dictionary of Almanack metrics.

    Args:
        repo_url: The GitHub repository URL.
        exclude_paths: Repository-relative paths or glob patterns to exclude from checks.

    Returns:
        Flattened metrics for the repository, including sustainability checks.
        If the processing fails, returns an error entry with the repository URL.
    """
    try:
        # Merge the base repo identifier with flattened metrics in one dictionary
        return {
            **{"Repository URL": repo_url},
            **table_to_wide(get_table(repo_path=repo_url, exclude_paths=exclude_paths)),
        }

    except Exception as e:
        LOGGER.error(f"ERROR processing {repo_url}: {e}")
        return {"Repository URL": repo_url, "almanack_error": str(e)}


def _get_almanack_version() -> str:
    """
    Seeks the current version of almanack using either pkg_resources
    or dunamai to determine the current version being used.

    Returns:
        str
            A string representing the version of almanack currently being used.
    """

    try:
        # attempt to gather the development version from dunamai
        # for scenarios where almanack from source is used.
        dunamai = importlib.import_module("dunamai")

        return dunamai.Version.from_any_vcs().serialize()
    except (RuntimeError, ModuleNotFoundError):
        # else grab a static version from __init__.py
        # for scenarios where the built/packaged almanack is used.
        almanack = importlib.import_module("almanack")

        return almanack.__version__


def get_github_build_metrics(
    repo_url: str,
    branch: str = "main",
    max_runs: int = 100,
    github_api_endpoint: str = "https://api.github.com/repos",
) -> dict:
    """
    Fetches the success ratio of the latest GitHub Actions build runs for a specified branch.

    Args:
        repo_url (str):
            The full URL of the repository
            (e.g., 'https://github.com/software-gardening/almanack').
        branch (str):
            The branch to filter for the workflow runs (default: "main").
        max_runs (int):
            The maximum number of latest workflow runs to analyze.
        github_api_endpoint (str):
            Base API endpoint for GitHub repositories.

    Returns:
        dict:
            The success ratio and details of the analyzed workflow runs.
    """
    # Validate and parse the repository URL
    parsed_url = urlparse(repo_url)
    if parsed_url.netloc != "github.com" or not parsed_url.path:
        return {}

    try:
        # gather the owner and repo name from the parsed url path
        owner, repo_name = parsed_url.path.strip("/").split("/")
    except ValueError:
        return {}

    # Fetch the latest workflow run data using get_api_data
    github_response = get_api_data(
        # Construct the API URL for GitHub Actions runs
        api_endpoint=f"{github_api_endpoint}/{owner}/{repo_name}/actions/runs",
        params={"event": "push", "branch": branch, "per_page": max_runs},
    )

    if github_response.get("workflow_runs"):
        workflow_runs = github_response["workflow_runs"]

        # Count successes and total runs
        total_runs = len(workflow_runs)
        successful_runs = sum(
            1 for run in workflow_runs if run["conclusion"] == "success"
        )

        return {
            "success_ratio": successful_runs / total_runs,
            "total_runs": total_runs,
            "successful_runs": successful_runs,
            "failing_runs": total_runs - successful_runs,
        }

    # else we return an empty dictionary
    return {}


def measure_coverage(
    repo: pygit2.Repository, primary_language: Optional[str]
) -> Optional[Dict[str, Any]]:
    """
    Measures code coverage for a given repository.

    Args:
        repo (pygit2.Repository):
            The pygit2 repository object to analyze.
        primary_language (Optional[str]):
            The primary programming language of the repository.

    Returns:
        Optional[dict[str,Any]]:
            Code coverage data or an empty dictionary if unable
            to find code coverage data.
    """

    if primary_language is None:
        return {}

    if primary_language.lower() == "python":
        return parse_python_coverage_data(repo)

    return {}


def parse_python_coverage_data(
    repo: pygit2.Repository,
) -> Optional[Dict[str, Any]]:
    """
    Parses coverage.py data from recognized formats such as JSON, XML, or LCOV.
    See here for more information:
    https://coverage.readthedocs.io/en/latest/cmd.html#cmd-report

    Args:
        repo (pygit2.Repository):
            The pygit2 repository object containing code.

    Returns:
        Optional[Dict[str, Any]]:
            A dictionary with standardized code coverage data or an
            empty dict if no data is found.
    """
    coverage_files = [
        "coverage.json",
        "coverage.xml",
        "coverage.lcov",
    ]
    for coverage_file in coverage_files:
        if (
            coverage_object := find_file(repo=repo, filepath=coverage_file)
        ) is not None:
            file_path = f"{repo.workdir}/{coverage_object.name}"
            total_lines = executed_lines = 0
            timestamp = None

            try:
                if coverage_file.endswith(".json"):
                    # Parse JSON coverage data
                    with open(file_path, "r") as f:
                        coverage_data = json.load(f)

                    # Use the `summary` key directly
                    summary = coverage_data.get("summary", {})
                    total_lines = summary.get("num_statements", 0)
                    executed_lines = summary.get("covered_lines", 0)
                    coverage_percentage = summary.get("percent_covered", 0.0)

                    # Retrieve the timestamp
                    timestamp = datetime.fromisoformat(
                        coverage_data["meta"]["timestamp"]
                    )

                elif coverage_file.endswith(".xml"):
                    # Parse XML coverage data (using defusedxml for safely parsing xml)
                    tree = ET.parse(file_path)
                    root = tree.getroot()

                    # Extract the total lines and executed lines directly from the root element
                    total_lines = int(root.attrib.get("lines-valid", 0))
                    executed_lines = int(root.attrib.get("lines-covered", 0))

                    # parse timestamp in XML attributes
                    timestamp_str = root.attrib.get("timestamp")
                    timestamp = datetime.fromtimestamp(
                        float(timestamp_str)
                        / 1000  # Convert from milliseconds to seconds
                    )

                # lcov files are one of the report types
                # produced by coverage.py
                elif coverage_file.endswith(".lcov"):
                    # Parse LCOV coverage data
                    with open(file_path, "r") as f:
                        for line in f:
                            working_line = line.strip()
                            if working_line.startswith(
                                "DA:"
                            ):  # DA:<line number>,<execution count>
                                _, execution_count = working_line.split(":")[1].split(
                                    ","
                                )
                                total_lines += 1
                                if int(execution_count) > 0:
                                    executed_lines += 1

                    # Determine the latest commit date for the LCOV file
                    timestamp = next(
                        (
                            datetime.fromtimestamp(commit.commit_time)
                            for commit in repo.walk(
                                repo.head.target, pygit2.GIT_SORT_TIME
                            )
                            if coverage_object.name in commit.tree
                        ),
                        None,
                    )

                # Calculate coverage percentage
                coverage_percentage = (
                    (executed_lines / total_lines * 100) if total_lines > 0 else 0.0
                )

                return {
                    "code_coverage_percent": coverage_percentage,
                    "date_of_last_coverage_run": timestamp,
                    "total_lines": total_lines,
                    "executed_lines": executed_lines,
                }

            except Exception as e:
                LOGGER.warning(f"Error reading {coverage_file}: {e}")
                continue

    # No recognized coverage files found
    LOGGER.debug("No coverage.py data found in the repository.")
    return {}


def compute_almanack_score(
    almanack_table: List[Dict[str, Union[int, float, bool]]],
) -> Dict[str, Union[int, float]]:
    """
    Computes an Almanack score by counting boolean Almanack
    table metrics to provide a quick summary of software sustainability.

    Args:
        almanack_table (List[Dict[str, Union[int, float, bool]]]):
            A list of dictionaries containing metrics.
            Each dictionary must have a "result" key
            with a value that is an int, float, or bool.
            A "sustainability_correlation" key is
            included for values to specify the
            relationship to sustainability:
            - 1 (positive correlation)
            - 0 (no correlation)
            - -1 (negative correlation)

    Returns:
        Dict[str, Union[int, float]]:
            Dictionary of length three, including the following:
            1) number of Almanack boolean metrics that passed
            (numerator), 2) number of total Almanack boolean
            metrics considered (denominator), and 3) a score that
            represents how likely the repository will be maintained
            over time based (numerator / denominator).
    """

    bool_results = []

    # Gather boolean Almanack values, contingent on sustainability_correlation
    for item in almanack_table:
        # We translate boolean values into numeric values based on the
        # sustainability_correlation provided from the metrics.yml file.
        # We transform the score based on the following logic:
        # - True with sustainability_correlation 1 = 1
        # - True with sustainability_correlation -1 = 0
        # - False with sustainability_correlation 1 = 0
        # - False with sustainability_correlation -1 = 1
        if item["result-type"] == "bool" and item["sustainability_correlation"] != 0:
            # for sustainability_correlation == 1 we treat True as positive sustainability indicator
            # and False as a negative sustainability indicator.
            # note: bools are a subclass of ints in Python.
            if item["sustainability_correlation"] == 1:
                bool_results.append(
                    int(item["result"]) if item["result"] is not None else 0
                )
            # for sustainability_correlation == -1 we treat True as negative sustainability indicator.
            # and False as a positive sustainability indicator.
            # note: bools are a subclass of ints in Python.
            elif item["sustainability_correlation"] == -1:
                bool_results.append(
                    int(not item["result"]) if item["result"] is not None else 0
                )

    almanack_score_values = {
        # capture numerator and denominator for use alongside the almanack score data
        "almanack-score-numerator": sum(bool_results) if bool_results else None,
        "almanack-score-denominator": len(bool_results) if bool_results else None,
        # Calculate almanack score, normalized to between 0 and 1
        "almanack-score": (
            sum(bool_results) / len(bool_results) if bool_results else None
        ),
    }
    return almanack_score_values
