"""GitHub アダプター。GitServiceAdapter の全メソッドを GitHub REST API で実装する。"""

from __future__ import annotations

import base64
from urllib.parse import quote

import requests

from gfo.exceptions import GfoError
from gfo.http import paginate_link_header

from .base import (
    Artifact,
    Branch,
    BranchProtection,
    CheckRun,
    Comment,
    Commit,
    CommitStatus,
    CompareFile,
    CompareResult,
    DeployKey,
    GitHubLikeAdapter,
    GitServiceAdapter,
    GpgKey,
    Issue,
    IssueTemplate,
    Label,
    Milestone,
    Notification,
    Organization,
    Pipeline,
    PullRequest,
    PullRequestCommit,
    PullRequestFile,
    Reaction,
    Release,
    ReleaseAsset,
    Repository,
    Review,
    Secret,
    SshKey,
    Tag,
    TagProtection,
    TimelineEvent,
    Variable,
    Webhook,
    Workflow,
)
from .registry import register


@register("github")
class GitHubAdapter(GitHubLikeAdapter, GitServiceAdapter):
    service_name = "GitHub"

    def _repos_path(self) -> str:
        return f"/repos/{quote(self._owner, safe='')}/{quote(self._repo, safe='')}"

    # --- PR ---

    def list_pull_requests(
        self,
        *,
        state: str = "open",
        limit: int = 30,
        author: str | None = None,
        label: str | None = None,
        assignee: str | None = None,
        search: str | None = None,
        base: str | None = None,
        head: str | None = None,
        draft: bool | None = None,
    ) -> list[PullRequest]:
        api_state = (
            "closed" if state == "merged" else state
        )  # GitHub API に merged パラメータはなく closed で代用
        params: dict = {"state": api_state}
        if base:
            params["base"] = base
        if head:
            params["head"] = head
        needs_client_filter = any([author, label, assignee, draft is not None, search])
        fetch_limit = 0 if needs_client_filter else limit
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/pulls",
            params=params,
            limit=fetch_limit,
        )
        # GitHub PR API は author/label/assignee/draft/search をネイティブ非対応のためクライアント側フィルタ
        if author:
            results = [r for r in results if (r.get("user") or {}).get("login") == author]
        if label:
            results = [
                r for r in results if label in [lb["name"] for lb in (r.get("labels") or [])]
            ]
        if assignee:
            results = [
                r for r in results if assignee in [a["login"] for a in (r.get("assignees") or [])]
            ]
        if draft is not None:
            results = [r for r in results if r.get("draft", False) == draft]
        if search:
            search_lower = search.lower()
            results = [
                r
                for r in results
                if search_lower in (r.get("title") or "").lower()
                or search_lower in (r.get("body") or "").lower()
            ]
        prs = [self._to_pull_request(r) for r in results]
        if state == "merged":
            prs = [pr for pr in prs if pr.state == "merged"]
        if needs_client_filter and limit > 0:
            prs = prs[:limit]
        return prs

    def create_pull_request(
        self,
        *,
        title: str,
        body: str = "",
        base: str,
        head: str,
        draft: bool = False,
        reviewers: list[str] | None = None,
        assignees: list[str] | None = None,
        labels: list[str] | None = None,
        milestone: str | None = None,
    ) -> PullRequest:
        payload: dict = {"title": title, "body": body, "base": base, "head": head, "draft": draft}
        resp = self._client.post(f"{self._repos_path()}/pulls", json=payload)
        pr = self._to_pull_request(resp.json())
        patch_payload: dict = {}
        if labels:
            patch_payload["labels"] = labels
        if assignees:
            patch_payload["assignees"] = assignees
        if milestone:
            patch_payload["milestone"] = self._resolve_milestone_number(milestone)
        if patch_payload:
            self._client.patch(f"{self._repos_path()}/issues/{pr.number}", json=patch_payload)
        if reviewers:
            self.request_reviewers(pr.number, reviewers)
        if patch_payload or reviewers:
            return self.get_pull_request(pr.number)
        return pr

    def _resolve_milestone_number(self, title: str) -> int:
        """milestone タイトルから number を解決する。"""
        for ms in self.list_milestones():
            if ms.title == title:
                return ms.number
        raise GfoError(f"Milestone not found: {title}")

    def get_pull_request(self, number: int) -> PullRequest:
        resp = self._client.get(f"{self._repos_path()}/pulls/{number}")
        return self._to_pull_request(resp.json())

    def merge_pull_request(
        self,
        number: int,
        *,
        method: str = "merge",
        title: str | None = None,
        message: str | None = None,
    ) -> None:
        payload: dict = {"merge_method": method}
        if title is not None:
            payload["commit_title"] = title
        if message is not None:
            payload["commit_message"] = message
        self._client.put(
            f"{self._repos_path()}/pulls/{number}/merge",
            json=payload,
        )

    def close_pull_request(self, number: int) -> None:
        self._client.patch(
            f"{self._repos_path()}/pulls/{number}",
            json={"state": "closed"},
        )

    def reopen_pull_request(self, number: int) -> None:
        self._client.patch(
            f"{self._repos_path()}/pulls/{number}",
            json={"state": "open"},
        )

    def lock_pull_request(self, number: int, *, reason: str | None = None) -> None:
        self.lock_issue(number, reason=reason)

    def unlock_pull_request(self, number: int) -> None:
        self.unlock_issue(number)

    def get_pr_checkout_refspec(self, number: int, *, pr: PullRequest | None = None) -> str:
        return f"refs/pull/{number}/head"

    # --- Issue ---

    def list_issues(
        self,
        *,
        state: str = "open",
        assignee: str | None = None,
        label: str | None = None,
        limit: int = 30,
        author: str | None = None,
        milestone: str | None = None,
        search: str | None = None,
    ) -> list[Issue]:
        params: dict = {"state": state}
        if assignee is not None:
            params["assignee"] = assignee
        if label is not None:
            params["labels"] = label
        if author is not None:
            params["creator"] = author
        if milestone is not None:
            params["milestone"] = self._resolve_milestone_number(milestone)
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/issues",
            params=params,
            limit=limit,
        )
        items = [r for r in results if "pull_request" not in r]
        if search:
            search_lower = search.lower()
            items = [
                r
                for r in items
                if search_lower in (r.get("title") or "").lower()
                or search_lower in (r.get("body") or "").lower()
            ]
        return [self._to_issue(r) for r in items]

    def create_issue(
        self,
        *,
        title: str,
        body: str = "",
        assignee: str | None = None,
        label: str | None = None,
        milestone: str | None = None,
        **kwargs,
    ) -> Issue:
        payload: dict = {"title": title, "body": body}
        if assignee is not None:
            payload["assignees"] = [assignee]
        if label is not None:
            payload["labels"] = [label]
        if milestone is not None:
            payload["milestone"] = self._resolve_milestone_number(milestone)
        resp = self._client.post(f"{self._repos_path()}/issues", json=payload)
        return self._to_issue(resp.json())

    def get_issue(self, number: int) -> Issue:
        resp = self._client.get(f"{self._repos_path()}/issues/{number}")
        return self._to_issue(resp.json())

    def close_issue(self, number: int) -> None:
        self._client.patch(
            f"{self._repos_path()}/issues/{number}",
            json={"state": "closed"},
        )

    def reopen_issue(self, number: int) -> None:
        self._client.patch(
            f"{self._repos_path()}/issues/{number}",
            json={"state": "open"},
        )

    def list_issue_templates(self) -> list[IssueTemplate]:
        try:
            resp = self._client.get(f"{self._repos_path()}/contents/.github/ISSUE_TEMPLATE")
        except (GfoError, requests.RequestException, KeyError, ValueError):
            return []
        files = resp.json()
        if not isinstance(files, list):
            return []
        templates: list[IssueTemplate] = []
        for f in files:
            if not f.get("name", "").endswith((".md", ".yml", ".yaml")):
                continue
            try:
                # f["url"] は GitHub API のフル URL なのでベース URL を除去して相対パスにする
                file_url = f["url"]
                if file_url.startswith(self._client.base_url):
                    file_url = file_url[len(self._client.base_url) :]
                file_resp = self._client.get(file_url)
                content = base64.b64decode(file_resp.json().get("content", "")).decode(
                    "utf-8", errors="replace"
                )
                template = self._parse_issue_template(content)
                templates.append(template)
            except (GfoError, requests.RequestException, KeyError, ValueError):
                continue
        return templates

    @staticmethod
    def _parse_issue_template(content: str) -> IssueTemplate:
        """GitHub Issue Template の frontmatter を簡易パースする。"""
        import re  # noqa: PLC0415

        name = title = about = ""
        labels: list[str] = []
        body = content
        if content.startswith("---"):
            parts = content.split("---", 2)
            if len(parts) >= 3:
                front = parts[1]
                body = parts[2].strip()
                m = re.search(r"^name:\s*(.+)$", front, re.MULTILINE)
                if m:
                    name = m.group(1).strip().strip("\"'")
                m = re.search(r"^title:\s*(.+)$", front, re.MULTILINE)
                if m:
                    title = m.group(1).strip().strip("\"'")
                m = re.search(r"^about:\s*(.+)$", front, re.MULTILINE)
                if m:
                    about = m.group(1).strip().strip("\"'")
                m = re.search(r"^labels:\s*\[(.+)\]", front, re.MULTILINE)
                if m:
                    labels = [lb.strip().strip("\"'") for lb in m.group(1).split(",")]
                elif re.search(r"^labels:", front, re.MULTILINE):
                    for lm in re.finditer(
                        r"^\s*-\s*(.+)$", front[front.index("labels:") :], re.MULTILINE
                    ):
                        labels.append(lm.group(1).strip().strip("\"'"))
        return IssueTemplate(
            name=name or "Untitled",
            title=title,
            body=body,
            about=about,
            labels=tuple(labels),
        )

    # --- Repository ---

    def list_repositories(self, *, owner: str | None = None, limit: int = 30) -> list[Repository]:
        if owner is not None:
            path = f"/users/{quote(owner, safe='')}/repos"
        else:
            path = "/user/repos"
        results = paginate_link_header(self._client, path, limit=limit)
        return [self._to_repository(r) for r in results]

    def create_repository(
        self, *, name: str, private: bool = False, description: str = ""
    ) -> Repository:
        payload = {"name": name, "private": private, "description": description}
        resp = self._client.post("/user/repos", json=payload)
        return self._to_repository(resp.json())

    def get_repository(self, owner: str | None = None, name: str | None = None) -> Repository:
        o = owner if owner is not None else self._owner
        n = name if name is not None else self._repo
        resp = self._client.get(f"/repos/{quote(o, safe='')}/{quote(n, safe='')}")
        return self._to_repository(resp.json())

    def delete_repository(self) -> None:
        self._client.delete(self._repos_path())

    def update_repository(
        self,
        *,
        name: str | None = None,
        description: str | None = None,
        private: bool | None = None,
        default_branch: str | None = None,
    ) -> Repository:
        payload: dict = {}
        if name is not None:
            payload["name"] = name
        if description is not None:
            payload["description"] = description
        if private is not None:
            payload["private"] = private
        if default_branch is not None:
            payload["default_branch"] = default_branch
        resp = self._client.patch(self._repos_path(), json=payload)
        return self._to_repository(resp.json())

    def archive_repository(self) -> None:
        self._client.patch(self._repos_path(), json={"archived": True})

    def get_languages(self) -> dict[str, int | float]:
        resp = self._client.get(f"{self._repos_path()}/languages")
        return dict(resp.json())

    def list_topics(self) -> list[str]:
        resp = self._client.get(f"{self._repos_path()}/topics")
        return list(resp.json().get("names", []))

    def set_topics(self, topics: list[str]) -> list[str]:
        resp = self._client.put(f"{self._repos_path()}/topics", json={"names": topics})
        return list(resp.json().get("names", []))

    def compare(self, base: str, head: str) -> CompareResult:
        resp = self._client.get(
            f"{self._repos_path()}/compare/{quote(base, safe='')}...{quote(head, safe='')}"
        )
        data = resp.json()
        files = tuple(
            CompareFile(
                filename=f.get("filename", ""),
                status=f.get("status", "modified"),
                additions=f.get("additions", 0),
                deletions=f.get("deletions", 0),
            )
            for f in (data.get("files") or [])
        )
        return CompareResult(
            total_commits=data.get("total_commits", 0),
            ahead_by=data.get("ahead_by", 0),
            behind_by=data.get("behind_by", 0),
            files=files,
        )

    def migrate_repository(
        self,
        clone_url: str,
        name: str,
        *,
        private: bool = False,
        description: str = "",
        mirror: bool = False,
        auth_token: str | None = None,
    ) -> Repository:
        # リポジトリ作成
        repo = self.create_repository(name=name, private=private, description=description)
        # Source Import API
        payload: dict = {"vcs": "git", "vcs_url": clone_url}
        if auth_token:
            payload["vcs_username"] = "x-access-token"
            payload["vcs_password"] = auth_token
        self._client.put(
            f"/repos/{quote(self._owner, safe='')}/{quote(name, safe='')}/import",
            json=payload,
        )
        return repo

    # --- Release ---

    def list_releases(self, *, limit: int = 30) -> list[Release]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/releases",
            limit=limit,
        )
        return [self._to_release(r) for r in results]

    def create_release(
        self,
        *,
        tag: str,
        title: str = "",
        notes: str = "",
        draft: bool = False,
        prerelease: bool = False,
        target: str | None = None,
    ) -> Release:
        payload = {
            "tag_name": tag,
            "name": title,
            "body": notes,
            "draft": draft,
            "prerelease": prerelease,
        }
        if target:
            payload["target_commitish"] = target
        resp = self._client.post(f"{self._repos_path()}/releases", json=payload)
        return self._to_release(resp.json())

    def delete_release(self, *, tag: str) -> None:
        resp = self._client.get(f"{self._repos_path()}/releases/tags/{quote(tag, safe='')}")
        release_id = resp.json()["id"]
        self._client.delete(f"{self._repos_path()}/releases/{release_id}")

    def get_release(self, *, tag: str) -> Release:
        resp = self._client.get(f"{self._repos_path()}/releases/tags/{quote(tag, safe='')}")
        return self._to_release(resp.json())

    def update_release(
        self,
        *,
        tag: str,
        title: str | None = None,
        notes: str | None = None,
        draft: bool | None = None,
        prerelease: bool | None = None,
    ) -> Release:
        resp = self._client.get(f"{self._repos_path()}/releases/tags/{quote(tag, safe='')}")
        release_id = resp.json()["id"]
        payload: dict = {}
        if title is not None:
            payload["name"] = title
        if notes is not None:
            payload["body"] = notes
        if draft is not None:
            payload["draft"] = draft
        if prerelease is not None:
            payload["prerelease"] = prerelease
        resp = self._client.patch(f"{self._repos_path()}/releases/{release_id}", json=payload)
        return self._to_release(resp.json())

    def get_latest_release(self):
        resp = self._client.get(f"{self._repos_path()}/releases/latest")
        return self._to_release(resp.json())

    def list_release_assets(self, *, tag):
        resp = self._client.get(f"{self._repos_path()}/releases/tags/{quote(tag, safe='')}")
        assets = resp.json().get("assets") or []
        return [self._to_release_asset(a) for a in assets]

    def upload_release_asset(self, *, tag, file_path, name=None):
        import re

        resp = self._client.get(f"{self._repos_path()}/releases/tags/{quote(tag, safe='')}")
        release_data = resp.json()
        upload_url = release_data.get("upload_url", "")
        upload_url = re.sub(r"\{[^}]*\}", "", upload_url)
        resp = self._client.upload_file_absolute(upload_url, file_path, name=name)
        return self._to_release_asset(resp.json())

    def download_release_asset(self, *, tag, asset_id, output_dir):
        import os

        from gfo.exceptions import GfoError

        meta_resp = self._client.get(f"{self._repos_path()}/releases/assets/{asset_id}")
        asset_name = os.path.basename(meta_resp.json().get("name", f"asset-{asset_id}"))
        output_path = os.path.join(output_dir, asset_name)
        if not os.path.realpath(output_path).startswith(os.path.realpath(output_dir)):
            raise GfoError(f"Invalid asset name: {asset_name}")
        url = f"{self._client.base_url}{self._repos_path()}/releases/assets/{asset_id}"
        self._client.download_file(url, output_path, headers={"Accept": "application/octet-stream"})
        return output_path

    def delete_release_asset(self, *, tag, asset_id):
        self._client.delete(f"{self._repos_path()}/releases/assets/{asset_id}")

    def update_release_asset(
        self, *, tag: str, asset_id: int | str, name: str | None = None
    ) -> ReleaseAsset:
        payload: dict = {}
        if name is not None:
            payload["name"] = name
        resp = self._client.patch(f"{self._repos_path()}/releases/assets/{asset_id}", json=payload)
        return self._to_release_asset(resp.json())

    # --- Label ---

    def list_labels(self, *, limit: int = 0) -> list[Label]:
        results = paginate_link_header(self._client, f"{self._repos_path()}/labels", limit=limit)
        return [self._to_label(r) for r in results]

    def create_label(
        self, *, name: str, color: str | None = None, description: str | None = None
    ) -> Label:
        payload: dict = {"name": name}
        if color is not None:
            payload["color"] = color
        if description is not None:
            payload["description"] = description
        resp = self._client.post(f"{self._repos_path()}/labels", json=payload)
        return self._to_label(resp.json())

    def delete_label(self, *, name: str) -> None:
        self._client.delete(f"{self._repos_path()}/labels/{quote(name, safe='')}")

    def update_label(
        self,
        *,
        name: str,
        new_name: str | None = None,
        color: str | None = None,
        description: str | None = None,
    ) -> Label:
        quoted = quote(name, safe="")
        payload: dict = {}
        if new_name is not None:
            payload["new_name"] = new_name
        if color is not None:
            payload["color"] = color.removeprefix("#")
        if description is not None:
            payload["description"] = description
        resp = self._client.patch(f"{self._repos_path()}/labels/{quoted}", json=payload)
        return self._to_label(resp.json())

    # --- Milestone ---

    def list_milestones(self, *, limit: int = 0) -> list[Milestone]:
        results = paginate_link_header(
            self._client, f"{self._repos_path()}/milestones", limit=limit
        )
        return [self._to_milestone(r) for r in results]

    def create_milestone(
        self, *, title: str, description: str | None = None, due_date: str | None = None
    ) -> Milestone:
        payload: dict = {"title": title}
        if description is not None:
            payload["description"] = description
        if due_date is not None:
            payload["due_on"] = due_date
        resp = self._client.post(f"{self._repos_path()}/milestones", json=payload)
        return self._to_milestone(resp.json())

    def delete_milestone(self, *, number: int) -> None:
        self._client.delete(f"{self._repos_path()}/milestones/{number}")

    def get_milestone(self, number: int) -> Milestone:
        resp = self._client.get(f"{self._repos_path()}/milestones/{number}")
        return self._to_milestone(resp.json())

    def update_milestone(
        self,
        number: int,
        *,
        title: str | None = None,
        description: str | None = None,
        due_date: str | None = None,
        state: str | None = None,
    ) -> Milestone:
        payload: dict = {}
        if title is not None:
            payload["title"] = title
        if description is not None:
            payload["description"] = description
        if due_date is not None:
            payload["due_on"] = due_date
        if state is not None:
            payload["state"] = state
        resp = self._client.patch(f"{self._repos_path()}/milestones/{number}", json=payload)
        return self._to_milestone(resp.json())

    # --- Comment ---

    def list_comments(self, resource: str, number: int, *, limit: int = 30) -> list[Comment]:
        # resource が "pr" または "issue" のどちらでも /issues/{number}/comments エンドポイントを使う
        # （GitHub では PR コメントは issues/comments と同じ）
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/issues/{number}/comments",
            limit=limit,
        )
        return [self._to_comment(r) for r in results]

    def create_comment(self, resource: str, number: int, *, body: str) -> Comment:
        resp = self._client.post(
            f"{self._repos_path()}/issues/{number}/comments",
            json={"body": body},
        )
        return self._to_comment(resp.json())

    def update_comment(self, resource: str, comment_id: int, *, body: str) -> Comment:
        resp = self._client.patch(
            f"{self._repos_path()}/issues/comments/{comment_id}",
            json={"body": body},
        )
        return self._to_comment(resp.json())

    def delete_comment(self, resource: str, comment_id: int) -> None:
        self._client.delete(f"{self._repos_path()}/issues/comments/{comment_id}")

    # --- PR update ---

    def update_pull_request(
        self,
        number: int,
        *,
        title: str | None = None,
        body: str | None = None,
        base: str | None = None,
        add_labels: list[str] | None = None,
        remove_labels: list[str] | None = None,
        add_assignees: list[str] | None = None,
        remove_assignees: list[str] | None = None,
        milestone: str | None = None,
    ) -> PullRequest:
        payload: dict = {}
        if title is not None:
            payload["title"] = title
        if body is not None:
            payload["body"] = body
        if base is not None:
            payload["base"] = base
        resp = self._client.patch(f"{self._repos_path()}/pulls/{number}", json=payload)
        pr = self._to_pull_request(resp.json())
        # labels/assignees/milestone は issues API 経由で更新
        issue_payload: dict = {}
        if add_labels or remove_labels or add_assignees or remove_assignees:
            issue_resp = self._client.get(f"{self._repos_path()}/issues/{number}")
            issue_data = issue_resp.json()
            if add_labels or remove_labels:
                current = [lb["name"] for lb in issue_data.get("labels") or []]
                updated = set(current)
                if add_labels:
                    updated.update(add_labels)
                if remove_labels:
                    updated -= set(remove_labels)
                issue_payload["labels"] = sorted(updated)
            if add_assignees or remove_assignees:
                current = [a["login"] for a in issue_data.get("assignees") or []]
                updated = set(current)
                if add_assignees:
                    updated.update(add_assignees)
                if remove_assignees:
                    updated -= set(remove_assignees)
                issue_payload["assignees"] = sorted(updated)
        if milestone is not None:
            issue_payload["milestone"] = self._resolve_milestone_number(milestone)
        if issue_payload:
            self._client.patch(f"{self._repos_path()}/issues/{number}", json=issue_payload)
            resp = self._client.get(f"{self._repos_path()}/pulls/{number}")
            return self._to_pull_request(resp.json())
        return pr

    # --- PR diff / checks / files / commits / reviewers / update-branch / dismiss ---

    def get_pull_request_diff(self, number: int) -> str:
        resp = self._client.get(
            f"{self._repos_path()}/pulls/{number}",
            headers={"Accept": "application/vnd.github.diff"},
        )
        return str(resp.text)

    def list_pull_request_checks(self, number: int) -> list[CheckRun]:
        # PR の head SHA を取得
        pr_resp = self._client.get(f"{self._repos_path()}/pulls/{number}")
        sha = pr_resp.json()["head"]["sha"]

        # Check Runs（GitHub Actions 等）
        runs: list[CheckRun] = []
        resp = self._client.get(f"{self._repos_path()}/commits/{sha}/check-runs")
        for cr in resp.json().get("check_runs", []):
            raw_status = cr.get("status", "queued")
            if raw_status == "completed":
                conclusion = cr.get("conclusion") or "failure"
                status = {
                    "success": "success",
                    "failure": "failure",
                    "cancelled": "cancelled",
                    "timed_out": "failure",
                    "action_required": "failure",
                    "neutral": "success",
                    "skipped": "success",
                    "stale": "failure",
                }.get(conclusion, conclusion)
            elif raw_status == "in_progress":
                status = "running"
                conclusion = ""
            else:  # queued, waiting, etc.
                status = "pending"
                conclusion = ""
            runs.append(
                CheckRun(
                    name=cr.get("name") or "",
                    status=status,
                    conclusion=cr.get("conclusion") or conclusion,
                    url=cr.get("html_url") or cr.get("url") or "",
                    started_at=cr.get("started_at") or "",
                )
            )

        # Commit Statuses（外部 CI 等）
        status_resp = self._client.get(f"{self._repos_path()}/commits/{sha}/status")
        for s in status_resp.json().get("statuses", []):
            state = s.get("state") or ""
            status_map = {
                "success": "success",
                "failure": "failure",
                "error": "failure",
                "pending": "pending",
            }
            runs.append(
                CheckRun(
                    name=s.get("context") or "",
                    status=status_map.get(state, state),
                    conclusion=state,
                    url=s.get("target_url") or "",
                    started_at=s.get("created_at") or "",
                )
            )

        return runs

    def list_pull_request_files(self, number: int) -> list[PullRequestFile]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/pulls/{number}/files",
            limit=0,
        )
        return [self._to_pull_request_file(r) for r in results]

    def list_pull_request_commits(self, number: int) -> list[PullRequestCommit]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/pulls/{number}/commits",
            limit=0,
        )
        return [self._to_pull_request_commit(r) for r in results]

    def list_requested_reviewers(self, number: int) -> list[str]:
        resp = self._client.get(f"{self._repos_path()}/pulls/{number}/requested_reviewers")
        data = resp.json()
        users = data.get("users") or []
        return [u["login"] for u in users]

    def request_reviewers(self, number: int, reviewers: list[str]) -> None:
        self._client.post(
            f"{self._repos_path()}/pulls/{number}/requested_reviewers",
            json={"reviewers": reviewers},
        )

    def remove_reviewers(self, number: int, reviewers: list[str]) -> None:
        self._client.delete(
            f"{self._repos_path()}/pulls/{number}/requested_reviewers",
            json={"reviewers": reviewers},
        )

    def update_pull_request_branch(self, number: int) -> None:
        self._client.put(
            f"{self._repos_path()}/pulls/{number}/update-branch",
            json={},
        )

    def dismiss_review(self, number: int, review_id: int, *, message: str = "") -> None:
        self._client.put(
            f"{self._repos_path()}/pulls/{number}/reviews/{review_id}/dismissals",
            json={"message": message},
        )

    # --- Issue update ---

    def update_issue(
        self,
        number: int,
        *,
        title: str | None = None,
        body: str | None = None,
        assignee: str | None = None,
        label: str | None = None,
        add_labels: list[str] | None = None,
        remove_labels: list[str] | None = None,
        add_assignees: list[str] | None = None,
        remove_assignees: list[str] | None = None,
        milestone: str | None = None,
    ) -> Issue:
        payload: dict = {}
        if title is not None:
            payload["title"] = title
        if body is not None:
            payload["body"] = body
        if assignee is not None:
            payload["assignees"] = [assignee]
        if label is not None and (add_labels or remove_labels):
            import warnings

            warnings.warn(
                "label は add_labels/remove_labels と同時指定できません。label を無視します",
                stacklevel=2,
            )
        elif label is not None:
            payload["labels"] = [label]
        if add_labels or remove_labels or add_assignees or remove_assignees:
            issue_resp = self._client.get(f"{self._repos_path()}/issues/{number}")
            issue_data = issue_resp.json()
            if add_labels or remove_labels:
                current = [lb["name"] for lb in issue_data.get("labels") or []]
                updated = set(current)
                if add_labels:
                    updated.update(add_labels)
                if remove_labels:
                    updated -= set(remove_labels)
                payload["labels"] = sorted(updated)
            if add_assignees or remove_assignees:
                current = [a["login"] for a in issue_data.get("assignees") or []]
                updated = set(current)
                if add_assignees:
                    updated.update(add_assignees)
                if remove_assignees:
                    updated -= set(remove_assignees)
                payload["assignees"] = sorted(updated)
        if milestone is not None:
            payload["milestone"] = self._resolve_milestone_number(milestone)
        resp = self._client.patch(f"{self._repos_path()}/issues/{number}", json=payload)
        return self._to_issue(resp.json())

    # --- Review ---

    def list_reviews(self, number: int) -> list[Review]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/pulls/{number}/reviews",
            limit=0,
        )
        return [self._to_review(r) for r in results]

    def create_review(self, number: int, *, state: str, body: str = "") -> Review:
        payload: dict = {"event": state.upper()}
        if body:
            payload["body"] = body
        resp = self._client.post(
            f"{self._repos_path()}/pulls/{number}/reviews",
            json=payload,
        )
        return self._to_review(resp.json())

    # --- Branch ---

    def get_branch(self, name: str) -> Branch:
        resp = self._client.get(f"{self._repos_path()}/branches/{quote(name, safe='')}")
        return self._to_branch(resp.json())

    def list_branches(self, *, limit: int = 30) -> list[Branch]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/branches",
            limit=limit,
        )
        return [self._to_branch(r) for r in results]

    def create_branch(self, *, name: str, ref: str) -> Branch:
        # ref が SHA でなければまず解決
        sha = ref
        if not (len(ref) == 40 and all(c in "0123456789abcdef" for c in ref)):
            resp = self._client.get(f"{self._repos_path()}/git/ref/heads/{quote(ref, safe='')}")
            sha = resp.json()["object"]["sha"]
        self._client.post(
            f"{self._repos_path()}/git/refs",
            json={"ref": f"refs/heads/{name}", "sha": sha},
        )
        resp = self._client.get(f"{self._repos_path()}/branches/{quote(name, safe='')}")
        return self._to_branch(resp.json())

    def delete_branch(self, *, name: str) -> None:
        self._client.delete(f"{self._repos_path()}/git/refs/heads/{quote(name, safe='')}")

    # --- Tag ---

    def get_tag(self, name: str) -> Tag:
        # GitHub REST API にはタグ単体取得エンドポイントがないため、一覧から検索する
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/tags",
            limit=0,
        )
        for t in results:
            if t.get("name") == name:
                return self._to_tag(t)
        from gfo.exceptions import NotFoundError

        raise NotFoundError(f"Tag '{name}' not found")

    def list_tags(self, *, limit: int = 30) -> list[Tag]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/tags",
            limit=limit,
        )
        return [self._to_tag(r) for r in results]

    def create_tag(self, *, name: str, ref: str, message: str = "") -> Tag:
        # lightweight tag: ref を直接 refs/tags に push
        sha = ref
        if not (len(ref) == 40 and all(c in "0123456789abcdef" for c in ref)):
            resp = self._client.get(f"{self._repos_path()}/git/ref/heads/{quote(ref, safe='')}")
            sha = resp.json()["object"]["sha"]
        self._client.post(
            f"{self._repos_path()}/git/refs",
            json={"ref": f"refs/tags/{name}", "sha": sha},
        )
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/tags",
            limit=0,
        )
        for t in results:
            if t.get("name") == name:
                return self._to_tag(t)
        from gfo.exceptions import GfoError

        raise GfoError(f"Tag '{name}' not found after creation")

    def delete_tag(self, *, name: str) -> None:
        self._client.delete(f"{self._repos_path()}/git/refs/tags/{quote(name, safe='')}")

    # --- CommitStatus ---

    def list_commit_statuses(self, ref: str, *, limit: int = 30) -> list[CommitStatus]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/statuses/{quote(ref, safe='')}",
            limit=limit,
        )
        return [self._to_commit_status(r) for r in results]

    def create_commit_status(
        self,
        ref: str,
        *,
        state: str,
        context: str = "",
        description: str = "",
        target_url: str = "",
    ) -> CommitStatus:
        payload: dict = {"state": state}
        if context:
            payload["context"] = context
        if description:
            payload["description"] = description
        if target_url:
            payload["target_url"] = target_url
        resp = self._client.post(
            f"{self._repos_path()}/statuses/{quote(ref, safe='')}",
            json=payload,
        )
        return self._to_commit_status(resp.json())

    # --- File ---

    def get_file_content(self, path: str, *, ref: str | None = None) -> tuple[str, str]:
        params: dict = {}
        if ref is not None:
            params["ref"] = ref
        resp = self._client.get(
            f"{self._repos_path()}/contents/{quote(path, safe='/')}",
            params=params,
        )
        data = resp.json()
        try:
            content = base64.b64decode(data["content"]).decode("utf-8")
            sha = data["sha"]
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: {e}") from e
        return content, sha

    def create_or_update_file(
        self,
        path: str,
        *,
        content: str,
        message: str,
        sha: str | None = None,
        branch: str | None = None,
    ) -> str | None:
        payload: dict = {
            "message": message,
            "content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
        }
        if sha is not None:
            payload["sha"] = sha
        if branch is not None:
            payload["branch"] = branch
        resp = self._client.put(
            f"{self._repos_path()}/contents/{quote(path, safe='/')}",
            json=payload,
        )
        sha = (resp.json().get("commit") or {}).get("sha")
        return str(sha) if sha else None

    def delete_file(
        self,
        path: str,
        *,
        sha: str,
        message: str,
        branch: str | None = None,
    ) -> None:
        payload: dict = {"message": message, "sha": sha}
        if branch is not None:
            payload["branch"] = branch
        self._client.delete(
            f"{self._repos_path()}/contents/{quote(path, safe='/')}",
            json=payload,
        )

    # --- Fork ---

    def fork_repository(self, *, organization: str | None = None) -> Repository:
        payload: dict = {}
        if organization is not None:
            payload["organization"] = organization
        resp = self._client.post(f"{self._repos_path()}/forks", json=payload)
        return self._to_repository(resp.json())

    def sync_fork(self, *, branch: str | None = None) -> None:
        if branch is None:
            branch = self.get_repository().default_branch
        self._client.post(f"{self._repos_path()}/merge-upstream", json={"branch": branch})

    # --- Webhook ---

    def list_webhooks(self, *, limit: int = 30) -> list[Webhook]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/hooks",
            limit=limit,
        )
        return [self._to_webhook(r) for r in results]

    def create_webhook(self, *, url: str, events: list[str], secret: str | None = None) -> Webhook:
        config: dict = {"url": url, "content_type": "json"}
        if secret is not None:
            config["secret"] = secret
        payload: dict = {"config": config, "events": events, "active": True}
        resp = self._client.post(f"{self._repos_path()}/hooks", json=payload)
        return self._to_webhook(resp.json())

    def delete_webhook(self, *, hook_id: int) -> None:
        self._client.delete(f"{self._repos_path()}/hooks/{hook_id}")

    def test_webhook(self, *, hook_id: int) -> None:
        self._client.post(f"{self._repos_path()}/hooks/{hook_id}/tests")

    def update_webhook(
        self,
        hook_id: int,
        *,
        url: str | None = None,
        events: list[str] | None = None,
        secret: str | None = None,
        active: bool | None = None,
    ) -> Webhook:
        payload: dict = {}
        if url is not None or secret is not None:
            config: dict = {}
            if url is not None:
                config["url"] = url
            config["content_type"] = "json"
            if secret is not None:
                config["secret"] = secret
            payload["config"] = config
        if events is not None:
            payload["events"] = events
        if active is not None:
            payload["active"] = active
        resp = self._client.patch(f"{self._repos_path()}/hooks/{hook_id}", json=payload)
        return self._to_webhook(resp.json())

    # --- DeployKey ---

    def get_deploy_key(self, key_id: int) -> DeployKey:
        resp = self._client.get(f"{self._repos_path()}/keys/{key_id}")
        return self._to_deploy_key(resp.json())

    def list_deploy_keys(self, *, limit: int = 30) -> list[DeployKey]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/keys",
            limit=limit,
        )
        return [self._to_deploy_key(r) for r in results]

    def create_deploy_key(self, *, title: str, key: str, read_only: bool = True) -> DeployKey:
        payload = {"title": title, "key": key, "read_only": read_only}
        resp = self._client.post(f"{self._repos_path()}/keys", json=payload)
        return self._to_deploy_key(resp.json())

    def delete_deploy_key(self, *, key_id: int) -> None:
        self._client.delete(f"{self._repos_path()}/keys/{key_id}")

    # --- Collaborator ---

    def list_collaborators(self, *, limit: int = 30) -> list[str]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/collaborators",
            limit=limit,
        )
        try:
            return [r["login"] for r in results]
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: {e}") from e

    def add_collaborator(self, *, username: str, permission: str = "write") -> None:
        self._client.put(
            f"{self._repos_path()}/collaborators/{quote(username, safe='')}",
            json={"permission": permission},
        )

    def remove_collaborator(self, *, username: str) -> None:
        self._client.delete(f"{self._repos_path()}/collaborators/{quote(username, safe='')}")

    # --- Pipeline (CI = GitHub Actions) ---

    def list_pipelines(self, *, ref: str | None = None, limit: int = 30) -> list[Pipeline]:
        params: dict = {}
        if ref is not None:
            params["branch"] = ref
        # /actions/runs はレスポンスが {"workflow_runs": [...]} 形式のためページネーション手動実装
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            req_params = {**params, "per_page": per_page, "page": page}
            resp = self._client.get(
                f"{self._repos_path()}/actions/runs",
                params=req_params,
            )
            body = resp.json()
            page_data: list[dict] = body.get("workflow_runs", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [self._to_pipeline(r) for r in results]

    def get_pipeline(self, pipeline_id: int | str) -> Pipeline:
        resp = self._client.get(f"{self._repos_path()}/actions/runs/{pipeline_id}")
        return self._to_pipeline(resp.json())

    def cancel_pipeline(self, pipeline_id: int | str) -> None:
        self._client.post(f"{self._repos_path()}/actions/runs/{pipeline_id}/cancel", json={})

    def trigger_pipeline(
        self, ref: str, *, workflow: str | None = None, inputs: dict | None = None
    ) -> Pipeline:
        from gfo.exceptions import GfoError

        if not workflow:
            raise GfoError("GitHub requires --workflow to trigger a pipeline.")
        payload: dict = {"ref": ref}
        if inputs:
            payload["inputs"] = inputs
        self._client.post(
            f"{self._repos_path()}/actions/workflows/{quote(workflow, safe='')}/dispatches",
            json=payload,
        )
        return Pipeline(id=0, status="pending", ref=ref, url="", created_at="")

    def retry_pipeline(self, pipeline_id: int | str) -> Pipeline:
        self._client.post(f"{self._repos_path()}/actions/runs/{pipeline_id}/rerun", json={})
        return self.get_pipeline(pipeline_id)

    def get_pipeline_logs(self, pipeline_id: int | str, *, job_id: int | str | None = None) -> str:
        if job_id is not None:
            resp = self._client.get(
                f"{self._repos_path()}/actions/jobs/{job_id}/logs",
                headers={"Accept": "application/vnd.github.v3+json"},
            )
            return str(resp.text)
        # 全ジョブのログを結合
        jobs_resp = self._client.get(f"{self._repos_path()}/actions/runs/{pipeline_id}/jobs")
        jobs = jobs_resp.json().get("jobs", [])
        logs = []
        for job in jobs:
            try:
                resp = self._client.get(
                    f"{self._repos_path()}/actions/jobs/{job['id']}/logs",
                    headers={"Accept": "application/vnd.github.v3+json"},
                )
                logs.append(f"=== {job.get('name', job['id'])} ===\n{resp.text}")
            except (GfoError, requests.RequestException):
                logs.append(f"=== {job.get('name', job['id'])} ===\n(log unavailable)")
        return "\n".join(logs)

    def list_workflows(self, *, limit: int = 30) -> list[Workflow]:
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                f"{self._repos_path()}/actions/workflows",
                params={"per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("workflows", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [self._to_workflow(r) for r in results]

    def enable_workflow(self, workflow_id: int | str) -> None:
        self._client.put(
            f"{self._repos_path()}/actions/workflows/{workflow_id}/enable",
            json={},
        )

    def disable_workflow(self, workflow_id: int | str) -> None:
        self._client.put(
            f"{self._repos_path()}/actions/workflows/{workflow_id}/disable",
            json={},
        )

    def list_artifacts(self, run_id: int | str, *, limit: int = 30) -> list[Artifact]:
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                f"{self._repos_path()}/actions/runs/{run_id}/artifacts",
                params={"per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("artifacts", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [self._to_artifact(r) for r in results]

    def download_artifact(
        self, run_id: int | str, artifact_id: int | str, *, output_dir: str = "."
    ) -> str:
        import os

        resp = self._client.get(f"{self._repos_path()}/actions/artifacts/{artifact_id}")
        data = resp.json()
        name = os.path.basename(data.get("name", f"artifact-{artifact_id}"))
        output_path = os.path.join(output_dir, f"{name}.zip")
        if not os.path.realpath(output_path).startswith(os.path.realpath(output_dir)):
            raise GfoError(f"Invalid artifact name: {name}")
        url = f"{self._client.base_url}{self._repos_path()}/actions/artifacts/{artifact_id}/zip"
        self._client.download_file(url, output_path)
        return output_path

    def download_run_logs(
        self, run_id: int | str, *, job_id: int | str | None = None, output_dir: str = "."
    ) -> str:
        import os

        if job_id is not None:
            output_path = os.path.join(output_dir, f"logs-{run_id}-job-{job_id}.txt")
            resp = self._client.get(
                f"{self._repos_path()}/actions/jobs/{job_id}/logs",
                headers={"Accept": "application/vnd.github.v3+json"},
            )
            with open(output_path, "w", encoding="utf-8") as f:
                f.write(resp.text)
        else:
            output_path = os.path.join(output_dir, f"logs-{run_id}.zip")
            url = f"{self._client.base_url}{self._repos_path()}/actions/runs/{run_id}/logs"
            self._client.download_file(url, output_path)
        return output_path

    @staticmethod
    def _to_workflow(data: dict) -> Workflow:
        try:
            state = data.get("state", "disabled")
            return Workflow(
                id=data["id"],
                name=data.get("name") or "",
                path=data.get("path") or "",
                state=state,
            )
        except (KeyError, TypeError) as e:
            raise GfoError(f"Unexpected API response: {e}") from e

    @staticmethod
    def _to_artifact(data: dict) -> Artifact:
        try:
            return Artifact(
                id=data["id"],
                name=data.get("name") or "",
                size=data.get("size_in_bytes") or 0,
                url=data.get("archive_download_url") or "",
                created_at=data.get("created_at") or "",
            )
        except (KeyError, TypeError) as e:
            raise GfoError(f"Unexpected API response: {e}") from e

    @staticmethod
    def _to_pipeline(data: dict) -> Pipeline:
        from gfo.exceptions import GfoError

        try:
            status_map = {
                "completed": lambda d: {
                    "success": "success",
                    "failure": "failure",
                    "cancelled": "cancelled",
                }.get(d.get("conclusion") or "", "failure"),
                "in_progress": lambda d: "running",
                "queued": lambda d: "pending",
                "waiting": lambda d: "pending",
            }
            raw_status = data.get("status", "queued")
            mapper = status_map.get(raw_status)
            status = mapper(data) if mapper else raw_status
            return Pipeline(
                id=data["id"],
                status=status,
                ref=data.get("head_branch") or "",
                url=data.get("html_url") or "",
                created_at=data.get("created_at") or "",
            )
        except (KeyError, TypeError) as e:
            raise GfoError(f"Unexpected API response: {e}") from e

    # --- User ---

    def get_current_user(self) -> dict:
        resp = self._client.get("/user")
        return dict(resp.json())

    # --- Secret ---

    def _secrets_base_path(self, scope: str | None) -> str:
        if scope:
            return f"/orgs/{quote(scope, safe='')}/actions/secrets"
        return f"{self._repos_path()}/actions/secrets"

    def list_secrets(self, *, scope: str | None = None, limit: int = 30) -> list[Secret]:
        base = self._secrets_base_path(scope)
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                base,
                params={"per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data = body.get("secrets", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [
            Secret(
                name=d["name"],
                created_at=d.get("created_at") or "",
                updated_at=d.get("updated_at") or "",
            )
            for d in results
        ]

    def set_secret(self, name: str, value: str, *, scope: str | None = None) -> Secret:
        base = self._secrets_base_path(scope)
        resp = self._client.get(f"{base}/public-key")
        pub_key_data = resp.json()
        encrypted = self._encrypt_secret(pub_key_data["key"], value)
        self._client.put(
            f"{base}/{quote(name, safe='')}",
            json={"encrypted_value": encrypted, "key_id": pub_key_data["key_id"]},
        )
        resp = self._client.get(f"{base}/{quote(name, safe='')}")
        data = resp.json()
        return Secret(
            name=data["name"],
            created_at=data.get("created_at") or "",
            updated_at=data.get("updated_at") or "",
        )

    def delete_secret(self, name: str, *, scope: str | None = None) -> None:
        base = self._secrets_base_path(scope)
        self._client.delete(f"{base}/{quote(name, safe='')}")

    @staticmethod
    def _encrypt_secret(public_key: str, secret_value: str) -> str:
        try:
            from nacl import encoding, public
        except ImportError:
            from gfo.exceptions import GfoError

            raise GfoError("PyNaCl is required for GitHub Secret encryption: pip install PyNaCl")
        key = public.PublicKey(public_key.encode(), encoding.Base64Encoder)
        sealed_box = public.SealedBox(key)
        encrypted = sealed_box.encrypt(secret_value.encode())
        return str(encoding.Base64Encoder().encode(encrypted).decode())

    # --- Variable ---

    def _variables_base_path(self, scope: str | None) -> str:
        if scope:
            return f"/orgs/{quote(scope, safe='')}/actions/variables"
        return f"{self._repos_path()}/actions/variables"

    def list_variables(self, *, scope: str | None = None, limit: int = 30) -> list[Variable]:
        base = self._variables_base_path(scope)
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                base,
                params={"per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data = body.get("variables", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [
            Variable(
                name=d["name"],
                value=d.get("value") or "",
                created_at=d.get("created_at") or "",
                updated_at=d.get("updated_at") or "",
            )
            for d in results
        ]

    def set_variable(
        self, name: str, value: str, *, scope: str | None = None, masked: bool = False
    ) -> Variable:
        from gfo.exceptions import NotFoundError

        base = self._variables_base_path(scope)
        try:
            self._client.get(f"{base}/{quote(name, safe='')}")
            self._client.patch(
                f"{base}/{quote(name, safe='')}",
                json={"name": name, "value": value},
            )
        except NotFoundError:
            self._client.post(
                base,
                json={"name": name, "value": value},
            )
        return Variable(name=name, value=value, created_at="", updated_at="")

    def get_variable(self, name: str, *, scope: str | None = None) -> Variable:
        resp = self._client.get(f"{self._variables_base_path(scope)}/{quote(name, safe='')}")
        data = resp.json()
        return Variable(
            name=data["name"],
            value=data.get("value") or "",
            created_at=data.get("created_at") or "",
            updated_at=data.get("updated_at") or "",
        )

    def delete_variable(self, name: str, *, scope: str | None = None) -> None:
        base = self._variables_base_path(scope)
        self._client.delete(f"{base}/{quote(name, safe='')}")

    # --- BranchProtection ---

    def list_branch_protections(self, *, limit: int = 30) -> list[BranchProtection]:
        results = paginate_link_header(self._client, f"{self._repos_path()}/branches", limit=0)
        protected = [r for r in results if r.get("protected")]
        bps = []
        from gfo.exceptions import HttpError, NotFoundError

        for b in protected[: limit if limit > 0 else None]:
            try:
                bp = self.get_branch_protection(b["name"])
                bps.append(bp)
            except (NotFoundError, HttpError):
                bps.append(
                    BranchProtection(
                        branch=b["name"],
                        require_reviews=0,
                        require_status_checks=(),
                        enforce_admins=False,
                        allow_force_push=False,
                        allow_deletions=False,
                    )
                )
        return bps

    def get_branch_protection(self, branch: str) -> BranchProtection:
        resp = self._client.get(
            f"{self._repos_path()}/branches/{quote(branch, safe='')}/protection"
        )
        data = resp.json()
        return self._to_branch_protection(branch, data)

    def set_branch_protection(
        self,
        branch: str,
        *,
        require_reviews: int | None = None,
        require_status_checks: list[str] | None = None,
        enforce_admins: bool | None = None,
        allow_force_push: bool | None = None,
        allow_deletions: bool | None = None,
    ) -> BranchProtection:
        # GitHub PUT は全フィールド必須なので、現在値を取得して補完する
        from gfo.exceptions import HttpError, NotFoundError

        try:
            current = self.get_branch_protection(branch)
        except (NotFoundError, HttpError):
            current = BranchProtection(
                branch=branch,
                require_reviews=0,
                require_status_checks=(),
                enforce_admins=False,
                allow_force_push=False,
                allow_deletions=False,
            )
        reviews = require_reviews if require_reviews is not None else current.require_reviews
        checks = (
            require_status_checks
            if require_status_checks is not None
            else list(current.require_status_checks)
        )
        enforce = enforce_admins if enforce_admins is not None else current.enforce_admins
        force_push = allow_force_push if allow_force_push is not None else current.allow_force_push
        deletions = allow_deletions if allow_deletions is not None else current.allow_deletions

        payload: dict = {
            "required_pull_request_reviews": {"required_approving_review_count": reviews}
            if reviews > 0
            else None,
            "required_status_checks": {"strict": True, "contexts": checks} if checks else None,
            "enforce_admins": enforce,
            "restrictions": None,
            "allow_force_pushes": force_push,
            "allow_deletions": deletions,
        }
        resp = self._client.put(
            f"{self._repos_path()}/branches/{quote(branch, safe='')}/protection",
            json=payload,
        )
        return self._to_branch_protection(branch, resp.json())

    def remove_branch_protection(self, branch: str) -> None:
        self._client.delete(f"{self._repos_path()}/branches/{quote(branch, safe='')}/protection")

    @staticmethod
    def _to_branch_protection(branch: str, data: dict) -> BranchProtection:
        reviews_obj = data.get("required_pull_request_reviews") or {}
        checks_obj = data.get("required_status_checks") or {}
        enforce_obj = data.get("enforce_admins")
        return BranchProtection(
            branch=branch,
            require_reviews=reviews_obj.get("required_approving_review_count", 0)
            if reviews_obj
            else 0,
            require_status_checks=tuple(checks_obj.get("contexts") or []),
            enforce_admins=enforce_obj.get("enabled", False)
            if isinstance(enforce_obj, dict)
            else bool(enforce_obj),
            allow_force_push=(data.get("allow_force_pushes") or {}).get("enabled", False)
            if isinstance(data.get("allow_force_pushes"), dict)
            else bool(data.get("allow_force_pushes", False)),
            allow_deletions=(data.get("allow_deletions") or {}).get("enabled", False)
            if isinstance(data.get("allow_deletions"), dict)
            else bool(data.get("allow_deletions", False)),
        )

    # --- Notification ---

    def list_notifications(
        self, *, unread_only: bool = False, limit: int = 30
    ) -> list[Notification]:
        params: dict = {}
        if not unread_only:
            params["all"] = "true"
        results = paginate_link_header(self._client, "/notifications", params=params, limit=limit)
        return [self._to_notification(d) for d in results]

    def mark_notification_read(self, notification_id: str) -> None:
        self._client.patch(f"/notifications/threads/{notification_id}", json={})

    def mark_all_notifications_read(self) -> None:
        self._client.put("/notifications", json={})

    @staticmethod
    def _to_notification(data: dict) -> Notification:
        try:
            subject = data.get("subject") or {}
            repo = data.get("repository") or {}
            return Notification(
                id=str(data["id"]),
                title=subject.get("title") or "",
                reason=data.get("reason") or "",
                unread=data.get("unread", False),
                repository=repo.get("full_name") or "",
                url=subject.get("url") or "",
                updated_at=data.get("updated_at") or "",
            )
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: missing field {e}") from e

    # --- Organization ---

    def list_organizations(self, *, limit: int = 30) -> list[Organization]:
        results = paginate_link_header(self._client, "/user/orgs", limit=limit)
        return [self._to_organization(d) for d in results]

    def get_organization(self, name: str) -> Organization:
        resp = self._client.get(f"/orgs/{quote(name, safe='')}")
        return self._to_organization(resp.json())

    def list_org_members(self, name: str, *, limit: int = 30) -> list[str]:
        results = paginate_link_header(
            self._client, f"/orgs/{quote(name, safe='')}/members", limit=limit
        )
        try:
            return [r["login"] for r in results]
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: {e}") from e

    def list_org_repos(self, name: str, *, limit: int = 30) -> list[Repository]:
        results = paginate_link_header(
            self._client, f"/orgs/{quote(name, safe='')}/repos", limit=limit
        )
        return [self._to_repository(r) for r in results]

    @staticmethod
    def _to_organization(data: dict) -> Organization:
        try:
            return Organization(
                name=data["login"],
                display_name=data.get("name") or data.get("login") or "",
                description=data.get("description"),
                url=data.get("html_url") or data.get("url") or "",
            )
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: missing field {e}") from e

    def create_organization(
        self, name: str, *, display_name: str | None = None, description: str | None = None
    ) -> Organization:
        payload: dict = {"login": name}
        if display_name:
            payload["profile_name"] = display_name
        if description:
            payload["description"] = description
        resp = self._client.post("/user/orgs", json=payload)
        data = resp.json()
        return Organization(
            name=data["login"],
            display_name=data.get("name") or data.get("login") or "",
            description=data.get("description"),
            url=data.get("html_url") or data.get("url") or "",
        )

    def delete_organization(self, name: str) -> None:
        self._client.delete(f"/orgs/{quote(name, safe='')}")

    def update_organization(
        self,
        name: str,
        *,
        display_name: str | None = None,
        description: str | None = None,
    ) -> Organization:
        payload: dict = {}
        if display_name is not None:
            payload["name"] = display_name
        if description is not None:
            payload["description"] = description
        resp = self._client.patch(f"/orgs/{quote(name, safe='')}", json=payload)
        return self._to_organization(resp.json())

    # --- SSH Key ---

    def get_ssh_key(self, key_id: int | str) -> SshKey:
        resp = self._client.get(f"/user/keys/{key_id}")
        return self._to_ssh_key(resp.json())

    def list_ssh_keys(self, *, limit: int = 30) -> list[SshKey]:
        results = paginate_link_header(self._client, "/user/keys", limit=limit)
        return [self._to_ssh_key(d) for d in results]

    def create_ssh_key(self, *, title: str, key: str) -> SshKey:
        resp = self._client.post("/user/keys", json={"title": title, "key": key})
        return self._to_ssh_key(resp.json())

    def delete_ssh_key(self, *, key_id: int | str) -> None:
        self._client.delete(f"/user/keys/{key_id}")

    @staticmethod
    def _to_ssh_key(data: dict) -> SshKey:
        try:
            return SshKey(
                id=data["id"],
                title=data.get("title") or "",
                key=data.get("key") or "",
                created_at=data.get("created_at") or "",
            )
        except (KeyError, TypeError) as e:
            from gfo.exceptions import GfoError

            raise GfoError(f"Unexpected API response: missing field {e}") from e

    # --- GPG Key ---

    def get_gpg_key(self, key_id: int | str) -> GpgKey:
        resp = self._client.get(f"/user/gpg_keys/{key_id}")
        return self._to_gpg_key(resp.json())

    def list_gpg_keys(self, *, limit: int = 30) -> list[GpgKey]:
        results = paginate_link_header(self._client, "/user/gpg_keys", limit=limit)
        return [self._to_gpg_key(r) for r in results]

    def create_gpg_key(self, *, armored_key: str) -> GpgKey:
        resp = self._client.post("/user/gpg_keys", json={"armored_public_key": armored_key})
        return self._to_gpg_key(resp.json())

    def delete_gpg_key(self, *, key_id: int | str) -> None:
        self._client.delete(f"/user/gpg_keys/{key_id}")

    @staticmethod
    def _to_gpg_key(data: dict) -> GpgKey:
        return GpgKey(
            id=data["id"],
            primary_key_id=data.get("primary_key_id") or "",
            public_key=data.get("public_key") or data.get("raw_key") or "",
            emails=tuple(e.get("email", "") for e in (data.get("emails") or [])),
            created_at=data.get("created_at") or "",
        )

    # --- Tag Protection ---

    def list_tag_protections(self, *, limit: int = 30) -> list[TagProtection]:
        resp = self._client.get(f"{self._repos_path()}/tags/protection")
        return [
            TagProtection(
                id=r["id"],
                pattern=r["pattern"],
                create_access_level="",
            )
            for r in resp.json()
        ]

    def create_tag_protection(
        self, pattern: str, *, create_access_level: str | None = None
    ) -> TagProtection:
        resp = self._client.post(f"{self._repos_path()}/tags/protection", json={"pattern": pattern})
        data = resp.json()
        return TagProtection(id=data["id"], pattern=data["pattern"], create_access_level="")

    def delete_tag_protection(self, protection_id: int | str) -> None:
        self._client.delete(f"{self._repos_path()}/tags/protection/{protection_id}")

    # --- Browse ---

    def get_web_url(self, resource: str = "repo", number: int | str | None = None) -> str:
        base = f"https://github.com/{self._owner}/{self._repo}"
        if resource == "pr":
            return f"{base}/pulls" if number is None else f"{base}/pull/{number}"
        if resource == "issue":
            return f"{base}/issues" if number is None else f"{base}/issues/{number}"
        if resource == "release":
            return f"{base}/releases" if number is None else f"{base}/releases/tag/{number}"
        if resource == "milestone":
            return f"{base}/milestones" if number is None else f"{base}/milestone/{number}"
        if resource == "settings":
            return f"{base}/settings"
        return base

    # --- Search ---

    def search_repositories(self, query: str, *, limit: int = 30) -> list[Repository]:
        # /search/repositories はレスポンスが {"items": [...]} 形式のためページネーション手動実装
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                "/search/repositories",
                params={"q": query, "per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("items", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [self._to_repository(r) for r in results]

    def search_issues(self, query: str, *, limit: int = 30) -> list[Issue]:
        # GitHub search/issues には is:issue または is:pull-request が必須
        search_query = (
            query if ("is:issue" in query or "is:pull-request" in query) else f"is:issue {query}"
        )
        # /search/issues はレスポンスが {"items": [...]} 形式のためページネーション手動実装
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                "/search/issues",
                params={"q": search_query, "per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("items", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [self._to_issue(r) for r in results if "pull_request" not in r]

    # --- Issue Reaction ---

    def list_issue_reactions(self, number: int) -> list[Reaction]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/issues/{number}/reactions",
            limit=0,
        )
        return [
            Reaction(
                id=r["id"],
                content=r["content"],
                user=r["user"]["login"],
                created_at=r.get("created_at") or "",
            )
            for r in results
        ]

    def add_issue_reaction(self, number: int, reaction: str) -> Reaction:
        resp = self._client.post(
            f"{self._repos_path()}/issues/{number}/reactions",
            json={"content": reaction},
        )
        r = resp.json()
        return Reaction(
            id=r["id"],
            content=r["content"],
            user=r["user"]["login"],
            created_at=r.get("created_at") or "",
        )

    def remove_issue_reaction(self, number: int, reaction: str) -> None:
        reactions = self.list_issue_reactions(number)
        for r in reactions:
            if r.content == reaction:
                self._client.delete(f"{self._repos_path()}/issues/{number}/reactions/{r.id}")
                return

    # --- Issue Timeline ---

    def get_issue_timeline(self, number: int, *, limit: int = 30) -> list[TimelineEvent]:
        results = paginate_link_header(
            self._client,
            f"{self._repos_path()}/issues/{number}/timeline",
            limit=limit,
        )
        return [
            TimelineEvent(
                id=e.get("id") or 0,
                event=e.get("event") or "",
                actor=(e.get("actor") or {}).get("login") or "",
                created_at=e.get("created_at") or "",
                detail=e.get("label", {}).get("name") or e.get("body") or "",
            )
            for e in results
        ]

    # --- Issue Lock ---

    def lock_issue(self, number: int, *, reason: str | None = None) -> None:
        payload: dict = {}
        if reason is not None:
            payload["lock_reason"] = reason
        self._client.put(
            f"{self._repos_path()}/issues/{number}/lock",
            json=payload,
        )

    def unlock_issue(self, number: int) -> None:
        self._client.delete(f"{self._repos_path()}/issues/{number}/lock")

    # --- Issue Subscribe ---

    def subscribe_issue(self, number: int) -> None:
        self._client.put(
            f"{self._repos_path()}/issues/{number}/subscription",
            json={"subscribed": True},
        )

    def unsubscribe_issue(self, number: int) -> None:
        self._client.put(
            f"{self._repos_path()}/issues/{number}/subscription",
            json={"subscribed": False},
        )

    # --- Issue Pin ---

    def pin_issue(self, number: int) -> None:
        self._client.post(f"{self._repos_path()}/issues/{number}/pin", json={})

    def unpin_issue(self, number: int) -> None:
        self._client.delete(f"{self._repos_path()}/issues/{number}/pin")

    # --- Search PR ---

    def search_pull_requests(
        self, query: str, *, state: str | None = None, limit: int = 30
    ) -> list[PullRequest]:
        search_query = f"is:pull-request {query}"
        if state and state != "all":
            search_query += f" is:{state}"
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                "/search/issues",
                params={"q": search_query, "per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("items", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        prs = []
        for r in results:
            pr_data = r.get("pull_request", {})
            prs.append(
                PullRequest(
                    number=r["number"],
                    title=r["title"],
                    body=r.get("body"),
                    state="merged" if pr_data.get("merged_at") else r["state"],
                    author=r["user"]["login"],
                    source_branch="",
                    target_branch="",
                    draft=r.get("draft", False),
                    url=r.get("html_url") or pr_data.get("html_url") or "",
                    created_at=r["created_at"],
                    updated_at=r.get("updated_at"),
                )
            )
        return prs

    # --- Search Commit ---

    def search_commits(
        self,
        query: str,
        *,
        author: str | None = None,
        since: str | None = None,
        until: str | None = None,
        limit: int = 30,
    ) -> list[Commit]:
        search_query = query
        if author:
            search_query += f" author:{author}"
        results: list[dict] = []
        per_page = min(limit, 30) if limit > 0 else 30
        page = 1
        while True:
            resp = self._client.get(
                "/search/commits",
                params={"q": search_query, "per_page": per_page, "page": page},
            )
            body = resp.json()
            page_data: list[dict] = body.get("items", []) if isinstance(body, dict) else []
            if not page_data:
                break
            results.extend(page_data)
            if limit > 0 and len(results) >= limit:
                results = results[:limit]
                break
            if len(page_data) < per_page:
                break
            page += 1
        return [
            Commit(
                sha=r.get("sha") or "",
                message=(r.get("commit") or {}).get("message") or "",
                author=(r.get("author") or {}).get("login")
                or (r.get("commit") or {}).get("author", {}).get("name")
                or "",
                url=r.get("html_url") or "",
                created_at=(r.get("commit") or {}).get("author", {}).get("date") or "",
            )
            for r in results
        ]

    # --- Repo Transfer ---

    def transfer_repository(self, new_owner: str, *, team_ids: list[int] | None = None) -> None:
        payload: dict = {"new_owner": new_owner}
        if team_ids:
            payload["team_ids"] = team_ids
        self._client.post(f"{self._repos_path()}/transfer", json=payload)

    # --- Repo Star ---

    def star_repository(self) -> None:
        self._client.put(f"/user/starred/{self._owner}/{self._repo}", json={})

    def unstar_repository(self) -> None:
        self._client.delete(f"/user/starred/{self._owner}/{self._repo}")
