"""GitHub App integration client.

Implements GitHubClientProtocol from core.protocols.
Uses httpx for API calls with GitHub App JWT authentication.
"""
from __future__ import annotations
import asyncio
import random
import time
from typing import Optional
from pathlib import Path

import httpx
import structlog
from jose import jwt as jose_jwt
from jose.exceptions import JWSError

from src.config import Settings, get_settings
from src.core.protocols import DiffInfo, PRInfo, RepoInfo

logger = structlog.get_logger()
GITHUB_API = "https://api.github.com"


def _github_api_error_message(resp: httpx.Response) -> str:
    """Best-effort parse of GitHub JSON error body (helps debug 403/422)."""
    try:
        data = resp.json()
        if isinstance(data, dict) and data.get("message"):
            msg = str(data["message"])
            errs = data.get("errors")
            if errs:
                return f"{msg} | {errs!r}"
            return msg
    except Exception:
        pass
    return (resp.text or resp.reason_phrase or "").strip() or f"HTTP {resp.status_code}"


def _is_secondary_rate_limit(resp: httpx.Response) -> bool:
    """Return true for GitHub's abuse/secondary throttling response."""
    if resp.status_code not in (403, 429):
        return False
    message = _github_api_error_message(resp).lower()
    return any(
        marker in message
        for marker in (
            "secondary rate limit",
            "rate limit exceeded",
            "abuse detection",
            "too many requests",
        )
    )


def _rate_limit_delay(resp: httpx.Response, attempt: int) -> float:
    """Calculate a bounded retry delay from GitHub headers, with jitter."""
    retry_after = (resp.headers.get("retry-after") or "").strip()
    try:
        delay = float(retry_after) if retry_after else 0.0
    except ValueError:
        delay = 0.0
    if delay <= 0:
        reset = (resp.headers.get("x-ratelimit-reset") or "").strip()
        try:
            delay = max(0.0, float(reset) - time.time()) if reset else 0.0
        except ValueError:
            delay = 0.0
    if delay <= 0:
        delay = min(30.0, 2.0 ** attempt)
    return min(60.0, delay) + random.uniform(0.0, 0.25)


def _git_head_ref_absent(status_code: int) -> bool:
    """True when ``GET .../git/ref/heads/{branch}`` has no ref yet (empty repo, no first push).

    GitHub typically returns **404**; installation-token calls sometimes return **409 Conflict**
    for the same empty-repo case.
    """
    return status_code in (404, 409)


def _normalize_github_pem(raw: str) -> str:
    """Turn env-style PEM (often one line with literal \\n) into real newlines."""
    s = (raw or "").strip()
    if not s:
        return s
    if "\\n" in s:
        s = s.replace("\\n", "\n")
    if s.startswith("\ufeff"):
        s = s.lstrip("\ufeff")
    return s


def _resolve_github_app_private_key(settings: Settings, override: Optional[str] = None) -> str:
    if override:
        return _normalize_github_pem(override)
    path = (settings.github_app_private_key_file or "").strip()
    if path:
        p = Path(path).expanduser()
        if p.is_file():
            return _normalize_github_pem(p.read_text(encoding="utf-8"))
        inline = _normalize_github_pem(settings.github_app_private_key)
        if inline:
            return inline
        raise FileNotFoundError(f"GitHub App private key file not found: {p}")
    return _normalize_github_pem(settings.github_app_private_key)


class GitHubAppClient:
    """GitHub App client for repo operations."""

    def __init__(self, app_id: Optional[str] = None, private_key: Optional[str] = None, installation_id: Optional[int] = None):
        settings = get_settings()
        self._app_id = app_id or settings.github_app_id
        self._private_key = _resolve_github_app_private_key(settings, override=private_key)
        self._installation_id = (
            installation_id if installation_id is not None else settings.github_installation_id
        )
        self._token: Optional[str] = None
        self._token_expires: float = 0

    def _generate_app_jwt(self) -> str:
        now = int(time.time())
        payload = {
            "iat": now - 60,
            "exp": now + (10 * 60),
            "iss": self._app_id,
        }
        if not self._private_key or "BEGIN" not in self._private_key:
            raise ValueError(
                "GitHub App private key is missing or not PEM. Set GITHUB_APP_PRIVATE_KEY to the "
                "full key (-----BEGIN … -----END), use real newlines or escaped \\n in .env, or set "
                "GITHUB_APP_PRIVATE_KEY_FILE to a .pem path (recommended on servers)."
            )
        try:
            return jose_jwt.encode(payload, self._private_key, algorithm="RS256")
        except JWSError as e:
            msg = str(e).lower()
            if "pem" in msg or "key" in msg:
                raise ValueError(
                    "GitHub App private key failed to load as PEM (RS256). Confirm the key matches "
                    "your GitHub App, includes BEGIN/END lines, and newlines are preserved; or use "
                    "GITHUB_APP_PRIVATE_KEY_FILE pointing at the .pem file."
                ) from e
            raise

    async def _get_installation_token(self) -> str:
        if self._token and time.time() < self._token_expires:
            return self._token

        if self._installation_id is None or int(self._installation_id) <= 0:
            raise ValueError(
                "GitHub App installation id is missing or invalid (must be > 0). "
                "Set GITHUB_INSTALLATION_ID in the environment, or persist "
                "workspace_settings.github_installation_id after the GitHub App is installed "
                "on the org/account (numeric id from the installation URL or API)."
            )

        app_jwt = self._generate_app_jwt()
        async with httpx.AsyncClient() as client:
            resp = await self._request_with_rate_limit_retry(
                client,
                "POST",
                f"{GITHUB_API}/app/installations/{self._installation_id}/access_tokens",
                headers={
                    "Authorization": f"Bearer {app_jwt}",
                    "Accept": "application/vnd.github+json",
                },
            )
            if resp.status_code == 404:
                raise ValueError(
                    f"GitHub App installation id {self._installation_id} not found (404). "
                    "Check GITHUB_INSTALLATION_ID / workspace_settings.github_installation_id matches "
                    "an installation of this app, and GITHUB_APP_ID + private key match that app."
                )
            resp.raise_for_status()
            data = resp.json()
            self._token = data["token"]
            self._token_expires = time.time() + 3500
            return self._token

    async def mint_repo_scoped_token(self, repo_full_name: str, *, ttl_hint_seconds: int = 3500) -> str:
        """A fresh installation token usable only against ``repo_full_name``.

        Unlike ``_get_installation_token`` (cached, installation-wide — every repo this
        GitHub App can reach), this is minted on demand and narrowed to one repo via the
        ``repositories`` scoping GitHub's token endpoint accepts. Intended for handing a
        push credential to a single customer repo's own CI run, where an installation-wide
        token would let that run act on every other P2HS-managed repo for its ~1h lifetime.
        """
        repo_name = repo_full_name.split("/")[-1]
        if self._installation_id is None or int(self._installation_id) <= 0:
            raise ValueError("GitHub App installation id is missing or invalid (must be > 0)")
        app_jwt = self._generate_app_jwt()
        async with httpx.AsyncClient() as client:
            resp = await self._request_with_rate_limit_retry(
                client,
                "POST",
                f"{GITHUB_API}/app/installations/{self._installation_id}/access_tokens",
                headers={
                    "Authorization": f"Bearer {app_jwt}",
                    "Accept": "application/vnd.github+json",
                },
                json={"repositories": [repo_name]},
            )
            resp.raise_for_status()
            return resp.json()["token"]

    async def _headers(self) -> dict:
        token = await self._get_installation_token()
        return {
            "Authorization": f"token {token}",
            "Accept": "application/vnd.github+json",
        }

    async def _request_with_rate_limit_retry(
        self, client: httpx.AsyncClient, method: str, url: str, **kwargs
    ) -> httpx.Response:
        """Retry only explicit GitHub secondary-limit responses.

        This is intentionally bounded: callers still receive the original HTTP error after
        the short first-aid window, while worker-level recovery can retry later.
        """
        response: httpx.Response | None = None
        for attempt in range(4):
            request = getattr(client, method.lower())
            response = await request(url, **kwargs)
            if not _is_secondary_rate_limit(response) or attempt == 3:
                return response
            delay = _rate_limit_delay(response, attempt + 1)
            logger.warning(
                "github_secondary_rate_limit_retry",
                method=method,
                url=url,
                attempt=attempt + 1,
                delay_seconds=round(delay, 2),
                github_message=_github_api_error_message(response),
            )
            await asyncio.sleep(delay)
        assert response is not None
        return response

    async def get_authenticated_clone_url(self, repo_full_name: str) -> str:
        """Return clone URL with installation token for git clone/push."""
        token = await self._get_installation_token()
        return f"https://x-access-token:{token}@github.com/{repo_full_name}.git"

    async def get_installation_actor_login(self) -> str:
        """Login for the user/org the installation token acts as (for `owner/repo` resolution)."""
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await self._request_with_rate_limit_retry(
                client, "GET", f"{GITHUB_API}/user", headers=headers
            )
            resp.raise_for_status()
            return str(resp.json()["login"])

    async def get_repository(self, full_name: str) -> Optional[RepoInfo]:
        """Return repo metadata or None if 404."""
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await self._request_with_rate_limit_retry(
                client, "GET", f"{GITHUB_API}/repos/{full_name}", headers=headers
            )
            if resp.status_code == 404:
                return None
            if resp.is_error:
                logger.error(
                    "github_get_repository_failed",
                    full_name=full_name,
                    status_code=resp.status_code,
                    github_message=_github_api_error_message(resp),
                )
            resp.raise_for_status()
            data = resp.json()
            return RepoInfo(
                id=data["id"],
                full_name=data["full_name"],
                clone_url=data["clone_url"],
                default_branch=data.get("default_branch") or "main",
                html_url=data.get("html_url"),
            )

    async def ensure_repository(self, name: str, private: bool = True, org: Optional[str] = None) -> RepoInfo:
        """Create repo if missing; on 422 name already exists, fetch existing (idempotent)."""
        org_part = (org or "").strip()
        owner = org_part or await self.get_installation_actor_login()
        full_name = f"{owner}/{name}"
        existing = await self.get_repository(full_name)
        if existing:
            return existing
        try:
            return await self.create_repo(name, private=private, org=org_part or None)
        except httpx.HTTPStatusError as e:
            if e.response is not None and e.response.status_code == 422:
                again = await self.get_repository(full_name)
                if again:
                    return again
            raise

    async def get_ref_head_sha(self, repo_full_name: str, branch: str) -> Optional[str]:
        """Return SHA at refs/heads/{branch} or None if ref missing."""
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo_full_name}/git/ref/heads/{branch}",
                headers=headers,
            )
            if _git_head_ref_absent(resp.status_code):
                return None
            if resp.status_code == 403:
                detail = _github_api_error_message(resp)
                logger.error(
                    "github_git_ref_forbidden",
                    repo=repo_full_name,
                    branch=branch,
                    github_message=detail,
                )
                raise ValueError(
                    "GitHub App cannot read git/refs for this repository (403). "
                    "If the org installed the app with 'Only select repositories', new repos are not "
                    "included automatically — set the installation to 'All repositories'. "
                    "Repository permissions: Contents Read and write, Metadata Read. "
                    f"GitHub: {detail}"
                )
            resp.raise_for_status()
            return str(resp.json()["object"]["sha"])

    async def create_branch_from_sha(self, repo_full_name: str, branch: str, sha: str) -> bool:
        """Create refs/heads/{branch} from an existing commit SHA.

        Returns True when the branch was created, False if it already existed.
        """
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{GITHUB_API}/repos/{repo_full_name}/git/refs",
                headers=headers,
                json={"ref": f"refs/heads/{branch}", "sha": sha},
            )
            if resp.status_code == 422:
                return False
            resp.raise_for_status()
            return True

    async def delete_work_branch(self, repo_full_name: str, branch: str) -> bool:
        """Delete a run work branch (p2hs/codegen/<run_id>) and nothing else.

        Refuses any other name, so a bad caller can never delete preview, main or a customer branch.
        Returns True when the branch is gone (deleted now, or already absent).
        """
        from src.domain.codegen.work_branch import is_work_branch

        if not is_work_branch(branch):
            raise ValueError("Only p2hs/codegen/<run_id> work branches may be deleted")
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.delete(
                f"{GITHUB_API}/repos/{repo_full_name}/git/refs/heads/{branch}", headers=headers
            )
            if resp.status_code in (404, 422):
                return True
            resp.raise_for_status()
            return True

    async def create_repo(self, name: str, private: bool = True, org: Optional[str] = None) -> RepoInfo:
        headers = await self._headers()
        url = f"{GITHUB_API}/orgs/{org}/repos" if org else f"{GITHUB_API}/user/repos"
        async with httpx.AsyncClient() as client:
            resp = await self._request_with_rate_limit_retry(
                client,
                "POST",
                url,
                headers=headers,
                json={"name": name, "private": private, "auto_init": False},
            )
            if resp.is_error:
                logger.error(
                    "github_create_repo_failed",
                    status_code=resp.status_code,
                    org=org or "",
                    url=str(resp.request.url),
                    github_message=_github_api_error_message(resp),
                )
            resp.raise_for_status()
            data = resp.json()
            logger.info("github_repo_created", repo=data["full_name"])
            return RepoInfo(
                id=data["id"],
                full_name=data["full_name"],
                clone_url=data["clone_url"],
                default_branch=data.get("default_branch") or "main",
                html_url=data.get("html_url"),
            )

    async def push_files(self, repo: str, branch: str, files: dict[str, str | bytes], message: str) -> str:
        return await self.create_commit(repo, branch, files, message)

    async def create_commit(
        self,
        repo: str,
        branch: str,
        files: dict[str, str | bytes | None],
        message: str,
    ) -> str:
        """Create a commit with multiple files using the Git Trees API.

        A ``None`` value deletes that path (callers pass only paths known to exist: GitHub rejects
        deleting one that does not). Deletions apply to an existing branch only."""
        import base64

        deletions = [path for path, content in files.items() if content is None]
        files = {path: content for path, content in files.items() if content is not None}

        def _blob_content(content: str | bytes) -> str:
            raw = content if isinstance(content, bytes) else content.encode()
            return base64.b64encode(raw).decode()

        async def _initialize_empty_repo_with_contents_api(
            client: httpx.AsyncClient,
        ) -> str:
            """Create the first commit in an empty repo.

            GitHub's Git Database API can return 409 "Git Repository is empty" for
            blob/tree endpoints until the repo has at least one commit. The Contents
            API is the supported bootstrap path for that first commit.
            """
            if not files:
                raise ValueError("Cannot create an initial commit with no files")
            first_path, first_content = next(iter(files.items()))
            init_resp = await client.put(
                f"{GITHUB_API}/repos/{repo}/contents/{first_path}",
                headers=headers,
                json={
                    "message": message,
                    "content": _blob_content(first_content),
                    "branch": branch,
                },
            )
            if init_resp.status_code == 409:
                logger.warning(
                    "github_contents_init_conflict",
                    repo=repo,
                    branch=branch,
                    path=first_path,
                    github_message=_github_api_error_message(init_resp),
                )
                init_resp = await client.put(
                    f"{GITHUB_API}/repos/{repo}/contents/{first_path}",
                    headers=headers,
                    json={
                        "message": message,
                        "content": _blob_content(first_content),
                    },
                )
            init_resp.raise_for_status()
            data = init_resp.json()
            sha = str((data.get("commit") or {}).get("sha") or "")
            if not sha:
                raise RuntimeError("GitHub contents API did not return commit sha")
            return sha

        async def _post_json_with_retry(
            client: httpx.AsyncClient,
            url: str,
            payload: dict,
            *,
            operation: str,
        ) -> httpx.Response:
            # Freshly-created repos can briefly return 409 while GitHub finishes
            # provisioning the git database / selected-repository installation access.
            # "Git Repository is empty" is a permanent state (not transient) — the caller
            # handles it by falling through to the Contents API, so return immediately.
            delays = [0.75, 1.5, 3.0, 5.0]
            for attempt, delay in enumerate([0.0] + delays, start=1):
                if delay:
                    await asyncio.sleep(delay)
                resp = await self._request_with_rate_limit_retry(
                    client, "POST", url, headers=headers, json=payload
                )
                if resp.status_code != 409:
                    return resp
                if "repository is empty" in _github_api_error_message(resp).lower():
                    return resp
                logger.warning(
                    "github_git_api_conflict_retry",
                    repo=repo,
                    branch=branch,
                    operation=operation,
                    attempt=attempt,
                    github_message=_github_api_error_message(resp),
                )
            return resp

        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            ref_resp = await client.get(f"{GITHUB_API}/repos/{repo}/git/ref/heads/{branch}", headers=headers)

            if ref_resp.status_code == 403:
                detail = _github_api_error_message(ref_resp)
                logger.error(
                    "github_git_ref_forbidden",
                    repo=repo,
                    branch=branch,
                    github_message=detail,
                )
                raise ValueError(
                    "GitHub App cannot read git/refs for this repository (403). "
                    "If the org installed the app with 'Only select repositories', new repos are not "
                    "included automatically — set the installation to 'All repositories' (GitHub's "
                    "add-repo-to-installation API needs a classic PAT, not an installation token). "
                    "Also confirm Repository permissions: Contents Read and write, Metadata Read. "
                    f"GitHub: {detail}"
                )

            if _git_head_ref_absent(ref_resp.status_code):
                blobs = []
                for path, content in files.items():
                    blob_resp = await _post_json_with_retry(
                        client,
                        f"{GITHUB_API}/repos/{repo}/git/blobs",
                        {"content": _blob_content(content), "encoding": "base64"},
                        operation="create_blob",
                    )
                    if blob_resp.status_code == 409 and "repository is empty" in _github_api_error_message(blob_resp).lower():
                        sha = await _initialize_empty_repo_with_contents_api(client)
                        remaining = dict(files)
                        remaining.pop(path, None)
                        if not remaining:
                            return sha
                        return await self.create_commit(repo, branch, remaining, message)
                    blob_resp.raise_for_status()
                    blobs.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_resp.json()["sha"]})

                tree_resp = await _post_json_with_retry(
                    client,
                    f"{GITHUB_API}/repos/{repo}/git/trees",
                    {"tree": blobs},
                    operation="create_tree",
                )
                tree_resp.raise_for_status()

                commit_resp = await _post_json_with_retry(
                    client,
                    f"{GITHUB_API}/repos/{repo}/git/commits",
                    {"message": message, "tree": tree_resp.json()["sha"]},
                    operation="create_commit",
                )
                commit_resp.raise_for_status()
                sha = commit_resp.json()["sha"]

                ref_create = await _post_json_with_retry(
                    client,
                    f"{GITHUB_API}/repos/{repo}/git/refs",
                    {"ref": f"refs/heads/{branch}", "sha": sha},
                    operation="create_ref",
                )
                if ref_create.status_code == 422:
                    ref_get = await client.get(
                        f"{GITHUB_API}/repos/{repo}/git/ref/heads/{branch}",
                        headers=headers,
                    )
                    ref_get.raise_for_status()
                    return str(ref_get.json()["object"]["sha"])
                ref_create.raise_for_status()
                return sha

            ref_resp.raise_for_status()
            parent_sha = ref_resp.json()["object"]["sha"]

            blobs = []
            for path, content in files.items():
                blob_resp = await _post_json_with_retry(
                    client,
                    f"{GITHUB_API}/repos/{repo}/git/blobs",
                    {"content": _blob_content(content), "encoding": "base64"},
                    operation="create_blob",
                )
                blob_resp.raise_for_status()
                blobs.append({"path": path, "mode": "100644", "type": "blob", "sha": blob_resp.json()["sha"]})
            for path in deletions:  # a tree entry with a null sha removes the path from the base tree
                blobs.append({"path": path, "mode": "100644", "type": "blob", "sha": None})

            tree_resp = await _post_json_with_retry(
                client,
                f"{GITHUB_API}/repos/{repo}/git/trees",
                {"tree": blobs, "base_tree": parent_sha},
                operation="create_tree",
            )
            tree_resp.raise_for_status()

            commit_resp = await _post_json_with_retry(
                client,
                f"{GITHUB_API}/repos/{repo}/git/commits",
                {"message": message, "tree": tree_resp.json()["sha"], "parents": [parent_sha]},
                operation="create_commit",
            )
            commit_resp.raise_for_status()
            sha = commit_resp.json()["sha"]

            await client.patch(
                f"{GITHUB_API}/repos/{repo}/git/refs/heads/{branch}",
                headers=headers,
                json={"sha": sha},
            )
            return sha

    async def list_tree_paths(self, repo: str, ref: str) -> set[str]:
        """Every file path on a branch (recursive tree). Used to delete only what exists."""
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo}/git/trees/{ref}",
                headers=headers,
                params={"recursive": "1"},
            )
            resp.raise_for_status()
            return {e["path"] for e in resp.json().get("tree", []) if e.get("type") == "blob"}

    async def create_pull_request(self, repo: str, title: str, head: str, base: str) -> PRInfo:
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.post(
                f"{GITHUB_API}/repos/{repo}/pulls",
                headers=headers,
                json={"title": title, "head": head, "base": base},
            )
            resp.raise_for_status()
            data = resp.json()
            return PRInfo(number=data["number"], url=data["html_url"])

    async def get_file_contents(self, repo: str, path: str, ref: str) -> bytes:
        import base64

        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo}/contents/{path}",
                headers=headers,
                params={"ref": ref},
            )
            resp.raise_for_status()
            return base64.b64decode(resp.json()["content"])

    async def compare_commits(self, repo: str, base: str, head: str) -> DiffInfo:
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo}/compare/{base}...{head}",
                headers=headers,
            )
            resp.raise_for_status()
            data = resp.json()
            return DiffInfo(files=data.get("files", []), html_url=data["html_url"])

    async def clone_and_push(self, repo: str, source_path: str, branch: str, message: str) -> str:
        """Read all files from source_path and push them as initial commit."""
        import os

        files = {}
        for root, dirs, filenames in os.walk(source_path):
            dirs[:] = [d for d in dirs if not d.startswith(".")]
            for fname in filenames:
                if fname.startswith("."):
                    continue
                fpath = os.path.join(root, fname)
                rel = os.path.relpath(fpath, source_path).replace("\\", "/")
                try:
                    with open(fpath, "r", encoding="utf-8") as f:
                        files[rel] = f.read()
                except UnicodeDecodeError:
                    continue

        return await self.create_commit(repo, branch, files, message)

    async def invite_collaborators(
        self,
        repo_full_name: str,
        usernames: list[str],
        permission: str = "push",
    ) -> None:
        """Invite GitHub users as collaborators on a repository.

        Swallows per-user failures with a warning — a bad username or a user
        already on the org won't block the bootstrap. Requires 'Administration:
        write' repository permission on the GitHub App.
        """
        if not usernames:
            return
        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            for username in usernames:
                username = username.strip()
                if not username:
                    continue
                resp = await client.put(
                    f"{GITHUB_API}/repos/{repo_full_name}/collaborators/{username}",
                    headers=headers,
                    json={"permission": permission},
                )
                if resp.status_code in (201, 204):
                    logger.info(
                        "github_collaborator_invited",
                        repo=repo_full_name,
                        username=username,
                        status=resp.status_code,
                    )
                else:
                    logger.warning(
                        "github_collaborator_invite_failed",
                        repo=repo_full_name,
                        username=username,
                        status=resp.status_code,
                        github_message=_github_api_error_message(resp),
                    )

    async def set_repo_action_secrets(self, repo_full_name: str, secrets: dict[str, str]) -> None:
        """Set GitHub Actions secrets on a repository.

        Uses libsodium sealed-box encryption as required by the GitHub API.
        Requires the GitHub App to have ``Secrets: Read and write`` repository permission.

        Args:
            repo_full_name: e.g. "my-org/p2hs-abc123"
            secrets: mapping of secret name → plaintext value
        """
        import base64

        from nacl import encoding, public  # type: ignore[import]

        headers = await self._headers()
        async with httpx.AsyncClient() as client:
            # Fetch the repo's public key for secret encryption
            key_resp = await client.get(
                f"{GITHUB_API}/repos/{repo_full_name}/actions/secrets/public-key",
                headers=headers,
            )
            if key_resp.status_code == 403:
                detail = _github_api_error_message(key_resp)
                logger.error(
                    "github_secrets_permission_denied",
                    repo=repo_full_name,
                    github_message=detail,
                )
                raise ValueError(
                    f"GitHub App cannot manage secrets for {repo_full_name} (403). "
                    "Add 'Secrets: Read and write' repository permission to the GitHub App "
                    f"and re-approve the installation. GitHub: {detail}"
                )
            key_resp.raise_for_status()
            key_data = key_resp.json()
            key_id = key_data["key_id"]
            public_key_bytes = base64.b64decode(key_data["key"])

            pk = public.PublicKey(public_key_bytes)
            box = public.SealedBox(pk)

            for name, value in secrets.items():
                encrypted = base64.b64encode(box.encrypt(value.encode("utf-8"))).decode("utf-8")
                put_resp = await client.put(
                    f"{GITHUB_API}/repos/{repo_full_name}/actions/secrets/{name}",
                    headers=headers,
                    json={"encrypted_value": encrypted, "key_id": key_id},
                )
                put_resp.raise_for_status()
                logger.info("github_repo_secret_set", repo=repo_full_name, secret=name)

    async def trigger_workflow_dispatch(
        self,
        repo_full_name: str,
        workflow_file: str,
        ref: str,
        inputs: dict | None = None,
    ) -> bool:
        """Trigger a workflow_dispatch event on a repository workflow.

        Returns True if the dispatch was accepted (204), False otherwise.
        """
        headers = await self._headers()
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.post(
                f"{GITHUB_API}/repos/{repo_full_name}/actions/workflows/{workflow_file}/dispatches",
                headers=headers,
                json={"ref": ref, "inputs": inputs or {}},
            )
            if resp.status_code == 204:
                logger.info(
                    "github_workflow_dispatch_triggered",
                    repo=repo_full_name,
                    workflow=workflow_file,
                    ref=ref,
                )
                return True
            logger.warning(
                "github_workflow_dispatch_failed",
                repo=repo_full_name,
                workflow=workflow_file,
                ref=ref,
                status=resp.status_code,
                body=resp.text[:200],
            )
            return False

    async def get_workflow_run_jobs(self, repo_full_name: str, run_id: int) -> list[dict]:
        """Return the list of jobs (with steps) for a workflow run."""
        headers = await self._headers()
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo_full_name}/actions/runs/{run_id}/jobs",
                headers=headers,
            )
            if resp.status_code != 200:
                logger.warning(
                    "github_get_workflow_jobs_failed",
                    repo=repo_full_name, run_id=run_id, status=resp.status_code,
                )
                return []
            return resp.json().get("jobs", [])

    async def list_workflow_runs(
        self, repo_full_name: str, *, workflow_name: str | None = None, limit: int = 10
    ) -> list[dict]:
        """Return recent workflow runs, optionally filtered by workflow file/name."""
        headers = await self._headers()
        params = {"per_page": max(1, min(limit, 100))}
        if workflow_name:
            params["workflow_id"] = workflow_name
        async with httpx.AsyncClient(timeout=30) as client:
            resp = await self._request_with_rate_limit_retry(
                client,
                "GET",
                f"{GITHUB_API}/repos/{repo_full_name}/actions/runs",
                headers=headers,
                params=params,
            )
            resp.raise_for_status()
            return list(resp.json().get("workflow_runs", []))

    async def get_job_log_text(self, repo_full_name: str, job_id: int) -> str:
        """Return the last ~100 lines of log text for a specific job."""
        headers = await self._headers()
        async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
            resp = await client.get(
                f"{GITHUB_API}/repos/{repo_full_name}/actions/jobs/{job_id}/logs",
                headers=headers,
            )
            if resp.status_code != 200:
                logger.warning(
                    "github_get_job_log_failed",
                    repo=repo_full_name, job_id=job_id, status=resp.status_code,
                )
                return ""
        lines = resp.text.splitlines()
        # Return the last 100 lines — typically enough to capture the error
        tail = lines[-100:] if len(lines) > 100 else lines
        return "\n".join(tail)
