"""Skills Hub GitHub adapter: API auth, tap providers, and the Contents/Trees source."""

import json
import logging
import subprocess
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import quote

import httpx

from hermes_cli._subprocess_compat import windows_hide_flags
from agent.retry_utils import parse_retry_after_seconds
from tools.skills_guard import TRUSTED_REPOS
from tools.skills_hub_models import (
    SkillBundle, SkillMeta, SkillSource, _cache_metas, _cached_metas, _dedupe_by_trust,
    _hermes_tags, _matches_query, _parse_frontmatter, _referenced_support_paths, hub,
    _validate_bundle_rel_path,
)

logger = logging.getLogger("tools.skills_hub")

# GitHub tap repo (owner/repo) -> provider label used by the docs-site catalog
# (website/scripts/extract-skills.py::GITHUB_TAP_LABELS). The runtime index collapses every tap into
# source="github"; ``extra.provider`` keeps per-tap identity searchable/filterable without disturbing
# dedup / floor / index-skip logic keyed on the bare source id.
GITHUB_TAP_PROVIDERS = {
    "openai/skills": "OpenAI", "anthropics/skills": "Anthropic", "huggingface/skills": "HuggingFace",
    "nvidia/skills": "NVIDIA", "voltagent/awesome-agent-skills": "VoltAgent", "garrytan/gstack": "gstack",
    "minimax-ai/cli": "MiniMax",
}

# Accepted ``--source`` provider filters (lowercased). Not real source ids —
# they narrow merged results to GitHub-tap skills carrying that ``extra.provider``.
_PROVIDER_FILTER_VALUES = frozenset(v.lower() for v in GITHUB_TAP_PROVIDERS.values())

_API = "https://api.github.com/repos"
_ACCEPT_JSON = "application/vnd.github.v3+json"


def github_provider_for(repo: str) -> Optional[str]:
    """Provider label for an ``owner/repo`` tap (case-insensitive), or None."""
    return GITHUB_TAP_PROVIDERS.get(repo.strip().lower()) if repo else None


def _filter_results_by_provider(results: List[SkillMeta], provider: str) -> List[SkillMeta]:
    """Keep only results whose ``extra.provider`` matches ``provider``. An explicit provider filter
    (``--source nvidia``) narrows to exactly that provider — the official catalog is NOT injected the
    way unfiltered browse does."""
    want = provider.strip().lower()
    return [r for r in results if str((r.extra or {}).get("provider", "")).lower() == want]


def _provider_filter_of(source_filter: str) -> str:
    """Normalized provider filter when ``--source`` names one (nvidia/openai/...), else ``""``."""
    value = source_filter.strip().lower()
    return value if value in _PROVIDER_FILTER_VALUES else ""


def _tap_cache_key(repo: str, path: str, bucket: Optional[str] = None) -> str:
    """Disk-cache key for one tap's skill listing (tests seed that cache through it too)."""
    return f"{repo}_{path}_{bucket or ''}".replace("/", "_").replace(" ", "_")


def _is_rate_limit_response(resp: httpx.Response) -> bool:
    """403 with exhausted quota, or any 429."""
    return resp.status_code == 429 or (
        resp.status_code == 403 and resp.headers.get("X-RateLimit-Remaining", "") == "0"
    )


class GitHubAuth:
    """GitHub API authentication, tried in priority order: GITHUB_TOKEN / GH_TOKEN (PAT), `gh auth token`
    (gh CLI), GitHub App JWT + installation token, then unauthenticated (60 req/hr, public repos only)."""

    def __init__(self):
        self._cached_token: Optional[str] = None
        self._cached_method: Optional[str] = None
        self._app_token_expiry: float = 0

    def get_headers(self) -> Dict[str, str]:
        token = self._resolve_token()
        return {"Accept": _ACCEPT_JSON, **({"Authorization": f"token {token}"} if token else {})}

    def is_authenticated(self) -> bool:
        return self._resolve_token() is not None

    def auth_method(self) -> str:
        """'pat', 'gh-cli', 'github-app', or 'anonymous'."""
        self._resolve_token()
        return self._cached_method or "anonymous"

    def _resolve_token(self) -> Optional[str]:
        if self._cached_token and (self._cached_method != "github-app" or time.time() < self._app_token_expiry):
            return self._cached_token
        for method, resolve in (
            ("pat", self._try_pat), ("gh-cli", self._try_gh_cli), ("github-app", self._try_github_app),
        ):
            token = resolve()
            if token:
                self._cached_token, self._cached_method = token, method
                if method == "github-app":
                    self._app_token_expiry = time.time() + 3500  # ~58 min (tokens last 1 hour)
                return token
        self._cached_method = "anonymous"
        return None

    @staticmethod
    def _try_pat() -> Optional[str]:
        # Profile-scoped secret lookup (multiplexed gateway safe).
        from agent.secret_scope import get_secret
        return get_secret("GITHUB_TOKEN") or get_secret("GH_TOKEN")

    def _try_gh_cli(self) -> Optional[str]:
        try:
            result = subprocess.run(
                ["gh", "auth", "token"], capture_output=True, text=True, encoding='utf-8', errors='replace',
                timeout=5, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags(),
            )
            if result.returncode == 0 and result.stdout.strip():
                return result.stdout.strip()
        except (FileNotFoundError, subprocess.TimeoutExpired) as e:
            logger.debug("gh CLI token lookup failed: %s", e)
        return None

    def _try_github_app(self) -> Optional[str]:
        from agent.secret_scope import get_secret
        app_id, key_path = get_secret("GITHUB_APP_ID"), get_secret("GITHUB_APP_PRIVATE_KEY_PATH")
        installation_id = get_secret("GITHUB_APP_INSTALLATION_ID")
        if not all([app_id, key_path, installation_id]):
            return None
        try:
            import jwt  # PyJWT
        except ImportError:
            logger.debug("PyJWT not installed, skipping GitHub App auth")
            return None
        try:
            key_file = Path(key_path)
            if not key_file.exists():
                return None
            now = int(time.time())
            encoded_jwt = jwt.encode(
                {"iat": now - 60, "exp": now + (10 * 60), "iss": app_id},
                key_file.read_text(encoding="utf-8-sig"), algorithm="RS256",
            )
            resp = httpx.post(
                f"https://api.github.com/app/installations/{installation_id}/access_tokens",
                headers={"Authorization": f"Bearer {encoded_jwt}", "Accept": _ACCEPT_JSON}, timeout=10,
            )
            if resp.status_code == 201:
                return resp.json().get("token")
        except Exception as e:
            logger.debug("GitHub App auth failed: %s", e)
        return None


def _split_repo_id(identifier: str) -> Optional[Tuple[str, str]]:
    """``owner/repo/path/to/skill`` -> ``(owner/repo, path/to/skill)``; None when too short."""
    parts = identifier.split("/", 2)
    return (f"{parts[0]}/{parts[1]}", parts[2]) if len(parts) >= 3 else None


def _skill_file_path(skill_path: str, filename: str = "SKILL.md") -> str:
    """Path of ``filename`` inside a skill directory. An empty ``skill_path`` means the skill
    directory IS the repo root — the single-skill layout some skills.sh repos use (``SKILL.md``
    and its ``references/``/``scripts/`` next to ``README.md``)."""
    return f"{skill_path}/{filename}" if skill_path else filename


def _skip_bundle_file(rel_path: str) -> bool:
    """Dotfiles, bytecode and __pycache__ never ship in a bundle."""
    base = rel_path.rsplit("/", 1)[-1]
    return base.startswith(".") or base.endswith(".pyc") or "__pycache__" in rel_path.split("/")


def _tree_members(entries: List[dict], prefix: str):
    """``(rel_path, item_path, is_regular_blob)`` for every git-tree entry under ``prefix``. Symlinks
    (mode 120000) and non-blobs report ``is_regular_blob=False`` so callers can reject a SKILL.md-linked
    symlink instead of silently following it."""
    for item in entries:
        item_path = item.get("path", "")
        if item_path.startswith(prefix):
            yield item_path[len(prefix):], item_path, item.get("type") == "blob" and item.get("mode") != "120000"


class GitHubSource(SkillSource):
    """Fetch skills from GitHub repos via the Contents API."""

    DEFAULT_TAPS = [
        # openai/skills keeps content under skills/.curated/ + skills/.system/; _list_skills_in_repo
        # skips "."/"_" directories, so both entries point at the inner paths.
        {"repo": "openai/skills", "path": "skills/.curated/"},
        {"repo": "openai/skills", "path": "skills/.system/"},
        {"repo": "anthropics/skills", "path": "skills/"},
        {"repo": "huggingface/skills", "path": "skills/"},
        # NVIDIA-verified skills (CUDA-X, NeMo, cuOpt, ...), each with a signed skill.oms.sig
        # + governance card; `trusted` via tools/skills_guard.py::TRUSTED_REPOS.
        {"repo": "NVIDIA/skills", "path": "skills/"},
        {"repo": "garrytan/gstack", "path": ""},
        # --- Science bucket ---
        # Two scientific-skill repos share one hub category via the tap-level "bucket" key so
        # their skills surface together. Both stay `community` trust on purpose (NOT in
        # tools/skills_guard.py::TRUSTED_REPOS): the guard scans every skill and INSTALL_POLICY
        # auto-installs only "safe" ones. Skills wrap third-party tools with their OWN licenses
        # (some GPL; KEGG is commercial for non-academic use) — surfaced per skill, not vetted here.
        # K-Dense-AI/scientific-agent-skills: flat skills/<name>/, MIT.
        {"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/", "bucket": "science"},
        # synthetic-sciences/openscience: Apache-2.0, nested backend/cli/skills/<category>/<name>/.
        # _list_skills_in_repo walks ONE level under a tap path, so each category is its own tap
        # (same one-entry-per-inner-path pattern as openai/skills above).
        *(
            {"repo": "synthetic-sciences/openscience", "path": f"backend/cli/skills/{_cat}/", "bucket": "science"}
            for _cat in (
                "biology", "chemistry", "cloud-compute", "coding", "data-engineering", "databases",
                "document-parsing", "llm-tools", "ml-inference", "ml-training", "other", "physics",
                "quantum", "research", "scholar-evaluation", "visualization", "writing",
            )
        ),
    ]

    SOURCE_ID = "github"
    _parse_frontmatter_quick = staticmethod(_parse_frontmatter)

    def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None):
        self.auth = auth
        self.taps = list(self.DEFAULT_TAPS) + list(extra_taps or [])
        # Per-instance repo -> (default_branch, tree_entries); lives for one
        # search/install flow so repeated tree lookups cost no API calls.
        self._tree_cache: Dict[str, Optional[Tuple[str, List[dict]]]] = {}
        self._tree_revisions: Dict[str, str] = {}
        # repo -> skills.sh.json grouping map; None = fetched, no sidecar.
        self._skillsh_groupings: Dict[str, Optional[Dict[str, str]]] = {}
        self._rate_limited: bool = False

    @property
    def is_rate_limited(self) -> bool:  # whether the GitHub API rate limit was hit during operations
        return self._rate_limited

    def trust_level_for(self, identifier: str) -> str:
        # identifier format: "owner/repo/path/to/skill"
        parts = identifier.split("/", 2)
        return "trusted" if len(parts) >= 2 and f"{parts[0]}/{parts[1]}" in TRUSTED_REPOS else "community"

    def search(self, query: str, limit: int = 10, *, provider_filter: str = "") -> List[SkillMeta]:
        """Substring-match taps, skip taps outside a provider filter, then dedupe by identifier
        preferring higher trust and limit."""
        results: List[SkillMeta] = []
        query_lower = query.lower()
        want = provider_filter.strip().lower()
        for tap in self.taps:
            # The tap repo fixes every result's provider, so wrong-provider taps can be
            # skipped before their enumeration cost (cache reads or GitHub API calls).
            if want and (github_provider_for(tap["repo"]) or "").lower() != want:
                continue
            try:
                for skill in self._list_skills_in_repo(tap["repo"], tap.get("path", ""), tap.get("bucket")):
                    if _matches_query(query_lower, skill.name, skill.description, skill.tags):
                        results.append(skill)
            except Exception as e:
                logger.debug("Failed to search %s: %s", tap['repo'], e)
        return _dedupe_by_trust(results)[:limit]

    def fetch(self, identifier: str) -> Optional[SkillBundle]:
        """Download a skill; identifier format: "owner/repo/path/to/skill-dir"."""
        if (split := _split_repo_id(identifier)) is None:
            return None
        repo, skill_path = split
        skill_dir = skill_path.rstrip("/")
        # Resolve the tree FIRST so every byte fetch — SKILL.md included — is pinned to the
        # same revision; an unpinned /contents fetch floats to HEAD and can serve bytes newer
        # than the tree the paths were validated against (TOCTOU). Idempotent + cached.
        tree = self._get_repo_tree(repo)
        pinned_ref = self._tree_revisions.get(repo)
        skill_md = self._fetch_file_content(repo, _skill_file_path(skill_dir), ref=pinned_ref)
        if skill_md is None:
            return None
        referenced = _referenced_support_paths(skill_md)
        if referenced is None:
            return None
        files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md}
        if tree is not None:
            complete = self._collect_tree_files(repo, skill_dir, tree[1], pinned_ref, referenced, files)
            if complete is None:
                return None
            # A bundle with a transiently failed blob fetch must not record the tree sha: the
            # update check would otherwise see "same revision" and never re-fetch the gap (#101454).
            revision = (pinned_ref or tree[0]) if complete else ""
        else:
            for rel_path in referenced:
                self._add_support_file(repo, _skill_file_path(skill_dir, rel_path), rel_path, files, rel_path)
            revision = ""
        url = (f"https://github.com/{repo}/tree/{revision}" + (f"/{skill_path}" if skill_path else "")
               if revision else f"https://github.com/{repo}/{skill_path}")
        return SkillBundle(
            name=skill_dir.split("/")[-1] or repo.split("/")[-1], files=files, source="github", identifier=identifier,
            trust_level=self.trust_level_for(identifier), metadata={"source_url": url, "source_revision": revision},
        )

    def current_revision(self, identifier: str) -> str:
        """Tree sha the default branch currently resolves to — one cached tree lookup per repo,
        no blob downloads — so an update check can skip refetching unchanged skills."""
        if (split := _split_repo_id(identifier)) is None:
            return ""
        repo, _ = split
        self._get_repo_tree(repo)  # populates _tree_revisions
        return self._tree_revisions.get(repo, "")

    def _add_support_file(self, repo: str, item_path: str, rel_path: str, files: dict, shown: str, **kw) -> bool:
        """Fetch one support file into ``files``; a failed fetch warns (naming ``shown``), is skipped, and
        returns False."""
        content = self._fetch_file_bytes(repo, item_path, **kw)
        if content is None:
            logger.warning("Failed to fetch referenced skill support file; continuing without it: %s", shown)
            return False
        files[rel_path] = content
        return True

    def _collect_tree_files(
        self, repo: str, skill_path: str, entries: List[dict], ref: Optional[str], referenced: set,
        files: Dict[str, Union[str, bytes]],
    ) -> Optional[bool]:
        """Download the FULL skill directory from the pinned tree into ``files``. Link-driven fetching
        silently dropped support files under non-canonical dirs (``reference/``, ``agents/``, root
        LICENSE); everything still goes through quarantine + scan, and the scanner sees MORE this way.
        Returns None (bundle rejected) on an unsafe path or a SKILL.md-linked path that exists in the
        tree as a symlink/non-blob — that shape is an escape attempt. A linked path that is simply absent
        is a dangling link (repo-only dev tool, prose over-match): warn and install without it. Returns
        False when a blob fetch failed (installed with a gap the next update check must be able to fill).
        An empty ``skill_path`` is the repo-root skill layout, so the whole repo root is its directory."""
        prefix = f"{skill_path}/" if skill_path else ""
        symlinked: set = set()
        complete = True
        for rel_path, item_path, regular in _tree_members(entries, prefix):
            if not regular:
                symlinked.add(rel_path)
                continue
            if rel_path == "SKILL.md" or _skip_bundle_file(rel_path):
                continue
            try:
                rel_path = _validate_bundle_rel_path(rel_path)
            except ValueError:
                logger.warning("Rejected unsafe file path in skill bundle: %s", item_path)
                return None
            complete &= self._add_support_file(repo, item_path, rel_path, files, item_path, ref=ref)
        for rel_path in sorted(referenced):
            # A SKILL.md-linked support path that isn't in the tree is a dangling link — a repo-only dev
            # tool, prose over-match, or a file the author forgot to push. Warn and install without it
            # rather than aborting the whole install (#66760/#90081): the skill body still works, and the
            # gap is visible in the log. A referenced path that IS in the tree but as a symlink (or any
            # non-regular entry) stays a hard rejection — that shape is an escape attempt, not a forgotten
            # file.
            if rel_path in symlinked:
                logger.warning("Rejected non-regular referenced file in skill bundle: %s%s", prefix, rel_path)
                return None
            if rel_path not in files:
                logger.warning(
                    "Referenced skill support file is missing; continuing without it: %s%s", prefix, rel_path)
        return complete

    def inspect(self, identifier: str) -> Optional[SkillMeta]:
        """Fetch just the SKILL.md metadata for preview."""
        if (split := _split_repo_id(identifier)) is None:
            return None
        repo, skill_path = split[0], split[1].rstrip("/")
        content = self._fetch_file_content(repo, _skill_file_path(skill_path))
        if not content:
            return None
        fm = _parse_frontmatter(content)
        tags = _hermes_tags(fm) or (fm["tags"] if isinstance(fm.get("tags"), list) else [])
        provider = github_provider_for(repo)
        return SkillMeta(
            name=fm.get("name", skill_path.split("/")[-1] or repo.split("/")[-1]),
            description=str(fm.get("description", "")),
            source="github", identifier=identifier, trust_level=self.trust_level_for(identifier),
            repo=repo, path=skill_path, tags=[str(t) for t in tags],
            extra={"provider": provider} if provider else {},
        )

    # -- Internal helpers --

    def _list_skills_in_repo(self, repo: str, path: str, bucket: Optional[str] = None) -> List[SkillMeta]:
        """List skill directories in a GitHub repo path, using cached index. ``bucket`` labels every
        skill from a tap whose repo ships no ``skills.sh.json`` grouping, so several repos can share one
        hub category (e.g. "science"); a sidecar grouping still wins when present."""
        cache_key = _tap_cache_key(repo, path, bucket)
        cached = _cached_metas(cache_key)
        if cached is not None:
            return cached
        resp = self._github_get(f"{_API}/{repo}/contents/{path.rstrip('/')}")
        if resp is None or resp.status_code != 200:
            return []
        entries = resp.json()
        if not isinstance(entries, list):
            return []
        skills: List[SkillMeta] = []
        groupings = self._get_skillsh_groupings(repo)
        prefix = path.rstrip("/")
        for entry in entries:
            if entry.get("type") != "dir" or entry["name"].startswith((".", "_")):
                continue
            dir_name = entry["name"]
            meta = self.inspect(f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}")
            if meta:
                category = (groupings and (groupings.get(meta.name) or groupings.get(dir_name))) or bucket
                if category:
                    meta.extra["category"] = category
                skills.append(meta)
        _cache_metas(cache_key, skills)
        return skills

    def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]:
        """Cached ``(default_branch, tree_entries)`` for a repo, or None. One install may need the tree
        several times; caching saves the ``GET /repos/{repo}`` + ``GET .../git/trees/{branch}`` pair each
        time (~12 of the 60/hr unauthenticated budget before)."""
        if repo in self._tree_cache:
            return self._tree_cache[repo]
        # Misses are cached too: within one command a truncated/unreachable tree stays that way,
        # and the update check now probes the tree before every fetch (#101454).
        self._tree_cache[repo] = None
        repo_data = self._github_json(f"{_API}/{repo}")
        if repo_data is None:
            return None
        default_branch = repo_data.get("default_branch", "main")
        tree_data = self._github_json(
            f"{_API}/{repo}/git/trees/{default_branch}", params={"recursive": "1"}, timeout=30.0,
        )
        if tree_data is None:
            return None
        if tree_data.get("truncated"):
            logger.debug("Git tree truncated for %s", repo)
            return None
        if isinstance(tree_data.get("sha"), str) and tree_data["sha"]:
            self._tree_revisions[repo] = tree_data["sha"]
        self._tree_cache[repo] = tree = (default_branch, tree_data.get("tree", []))
        return tree

    def _github_json(self, url: str, **kwargs) -> Optional[dict]:
        """Decoded JSON body of a 200 ``_github_get`` (which flags rate-limit exhaustion), else None."""
        resp = self._github_get(url, **kwargs)
        try:
            return resp.json() if resp is not None and resp.status_code == 200 else None
        except ValueError:
            return None

    def _github_get(
        self, url: str, *, params: Optional[Dict] = None, headers: Optional[Dict] = None,
        timeout: float = 15.0, max_retries: int = 3,
    ) -> Optional[httpx.Response]:
        """GET against the GitHub API with retry/backoff on transient failures. Returns the final
        response (caller inspects status) or None when every attempt raised a transport error.
        Retries rate-limit 403/429 (waiting until ``Retry-After`` / ``X-RateLimit-Reset`` when present,
        capped 60s — one shared limit zeroes every GitHub tap at once during an index build), 5xx, and
        transport errors with exponential backoff. Terminal rate-limit exhaustion flags the instance so
        an index build fails loud instead of silently shipping zero GitHub skills."""
        hdrs = headers if headers is not None else self.auth.get_headers()
        backoff = 1.0
        last_resp: Optional[httpx.Response] = None
        for attempt in range(max_retries):
            last_attempt = attempt >= max_retries - 1
            wait = backoff
            try:
                resp = hub()._skills_hub_http_get(
                    url, params=params, headers=hdrs, timeout=timeout, follow_redirects=True
                )
            except httpx.HTTPError as e:
                logger.debug("GitHub GET %s failed (attempt %d/%d): %s", url, attempt + 1, max_retries, e)
                if last_attempt:
                    return None
            else:
                last_resp = resp
                if resp.status_code == 200:
                    return resp
                if resp.status_code in (403, 429):
                    limited = _is_rate_limit_response(resp)
                    if not limited or last_attempt:
                        if limited:  # terminal exhaustion: flag the instance so callers fail loud
                            self._rate_limited = True
                            logger.warning("GitHub API rate limit exhausted (unauthenticated: 60 req/hr). "
                                           "Set GITHUB_TOKEN or install the gh CLI to raise the limit to 5,000/hr.")
                        return resp
                    reset = resp.headers.get("X-RateLimit-Reset", "")
                    retry_after = parse_retry_after_seconds(resp.headers)
                    if retry_after is not None:
                        wait = min(retry_after, 60.0)
                    elif reset.isdigit():
                        delta = float(reset) - time.time()
                        if 0 < delta <= 60.0:
                            wait = delta
                    logger.debug("GitHub rate limited on %s, waiting %.1fs (attempt %d/%d)",
                                 url, wait, attempt + 1, max_retries)
                elif not (500 <= resp.status_code < 600) or last_attempt:
                    return resp
            time.sleep(wait)
            backoff = min(backoff * 2, 30.0)
        return last_resp

    def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]:
        """Locate ``<skill_name>/SKILL.md`` anywhere in the repo tree (one API call); full identifier or None."""
        if (cached := self._get_repo_tree(repo)) is None:
            return None
        skill_md_suffix = f"/{skill_name}/SKILL.md"
        for entry in cached[1]:
            path = entry.get("path", "")
            if entry.get("type") == "blob" and (path.endswith(skill_md_suffix) or path == skill_md_suffix[1:]):
                return f"{repo}/{path[: -len('/SKILL.md')]}"
        return None

    def _find_repo_root_skill(self, repo: str) -> Optional[str]:
        """Identifier for a single-skill repo whose ``SKILL.md`` sits at the repo ROOT (no skill
        directory) — e.g. ``orzcls/win-disk-cleaner``. The empty path segment (``owner/repo/``)
        denotes the skill directory being the repo root. Only repos with EXACTLY ONE SKILL.md in
        the whole tree qualify, so a categorized multi-skill repo never resolves here."""
        tree = self._get_repo_tree(repo)
        if tree is None:
            return None
        skill_mds = [
            entry.get("path", "") for entry in tree[1]
            if entry.get("type") == "blob" and entry.get("mode") != "120000"
            and (entry.get("path", "") == "SKILL.md" or entry.get("path", "").endswith("/SKILL.md"))
        ]
        return f"{repo}/" if skill_mds == ["SKILL.md"] else None

    def _fetch_file_content(self, repo: str, path: str, ref: Optional[str] = None) -> Optional[str]:
        """Fetch a single text file from GitHub (None on miss or non-UTF-8)."""
        content = self._fetch_file_bytes(repo, path, ref=ref)
        try:
            return None if content is None else content.decode("utf-8")
        except UnicodeDecodeError:
            return None

    def _fetch_file_bytes(self, repo: str, path: str, ref: Optional[str] = None) -> Optional[bytes]:
        """Fetch exact file bytes. ``ref`` pins to a tree SHA (see ``fetch`` on
        the TOCTOU); None keeps the legacy unpinned behavior."""
        resp = self._github_get(
            f"{_API}/{repo}/contents/{quote(path, safe='/')}", params={"ref": ref} if ref else None,
            headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"},
        )
        return resp.content if resp is not None and resp.status_code == 200 else None

    def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
        """Repo-root ``skills.sh.json`` groupings flattened to ``{skill_name: title}``. ``skills.sh.json``
        is a cross-ecosystem standard (``$schema: https://skills.sh/schemas/skills.sh.schema.json``); any
        tap shipping it gets category pills for free. None when absent/unparsable; cached per repo."""
        if repo not in self._skillsh_groupings:
            content = self._fetch_file_content(repo, "skills.sh.json")
            self._skillsh_groupings[repo] = self._parse_skillsh_groupings(content) if content else None
        return self._skillsh_groupings[repo]

    @staticmethod
    def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]:
        """Flatten ``{"groupings": [{"title", "skills": [...]}]}``; None if not usable."""
        try:
            data = json.loads(content)
        except (json.JSONDecodeError, TypeError):
            return None
        groupings = data.get("groupings") if isinstance(data, dict) else None
        if not isinstance(groupings, list):
            return None
        mapping: Dict[str, str] = {}
        for group in groupings:
            if not isinstance(group, dict):
                continue
            title, members = group.get("title"), group.get("skills")
            if not isinstance(title, str) or not isinstance(members, list):
                continue
            for member in members:
                if isinstance(member, str) and member:
                    mapping.setdefault(member, title)  # first grouping wins
        return mapping
