"""GitHubAdapter のテスト。"""

from __future__ import annotations

import json

import pytest
import responses

from gfo.adapter.base import (
    Branch,
    CheckRun,
    Comment,
    CommitStatus,
    DeployKey,
    Issue,
    Milestone,
    Pipeline,
    PullRequest,
    PullRequestCommit,
    PullRequestFile,
    Release,
    Repository,
    Review,
    Tag,
    Webhook,
)
from gfo.adapter.github import GitHubAdapter
from gfo.adapter.registry import get_adapter_class
from gfo.exceptions import AuthenticationError, GfoError, NotFoundError, ServerError

BASE = "https://api.github.com"
REPOS = f"{BASE}/repos/test-owner/test-repo"


# --- サンプルデータ ---


def _pr_data(*, number=1, state="open", merged_at=None, draft=False, labels=None, assignees=None):
    data = {
        "number": number,
        "title": f"PR #{number}",
        "body": "description",
        "state": state,
        "merged_at": merged_at,
        "user": {"login": "author1"},
        "head": {"ref": "feature"},
        "base": {"ref": "main"},
        "draft": draft,
        "html_url": f"https://github.com/test-owner/test-repo/pull/{number}",
        "created_at": "2025-01-01T00:00:00Z",
        "updated_at": "2025-01-02T00:00:00Z",
    }
    if labels is not None:
        data["labels"] = labels
    if assignees is not None:
        data["assignees"] = assignees
    return data


def _issue_data(*, number=1, state="open", has_pr=False):
    data = {
        "number": number,
        "title": f"Issue #{number}",
        "body": "issue body",
        "state": state,
        "user": {"login": "reporter"},
        "assignees": [{"login": "dev1"}],
        "labels": [{"name": "bug"}],
        "html_url": f"https://github.com/test-owner/test-repo/issues/{number}",
        "created_at": "2025-01-01T00:00:00Z",
    }
    if has_pr:
        data["pull_request"] = {"url": "..."}
    return data


def _repo_data(*, name="test-repo", full_name="test-owner/test-repo"):
    return {
        "name": name,
        "full_name": full_name,
        "description": "A test repo",
        "private": False,
        "default_branch": "main",
        "clone_url": f"https://github.com/{full_name}.git",
        "html_url": f"https://github.com/{full_name}",
    }


def _release_data(*, tag="v1.0.0"):
    return {
        "tag_name": tag,
        "name": f"Release {tag}",
        "body": "release notes",
        "draft": False,
        "prerelease": False,
        "html_url": f"https://github.com/test-owner/test-repo/releases/tag/{tag}",
        "created_at": "2025-01-01T00:00:00Z",
    }


def _label_data(*, name="bug"):
    return {"name": name, "color": "d73a4a", "description": "Something isn't working"}


def _milestone_data(*, number=1):
    return {
        "number": number,
        "title": f"v{number}.0",
        "description": "milestone desc",
        "state": "open",
        "due_on": "2025-06-01T00:00:00Z",
    }


def _comment_data(*, comment_id=10):
    return {
        "id": comment_id,
        "body": "A comment",
        "user": {"login": "commenter"},
        "html_url": f"https://github.com/test-owner/test-repo/issues/1#issuecomment-{comment_id}",
        "created_at": "2025-01-01T00:00:00Z",
        "updated_at": "2025-01-02T00:00:00Z",
    }


def _review_data(*, review_id=20, state="APPROVED"):
    return {
        "id": review_id,
        "state": state,
        "body": "LGTM",
        "user": {"login": "reviewer"},
        "html_url": f"https://github.com/test-owner/test-repo/pull/1#pullrequestreview-{review_id}",
        "submitted_at": "2025-01-01T00:00:00Z",
    }


def _branch_data(*, name="feature", sha="abc123"):
    return {
        "name": name,
        "commit": {
            "sha": sha,
            "url": f"https://api.github.com/repos/test-owner/test-repo/commits/{sha}",
        },
        "protected": False,
    }


def _tag_data(*, name="v1.0.0", sha="def456"):
    return {
        "name": name,
        "commit": {"sha": sha},
        "zipball_url": "",
        "tarball_url": "",
    }


def _commit_status_data(*, state="success", context="ci/test"):
    return {
        "state": state,
        "context": context,
        "description": "Tests passed",
        "target_url": "https://ci.example.com/build/1",
        "created_at": "2025-01-01T00:00:00Z",
    }


def _webhook_data(*, hook_id=100):
    return {
        "id": hook_id,
        "config": {"url": "https://example.com/hook", "content_type": "json"},
        "events": ["push", "pull_request"],
        "active": True,
    }


def _deploy_key_data(*, key_id=200):
    return {
        "id": key_id,
        "title": "Deploy Key",
        "key": "ssh-rsa AAAA...",
        "read_only": True,
    }


def _pipeline_data(*, run_id=300, status="completed", conclusion="success"):
    return {
        "id": run_id,
        "status": status,
        "conclusion": conclusion,
        "head_branch": "main",
        "html_url": f"https://github.com/test-owner/test-repo/actions/runs/{run_id}",
        "created_at": "2025-01-01T00:00:00Z",
    }


# --- 変換メソッドのテスト ---


class TestToPullRequest:
    def test_open(self):
        pr = GitHubAdapter._to_pull_request(_pr_data())
        assert pr.state == "open"
        assert pr.number == 1
        assert pr.author == "author1"
        assert pr.source_branch == "feature"
        assert pr.draft is False

    def test_closed(self):
        pr = GitHubAdapter._to_pull_request(_pr_data(state="closed"))
        assert pr.state == "closed"

    def test_merged(self):
        pr = GitHubAdapter._to_pull_request(
            _pr_data(state="closed", merged_at="2025-01-03T00:00:00Z")
        )
        assert pr.state == "merged"


class TestToIssue:
    def test_basic(self):
        issue = GitHubAdapter._to_issue(_issue_data())
        assert issue.number == 1
        assert issue.author == "reporter"
        assert issue.assignees == ["dev1"]
        assert issue.labels == ["bug"]


class TestToRepository:
    def test_basic(self):
        repo = GitHubAdapter._to_repository(_repo_data())
        assert repo.name == "test-repo"
        assert repo.full_name == "test-owner/test-repo"
        assert repo.private is False


class TestToRelease:
    def test_basic(self):
        rel = GitHubAdapter._to_release(_release_data())
        assert rel.tag == "v1.0.0"
        assert rel.title == "Release v1.0.0"


class TestToLabel:
    def test_basic(self):
        label = GitHubAdapter._to_label(_label_data())
        assert label.name == "bug"
        assert label.color == "d73a4a"


class TestToMilestone:
    def test_basic(self):
        ms = GitHubAdapter._to_milestone(_milestone_data())
        assert ms.number == 1
        assert ms.due_date == "2025-06-01T00:00:00Z"


class TestToComment:
    def test_basic(self):
        comment = GitHubAdapter._to_comment(_comment_data())
        assert comment.id == 10
        assert comment.body == "A comment"
        assert comment.author == "commenter"

    def test_null_user(self):
        data = _comment_data()
        data["user"] = None
        from gfo.exceptions import GfoError

        with pytest.raises(GfoError):
            GitHubAdapter._to_comment(data)


class TestToReview:
    def test_basic(self):
        review = GitHubAdapter._to_review(_review_data())
        assert review.id == 20
        assert review.body == "LGTM"
        assert review.author == "reviewer"

    def test_state_approved(self):
        review = GitHubAdapter._to_review(_review_data(state="APPROVED"))
        assert review.state == "approved"

    def test_state_changes_requested(self):
        review = GitHubAdapter._to_review(_review_data(state="CHANGES_REQUESTED"))
        assert review.state == "changes_requested"

    def test_state_commented(self):
        review = GitHubAdapter._to_review(_review_data(state="COMMENTED"))
        assert review.state == "commented"


class TestToBranch:
    def test_basic(self):
        branch = GitHubAdapter._to_branch(_branch_data())
        assert branch.name == "feature"
        assert branch.sha == "abc123"
        assert branch.protected is False

    def test_commit_id_fallback(self):
        data = _branch_data()
        del data["commit"]["sha"]
        data["commit"]["id"] = "fallback_sha"
        # sha がない場合は commit.id にフォールバック（Gitea/Gogs 系との互換）
        branch = GitHubAdapter._to_branch(data)
        assert branch.sha == "fallback_sha"


class TestToTag:
    def test_basic(self):
        tag = GitHubAdapter._to_tag(_tag_data())
        assert tag.name == "v1.0.0"
        assert tag.sha == "def456"

    def test_with_message(self):
        data = _tag_data()
        data["message"] = "release v1.0.0"
        tag = GitHubAdapter._to_tag(data)
        # _to_tag は message フィールドを使わず常に "" を設定する
        assert tag.message == ""


class TestToCommitStatus:
    def test_basic(self):
        cs = GitHubAdapter._to_commit_status(_commit_status_data())
        assert cs.state == "success"
        assert cs.context == "ci/test"
        assert cs.description == "Tests passed"
        assert cs.target_url == "https://ci.example.com/build/1"
        assert cs.created_at == "2025-01-01T00:00:00Z"


class TestToWebhook:
    def test_basic(self):
        hook = GitHubAdapter._to_webhook(_webhook_data())
        assert hook.id == 100
        assert hook.url == "https://example.com/hook"
        assert hook.events == ("push", "pull_request")
        assert hook.active is True


class TestToDeployKey:
    def test_basic(self):
        dk = GitHubAdapter._to_deploy_key(_deploy_key_data())
        assert dk.id == 200
        assert dk.title == "Deploy Key"
        assert dk.key == "ssh-rsa AAAA..."
        assert dk.read_only is True


class TestToPipeline:
    def test_completed_success(self):
        pl = GitHubAdapter._to_pipeline(_pipeline_data(status="completed", conclusion="success"))
        assert pl.status == "success"
        assert pl.id == 300
        assert pl.ref == "main"

    def test_completed_failure(self):
        pl = GitHubAdapter._to_pipeline(_pipeline_data(status="completed", conclusion="failure"))
        assert pl.status == "failure"

    def test_in_progress(self):
        pl = GitHubAdapter._to_pipeline(_pipeline_data(status="in_progress", conclusion=None))
        assert pl.status == "running"

    def test_queued(self):
        pl = GitHubAdapter._to_pipeline(_pipeline_data(status="queued", conclusion=None))
        assert pl.status == "pending"


# --- PR 系 ---


class TestListPullRequests:
    def test_open(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[_pr_data()],
            status=200,
        )
        prs = github_adapter.list_pull_requests()
        assert len(prs) == 1
        assert prs[0].state == "open"

    def test_merged_filter(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                _pr_data(number=1, state="closed", merged_at="2025-01-03T00:00:00Z"),
                _pr_data(number=2, state="closed"),
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(state="merged")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_pagination(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[_pr_data(number=1)],
            status=200,
            headers={"Link": f'<{REPOS}/pulls?page=2>; rel="next"'},
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[_pr_data(number=2)],
            status=200,
        )
        prs = github_adapter.list_pull_requests(limit=10)
        assert len(prs) == 2

    def test_base_filter(self, mock_responses, github_adapter):
        """base パラメータがクエリパラメータとして送信されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[_pr_data()],
            status=200,
        )
        github_adapter.list_pull_requests(base="main")
        assert "base=main" in mock_responses.calls[0].request.url

    def test_head_filter(self, mock_responses, github_adapter):
        """head パラメータがクエリパラメータとして送信されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[_pr_data()],
            status=200,
        )
        github_adapter.list_pull_requests(head="feature")
        assert "head=feature" in mock_responses.calls[0].request.url

    def test_author_client_filter(self, mock_responses, github_adapter):
        """author フィルタがクライアント側で適用されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                _pr_data(number=1),  # user.login = "author1"
                {**_pr_data(number=2), "user": {"login": "author2"}},
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(author="author1")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_label_client_filter(self, mock_responses, github_adapter):
        """label フィルタがクライアント側で適用されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                _pr_data(number=1, labels=[{"name": "bug"}, {"name": "urgent"}]),
                _pr_data(number=2, labels=[{"name": "feature"}]),
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(label="bug")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_assignee_client_filter(self, mock_responses, github_adapter):
        """assignee フィルタがクライアント側で適用されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                _pr_data(number=1, assignees=[{"login": "dev1"}]),
                _pr_data(number=2, assignees=[{"login": "dev2"}]),
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(assignee="dev1")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_draft_client_filter(self, mock_responses, github_adapter):
        """draft フィルタがクライアント側で適用されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                _pr_data(number=1, draft=True),
                _pr_data(number=2, draft=False),
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(draft=True)
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_search_client_filter(self, mock_responses, github_adapter):
        """search フィルタがタイトル部分一致でクライアント側フィルタされることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                {**_pr_data(number=1), "title": "Fix login bug"},
                {**_pr_data(number=2), "title": "Add feature"},
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(search="login")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_search_client_filter_body(self, mock_responses, github_adapter):
        """search フィルタが body 部分一致でもマッチすることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls",
            json=[
                {**_pr_data(number=1), "title": "unrelated", "body": "Fix login bug details"},
                {**_pr_data(number=2), "title": "unrelated2", "body": "something else"},
            ],
            status=200,
        )
        prs = github_adapter.list_pull_requests(search="login")
        assert len(prs) == 1
        assert prs[0].number == 1

    def test_client_filter_fetches_all_then_limits(self, mock_responses, github_adapter):
        """クライアント側フィルタ使用時は全件取得後にlimitでスライスする。"""
        prs = [_pr_data(number=i) for i in range(1, 6)]
        mock_responses.add(responses.GET, f"{REPOS}/pulls", json=prs, status=200)
        result = github_adapter.list_pull_requests(author="author1", limit=2)
        assert len(result) == 2
        # クライアント側フィルタ時は limit=0（全件取得）で fetch される
        url = mock_responses.calls[0].request.url
        assert "per_page=30" in url  # limit=0 時は per_page=30 (デフォルト)


class TestCreatePullRequest:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/pulls",
            json=_pr_data(),
            status=201,
        )
        pr = github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
        )
        assert isinstance(pr, PullRequest)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["draft"] is False

    def test_create_draft(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/pulls",
            json=_pr_data(draft=True),
            status=201,
        )
        _ = github_adapter.create_pull_request(
            title="Draft",
            body="",
            base="main",
            head="feature",
            draft=True,
        )
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["draft"] is True

    def test_create_with_reviewers(self, mock_responses, github_adapter):
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        mock_responses.add(
            responses.POST, f"{REPOS}/pulls/1/requested_reviewers", json={}, status=201
        )
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
            reviewers=["alice", "bob"],
        )
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["reviewers"] == ["alice", "bob"]

    def test_create_with_labels_and_assignees(self, mock_responses, github_adapter):
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
            labels=["bug", "urgent"],
            assignees=["alice"],
        )
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["labels"] == ["bug", "urgent"]
        assert req_body["assignees"] == ["alice"]

    def test_create_with_milestone(self, mock_responses, github_adapter):
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[
                {
                    "number": 1,
                    "id": 1,
                    "title": "v1.0",
                    "description": None,
                    "state": "open",
                    "due_on": None,
                },
            ],
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
            milestone="v1.0",
        )
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert req_body["milestone"] == 1

    def test_create_with_milestone_not_found(self, mock_responses, github_adapter):
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        mock_responses.add(responses.GET, f"{REPOS}/milestones", json=[], status=200)
        with pytest.raises(GfoError, match="Milestone not found"):
            github_adapter.create_pull_request(
                title="PR #1",
                body="desc",
                base="main",
                head="feature",
                milestone="nonexistent",
            )

    def test_create_with_patch_re_fetches(self, mock_responses, github_adapter):
        """PATCH 後に get_pull_request で再取得して最新状態を返す（#8）。"""
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        updated = _pr_data()
        updated["title"] = "Updated PR #1"
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=updated, status=200)
        pr = github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
            labels=["bug"],
        )
        assert pr.title == "Updated PR #1"
        assert mock_responses.calls[2].request.method == "GET"

    def test_create_without_extras_does_not_re_fetch(self, mock_responses, github_adapter):
        """追加パラメータなしの場合、再取得しない。"""
        mock_responses.add(responses.POST, f"{REPOS}/pulls", json=_pr_data(), status=201)
        pr = github_adapter.create_pull_request(
            title="PR #1",
            body="desc",
            base="main",
            head="feature",
        )
        assert pr.title == "PR #1"
        assert len(mock_responses.calls) == 1


class TestGetPullRequest:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/42",
            json=_pr_data(number=42),
            status=200,
        )
        pr = github_adapter.get_pull_request(42)
        assert pr.number == 42


class TestMergePullRequest:
    def test_merge(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/merge",
            json={"merged": True},
            status=200,
        )
        github_adapter.merge_pull_request(1)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["merge_method"] == "merge"

    def test_merge_squash(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/merge",
            json={"merged": True},
            status=200,
        )
        github_adapter.merge_pull_request(1, method="squash")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["merge_method"] == "squash"

    def test_merge_with_commit_title_and_message(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/merge",
            json={"merged": True},
            status=200,
        )
        github_adapter.merge_pull_request(1, title="Custom title", message="Custom body")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["commit_title"] == "Custom title"
        assert req_body["commit_message"] == "Custom body"

    def test_merge_with_title_only(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/merge",
            json={"merged": True},
            status=200,
        )
        github_adapter.merge_pull_request(1, title="Title only")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["commit_title"] == "Title only"
        assert "commit_message" not in req_body


class TestClosePullRequest:
    def test_close(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/pulls/1",
            json=_pr_data(state="closed"),
            status=200,
        )
        github_adapter.close_pull_request(1)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "closed"


class TestReopenPullRequest:
    def test_reopen(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/pulls/1",
            json=_pr_data(state="open"),
            status=200,
        )
        github_adapter.reopen_pull_request(1)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "open"


class TestCheckoutRefspec:
    def test_refspec(self, github_adapter):
        assert github_adapter.get_pr_checkout_refspec(42) == "refs/pull/42/head"


# --- Issue 系 ---


class TestListIssues:
    def test_excludes_prs(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues",
            json=[_issue_data(number=1), _issue_data(number=2, has_pr=True)],
            status=200,
        )
        issues = github_adapter.list_issues()
        assert len(issues) == 1
        assert issues[0].number == 1

    def test_with_filters(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues",
            json=[_issue_data()],
            status=200,
        )
        issues = github_adapter.list_issues(assignee="dev1", label="bug")
        assert len(issues) == 1
        req = mock_responses.calls[0].request
        assert "assignee=dev1" in req.url
        assert "labels=bug" in req.url

    def test_author_filter(self, mock_responses, github_adapter):
        """author フィルタが creator パラメータとして送信されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues",
            json=[_issue_data()],
            status=200,
        )
        github_adapter.list_issues(author="alice")
        req = mock_responses.calls[0].request
        assert "creator=alice" in req.url

    def test_milestone_filter(self, mock_responses, github_adapter):
        """milestone フィルタがマイルストーン番号に解決されて送信されることを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[{"number": 3, "title": "v1.0", "state": "open", "due_on": None}],
            status=200,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues",
            json=[_issue_data()],
            status=200,
        )
        github_adapter.list_issues(milestone="v1.0")
        req = mock_responses.calls[1].request
        assert "milestone=3" in req.url

    def test_search_filter(self, mock_responses, github_adapter):
        """search フィルタがクライアント側でタイトル/ボディを部分一致検索することを確認する。"""
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues",
            json=[
                {**_issue_data(number=1), "title": "Login error", "body": "details"},
                {**_issue_data(number=2), "title": "Other", "body": "unrelated"},
            ],
            status=200,
        )
        issues = github_adapter.list_issues(search="login")
        assert len(issues) == 1
        assert issues[0].number == 1


class TestCreateIssue:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/issues",
            json=_issue_data(),
            status=201,
        )
        issue = github_adapter.create_issue(title="Issue #1", body="body")
        assert isinstance(issue, Issue)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "assignees" not in req_body
        assert "labels" not in req_body

    def test_create_with_assignee_and_label(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/issues",
            json=_issue_data(),
            status=201,
        )
        github_adapter.create_issue(
            title="Issue",
            assignee="dev1",
            label="bug",
        )
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["assignees"] == ["dev1"]
        assert req_body["labels"] == ["bug"]


class TestGetIssue:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/5",
            json=_issue_data(number=5),
            status=200,
        )
        issue = github_adapter.get_issue(5)
        assert issue.number == 5


class TestCloseIssue:
    def test_close(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/issues/3",
            json=_issue_data(number=3, state="closed"),
            status=200,
        )
        github_adapter.close_issue(3)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "closed"


class TestReopenIssue:
    def test_reopen(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/issues/3",
            json=_issue_data(number=3, state="open"),
            status=200,
        )
        github_adapter.reopen_issue(3)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "open"


# --- Repository 系 ---


class TestListRepositories:
    def test_with_owner(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/users/someone/repos",
            json=[_repo_data()],
            status=200,
        )
        repos = github_adapter.list_repositories(owner="someone")
        assert len(repos) == 1

    def test_no_owner(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/user/repos",
            json=[_repo_data()],
            status=200,
        )
        repos = github_adapter.list_repositories()
        assert len(repos) == 1

    def test_owner_with_special_chars_is_encoded(self, mock_responses, github_adapter):
        """list_repositories(owner="...") で特殊文字が URL エンコードされる（R41-01）。"""
        mock_responses.add(
            responses.GET,
            f"{BASE}/users/org%2Fsub/repos",
            json=[_repo_data()],
            status=200,
        )
        github_adapter.list_repositories(owner="org/sub")
        assert "%2F" in mock_responses.calls[0].request.url


class TestCreateRepository:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{BASE}/user/repos",
            json=_repo_data(),
            status=201,
        )
        repo = github_adapter.create_repository(name="test-repo")
        assert isinstance(repo, Repository)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["name"] == "test-repo"


class TestGetRepository:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/repos/other/other-repo",
            json=_repo_data(name="other-repo", full_name="other/other-repo"),
            status=200,
        )
        repo = github_adapter.get_repository(owner="other", name="other-repo")
        assert repo.name == "other-repo"

    def test_get_defaults(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}",
            json=_repo_data(),
            status=200,
        )
        repo = github_adapter.get_repository()
        assert repo.full_name == "test-owner/test-repo"

    def test_get_owner_with_special_chars_is_encoded(self, mock_responses, github_adapter):
        """owner に特殊文字が含まれる場合、URL エンコードされてリクエストされる。"""
        mock_responses.add(
            responses.GET,
            f"{BASE}/repos/org%2Fsub/repo",
            json=_repo_data(name="repo", full_name="org/sub/repo"),
            status=200,
        )
        github_adapter.get_repository(owner="org/sub", name="repo")
        assert "%2F" in mock_responses.calls[0].request.url


# --- Release 系 ---


class TestListReleases:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases",
            json=[_release_data()],
            status=200,
        )
        releases = github_adapter.list_releases()
        assert len(releases) == 1
        assert releases[0].tag == "v1.0.0"


class TestCreateRelease:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/releases",
            json=_release_data(),
            status=201,
        )
        rel = github_adapter.create_release(tag="v1.0.0", title="Release v1.0.0")
        assert isinstance(rel, Release)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["tag_name"] == "v1.0.0"

    def test_create_with_target(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/releases",
            json=_release_data(),
            status=201,
        )
        github_adapter.create_release(tag="v1.0.0", target="develop")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["target_commitish"] == "develop"

    def test_create_without_target_omits_target_commitish(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/releases",
            json=_release_data(),
            status=201,
        )
        github_adapter.create_release(tag="v1.0.0")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "target_commitish" not in req_body


class TestGetRelease:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json=_release_data(),
            status=200,
        )
        rel = github_adapter.get_release(tag="v1.0.0")
        assert isinstance(rel, Release)
        assert rel.tag == "v1.0.0"

    def test_tag_url_encoded(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0%2Brc1",
            json=_release_data(tag="v1.0.0+rc1"),
            status=200,
        )
        rel = github_adapter.get_release(tag="v1.0.0+rc1")
        assert rel.tag == "v1.0.0+rc1"


class TestUpdateRelease:
    def test_update(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={"id": 42, "tag_name": "v1.0.0"},
            status=200,
        )
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/releases/42",
            json=_release_data(),
            status=200,
        )
        rel = github_adapter.update_release(tag="v1.0.0", title="Updated")
        assert isinstance(rel, Release)
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["name"] == "Updated"

    def test_update_all_fields(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={"id": 42, "tag_name": "v1.0.0"},
            status=200,
        )
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/releases/42",
            json=_release_data(),
            status=200,
        )
        github_adapter.update_release(
            tag="v1.0.0",
            title="New",
            notes="Notes",
            draft=True,
            prerelease=True,
        )
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["name"] == "New"
        assert req_body["body"] == "Notes"
        assert req_body["draft"] is True
        assert req_body["prerelease"] is True

    def test_update_no_optional_fields(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={"id": 42, "tag_name": "v1.0.0"},
            status=200,
        )
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/releases/42",
            json=_release_data(),
            status=200,
        )
        github_adapter.update_release(tag="v1.0.0")
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert "name" not in req_body
        assert "body" not in req_body
        assert "draft" not in req_body
        assert "prerelease" not in req_body


# --- Label 系 ---


class TestListLabels:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/labels",
            json=[_label_data(), _label_data(name="enhancement")],
            status=200,
        )
        labels = github_adapter.list_labels()
        assert len(labels) == 2


class TestCreateLabel:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/labels",
            json=_label_data(),
            status=201,
        )
        label = github_adapter.create_label(name="bug", color="d73a4a")
        assert label.name == "bug"

    def test_create_optional_fields(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/labels",
            json={"name": "minimal", "color": None, "description": None},
            status=201,
        )
        github_adapter.create_label(name="minimal")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "color" not in req_body
        assert "description" not in req_body

    def test_create_with_description(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/labels",
            json={"name": "bug", "color": "d73a4a", "description": "Something broken"},
            status=201,
        )
        github_adapter.create_label(name="bug", color="d73a4a", description="Something broken")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["description"] == "Something broken"


# --- Milestone 系 ---


class TestListMilestones:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[_milestone_data()],
            status=200,
        )
        milestones = github_adapter.list_milestones()
        assert len(milestones) == 1
        assert milestones[0].title == "v1.0"


class TestCreateMilestone:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/milestones",
            json=_milestone_data(),
            status=201,
        )
        ms = github_adapter.create_milestone(title="v1.0", due_date="2025-06-01T00:00:00Z")
        assert isinstance(ms, Milestone)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["due_on"] == "2025-06-01T00:00:00Z"

    def test_create_optional_fields(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/milestones",
            json=_milestone_data(),
            status=201,
        )
        github_adapter.create_milestone(title="v1.0")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "description" not in req_body
        assert "due_on" not in req_body

    def test_create_with_description(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/milestones",
            json=_milestone_data(),
            status=201,
        )
        github_adapter.create_milestone(title="v1.0", description="First release")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["description"] == "First release"


# --- Registry ---


class TestReposPath:
    def test_non_ascii_owner_encoded(self):
        """非ASCII owner が URL エンコードされる。"""
        from gfo.http import HttpClient

        client = HttpClient("https://api.github.com")
        adapter = GitHubAdapter(client, "日本語-owner", "my-repo")
        path = adapter._repos_path()
        assert "日本語" not in path
        assert "%E6%97%A5%E6%9C%AC%E8%AA%9E" in path

    def test_special_char_owner_encoded(self):
        """スペースを含む owner が URL エンコードされる。"""
        from gfo.http import HttpClient

        client = HttpClient("https://api.github.com")
        adapter = GitHubAdapter(client, "my owner", "my-repo")
        path = adapter._repos_path()
        assert " " not in path
        assert "my%20owner" in path


class TestRegistry:
    def test_registered(self):
        assert get_adapter_class("github") is GitHubAdapter


class TestErrorHandling:
    """HTTP エラーが適切な例外に変換されることを確認する。"""

    def test_not_found_raises_error(self, mock_responses, github_adapter):
        mock_responses.add(responses.GET, f"{REPOS}/issues/999", status=404)
        with pytest.raises(NotFoundError):
            github_adapter.get_issue(999)

    def test_401_raises_auth_error(self, mock_responses, github_adapter):
        mock_responses.add(responses.GET, f"{REPOS}/pulls", status=401)
        with pytest.raises(AuthenticationError):
            github_adapter.list_pull_requests()

    def test_500_raises_server_error(self, mock_responses, github_adapter):
        mock_responses.add(responses.GET, f"{REPOS}/issues", status=500)
        with pytest.raises(ServerError):
            github_adapter.list_issues()

    def test_malformed_pr_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_pull_request で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1",
            json={"incomplete": True},
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.get_pull_request(1)

    def test_malformed_issue_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_issue で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"incomplete": True},
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.get_issue(1)

    def test_malformed_milestone_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_milestone で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[{"incomplete": True}],
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.list_milestones()

    def test_malformed_repository_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_repository で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}",
            json={"incomplete": True},
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.get_repository()

    def test_malformed_release_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_release で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases",
            json=[{"incomplete": True}],
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.list_releases()

    def test_malformed_label_raises_gfo_error(self, mock_responses, github_adapter):
        """_to_label で必須フィールド欠落 → GfoError。"""
        from gfo.exceptions import GfoError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/labels",
            json=[{"incomplete": True}],
            status=200,
        )
        with pytest.raises(GfoError):
            github_adapter.list_labels()


# --- Delete 系 ---


class TestDeleteRelease:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={"id": 42, "tag_name": "v1.0.0"},
            status=200,
        )
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/releases/42",
            status=204,
        )
        github_adapter.delete_release(tag="v1.0.0")

    def test_tag_url_encoded(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0%2Brc1",
            json={"id": 99, "tag_name": "v1.0.0+rc1"},
            status=200,
        )
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/releases/99",
            status=204,
        )
        github_adapter.delete_release(tag="v1.0.0+rc1")
        assert "%2B" in mock_responses.calls[0].request.url


class TestDeleteLabel:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/labels/bug",
            status=204,
        )
        github_adapter.delete_label(name="bug")

    def test_name_url_encoded(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/labels/my%20label",
            status=204,
        )
        github_adapter.delete_label(name="my label")
        assert "%20" in mock_responses.calls[0].request.url


class TestUpdateLabel:
    def test_update(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/labels/bug",
            json=_label_data(name="bug-fix"),
            status=200,
        )
        label = github_adapter.update_label(name="bug", new_name="bug-fix")
        assert label.name == "bug-fix"
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["new_name"] == "bug-fix"

    def test_update_color(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/labels/bug",
            json=_label_data(),
            status=200,
        )
        github_adapter.update_label(name="bug", color="ff0000")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["color"] == "ff0000"

    def test_update_description(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/labels/bug",
            json=_label_data(),
            status=200,
        )
        github_adapter.update_label(name="bug", description="Updated desc")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["description"] == "Updated desc"

    def test_name_url_encoded(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/labels/my%20label",
            json=_label_data(name="my label"),
            status=200,
        )
        github_adapter.update_label(name="my label", new_name="renamed")
        assert "%20" in mock_responses.calls[0].request.url

    def test_optional_fields_omitted(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/labels/bug",
            json=_label_data(),
            status=200,
        )
        github_adapter.update_label(name="bug")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "new_name" not in req_body
        assert "color" not in req_body
        assert "description" not in req_body


class TestDeleteMilestone:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/milestones/3",
            status=204,
        )
        github_adapter.delete_milestone(number=3)


class TestGetMilestone:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones/1",
            json=_milestone_data(),
            status=200,
        )
        ms = github_adapter.get_milestone(1)
        assert isinstance(ms, Milestone)
        assert ms.title == "v1.0"

    def test_get_number(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones/5",
            json=_milestone_data(number=5),
            status=200,
        )
        ms = github_adapter.get_milestone(5)
        assert ms.number == 5


class TestUpdateMilestone:
    def test_update_title(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/milestones/1",
            json=_milestone_data(),
            status=200,
        )
        ms = github_adapter.update_milestone(1, title="v2.0")
        assert isinstance(ms, Milestone)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["title"] == "v2.0"

    def test_update_state(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/milestones/1",
            json=_milestone_data(),
            status=200,
        )
        github_adapter.update_milestone(1, state="closed")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "closed"

    def test_update_due_date(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/milestones/1",
            json=_milestone_data(),
            status=200,
        )
        github_adapter.update_milestone(1, due_date="2026-01-01")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["due_on"] == "2026-01-01"

    def test_update_optional_fields_omitted(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/milestones/1",
            json=_milestone_data(),
            status=200,
        )
        github_adapter.update_milestone(1)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "title" not in req_body
        assert "description" not in req_body
        assert "due_on" not in req_body
        assert "state" not in req_body


class TestDeleteRepository:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            REPOS,
            status=204,
        )
        github_adapter.delete_repository()
        assert mock_responses.calls[0].request.method == "DELETE"
        assert mock_responses.calls[0].request.url.endswith("/repos/test-owner/test-repo")


# --- Comment 系 ---


class TestListComments:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1/comments",
            json=[_comment_data()],
            status=200,
        )
        comments = github_adapter.list_comments("issue", 1)
        assert len(comments) == 1
        assert isinstance(comments[0], Comment)
        assert comments[0].body == "A comment"

    def test_pr_resource(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/2/comments",
            json=[_comment_data(comment_id=11)],
            status=200,
        )
        comments = github_adapter.list_comments("pr", 2)
        assert len(comments) == 1


class TestCreateComment:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/issues/1/comments",
            json=_comment_data(),
            status=201,
        )
        comment = github_adapter.create_comment("issue", 1, body="A comment")
        assert isinstance(comment, Comment)

    def test_request_body(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/issues/1/comments",
            json=_comment_data(),
            status=201,
        )
        github_adapter.create_comment("issue", 1, body="Hello")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["body"] == "Hello"


class TestUpdateComment:
    def test_update(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/issues/comments/10",
            json=_comment_data(),
            status=200,
        )
        comment = github_adapter.update_comment("issue", 10, body="Updated")
        assert isinstance(comment, Comment)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["body"] == "Updated"


class TestDeleteComment:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/issues/comments/10",
            status=204,
        )
        github_adapter.delete_comment("issue", 10)
        assert mock_responses.calls[0].request.method == "DELETE"


# --- PR Update / Issue Update ---


class TestUpdatePullRequest:
    def test_update_title(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/pulls/1",
            json=_pr_data(),
            status=200,
        )
        pr = github_adapter.update_pull_request(1, title="New Title")
        assert isinstance(pr, PullRequest)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["title"] == "New Title"

    def test_update_body(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/pulls/1",
            json=_pr_data(),
            status=200,
        )
        github_adapter.update_pull_request(1, body="New body")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["body"] == "New body"

    def test_only_changed_fields_sent(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/pulls/1",
            json=_pr_data(),
            status=200,
        )
        github_adapter.update_pull_request(1, title="Only Title")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "body" not in req_body
        assert "base" not in req_body

    def test_add_labels(self, mock_responses, github_adapter):
        mock_responses.add(responses.PATCH, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [{"name": "existing"}], "assignees": []},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.update_pull_request(1, add_labels=["new"])
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert sorted(req_body["labels"]) == ["existing", "new"]

    def test_remove_labels(self, mock_responses, github_adapter):
        mock_responses.add(responses.PATCH, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [{"name": "bug"}, {"name": "wontfix"}], "assignees": []},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.update_pull_request(1, remove_labels=["wontfix"])
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert req_body["labels"] == ["bug"]

    def test_add_assignees(self, mock_responses, github_adapter):
        mock_responses.add(responses.PATCH, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [], "assignees": [{"login": "alice"}]},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.update_pull_request(1, add_assignees=["bob"])
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert sorted(req_body["assignees"]) == ["alice", "bob"]

    def test_remove_assignees(self, mock_responses, github_adapter):
        mock_responses.add(responses.PATCH, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [], "assignees": [{"login": "alice"}, {"login": "bob"}]},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.update_pull_request(1, remove_assignees=["bob"])
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert req_body["assignees"] == ["alice"]

    def test_milestone(self, mock_responses, github_adapter):
        mock_responses.add(responses.PATCH, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[
                {
                    "number": 5,
                    "title": "v1.0",
                    "description": None,
                    "state": "open",
                    "due_on": None,
                },
            ],
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json={}, status=200)
        mock_responses.add(responses.GET, f"{REPOS}/pulls/1", json=_pr_data(), status=200)
        github_adapter.update_pull_request(1, milestone="v1.0")
        req_body = json.loads(mock_responses.calls[2].request.body)
        assert req_body["milestone"] == 5


class TestUpdateIssue:
    def test_update_title(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/issues/1",
            json=_issue_data(),
            status=200,
        )
        issue = github_adapter.update_issue(1, title="New Title")
        assert isinstance(issue, Issue)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["title"] == "New Title"

    def test_only_changed_fields_sent(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/issues/1",
            json=_issue_data(),
            status=200,
        )
        github_adapter.update_issue(1, title="Title Only")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert "body" not in req_body
        assert "assignees" not in req_body

    def test_add_labels(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [{"name": "bug"}], "assignees": []},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json=_issue_data(), status=200)
        github_adapter.update_issue(1, add_labels=["enhancement"])
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert sorted(req_body["labels"]) == ["bug", "enhancement"]

    def test_remove_labels(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [{"name": "bug"}, {"name": "wontfix"}], "assignees": []},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json=_issue_data(), status=200)
        github_adapter.update_issue(1, remove_labels=["wontfix"])
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["labels"] == ["bug"]

    def test_add_assignees(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/issues/1",
            json={"labels": [], "assignees": [{"login": "dev1"}]},
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json=_issue_data(), status=200)
        github_adapter.update_issue(1, add_assignees=["dev2"])
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert sorted(req_body["assignees"]) == ["dev1", "dev2"]

    def test_milestone(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/milestones",
            json=[
                {
                    "number": 5,
                    "title": "v1.0",
                    "description": None,
                    "state": "open",
                    "due_on": None,
                },
            ],
            status=200,
        )
        mock_responses.add(responses.PATCH, f"{REPOS}/issues/1", json=_issue_data(), status=200)
        github_adapter.update_issue(1, milestone="v1.0")
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["milestone"] == 5


# --- Review 系 ---


class TestListReviews:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1/reviews",
            json=[_review_data()],
            status=200,
        )
        reviews = github_adapter.list_reviews(1)
        assert len(reviews) == 1
        assert isinstance(reviews[0], Review)
        assert reviews[0].state == "approved"


class TestCreateReview:
    def test_approve(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/pulls/1/reviews",
            json=_review_data(state="APPROVED"),
            status=200,
        )
        review = github_adapter.create_review(1, state="approve")
        assert isinstance(review, Review)

    def test_request_body(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/pulls/1/reviews",
            json=_review_data(state="APPROVED"),
            status=200,
        )
        github_adapter.create_review(1, state="approve", body="LGTM")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["event"] == "APPROVE"
        assert req_body["body"] == "LGTM"


# --- Branch 系 ---


class TestListBranches:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/branches",
            json=[_branch_data()],
            status=200,
        )
        branches = github_adapter.list_branches()
        assert len(branches) == 1
        assert isinstance(branches[0], Branch)
        assert branches[0].name == "feature"


class TestGetBranch:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/branches/feature",
            json=_branch_data(),
            status=200,
        )
        branch = github_adapter.get_branch("feature")
        assert isinstance(branch, Branch)
        assert branch.name == "feature"
        assert branch.sha == "abc123"

    def test_not_found(self, mock_responses, github_adapter):
        from gfo.exceptions import NotFoundError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/branches/nonexistent",
            json={"message": "Branch not found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_branch("nonexistent")


class TestCreateBranch:
    def test_create_from_sha(self, mock_responses, github_adapter):
        # 40文字の hex SHA を渡すと直接 POST /git/refs する
        sha40 = "a" * 40
        mock_responses.add(
            responses.POST,
            f"{REPOS}/git/refs",
            json={"ref": "refs/heads/new-branch", "object": {"sha": sha40}},
            status=201,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/branches/new-branch",
            json=_branch_data(name="new-branch", sha=sha40),
            status=200,
        )
        branch = github_adapter.create_branch(name="new-branch", ref=sha40)
        assert isinstance(branch, Branch)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["ref"] == "refs/heads/new-branch"
        assert req_body["sha"] == sha40

    def test_create_from_branch_name(self, mock_responses, github_adapter):
        # 既存ブランチ名が ref として渡された場合、SHA を解決してから作成する
        mock_responses.add(
            responses.GET,
            f"{REPOS}/git/ref/heads/main",
            json={"object": {"sha": "sha999"}},
            status=200,
        )
        mock_responses.add(
            responses.POST,
            f"{REPOS}/git/refs",
            json={"ref": "refs/heads/new-branch", "object": {"sha": "sha999"}},
            status=201,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/branches/new-branch",
            json=_branch_data(name="new-branch", sha="sha999"),
            status=200,
        )
        github_adapter.create_branch(name="new-branch", ref="main")
        post_body = json.loads(mock_responses.calls[1].request.body)
        assert post_body["sha"] == "sha999"


class TestDeleteBranch:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/git/refs/heads/feature",
            status=204,
        )
        github_adapter.delete_branch(name="feature")
        assert mock_responses.calls[0].request.method == "DELETE"


# --- Tag 系 ---


class TestListTags:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/tags",
            json=[_tag_data()],
            status=200,
        )
        tags = github_adapter.list_tags()
        assert len(tags) == 1
        assert isinstance(tags[0], Tag)
        assert tags[0].name == "v1.0.0"


class TestGetTag:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/tags",
            json=[_tag_data()],
            status=200,
        )
        tag = github_adapter.get_tag("v1.0.0")
        assert isinstance(tag, Tag)
        assert tag.name == "v1.0.0"

    def test_not_found(self, mock_responses, github_adapter):
        from gfo.exceptions import NotFoundError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/tags",
            json=[],
            status=200,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_tag("nonexistent")


class TestCreateTag:
    def test_create(self, mock_responses, github_adapter):
        # 40文字の hex SHA を渡すと直接 POST /git/refs する
        sha40 = "d" * 40
        mock_responses.add(
            responses.POST,
            f"{REPOS}/git/refs",
            json={"ref": "refs/tags/v2.0.0", "object": {"sha": sha40}},
            status=201,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/tags",
            json=[_tag_data(name="v2.0.0", sha=sha40)],
            status=200,
        )
        tag = github_adapter.create_tag(name="v2.0.0", ref=sha40)
        assert isinstance(tag, Tag)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["ref"] == "refs/tags/v2.0.0"


class TestDeleteTag:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/git/refs/tags/v1.0.0",
            status=204,
        )
        github_adapter.delete_tag(name="v1.0.0")
        assert mock_responses.calls[0].request.method == "DELETE"


# --- CommitStatus 系 ---


class TestListCommitStatuses:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/statuses/abc123",
            json=[_commit_status_data()],
            status=200,
        )
        statuses = github_adapter.list_commit_statuses("abc123")
        assert len(statuses) == 1
        assert isinstance(statuses[0], CommitStatus)
        assert statuses[0].state == "success"


class TestCreateCommitStatus:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/statuses/abc123",
            json=_commit_status_data(),
            status=201,
        )
        status = github_adapter.create_commit_status("abc123", state="success", context="ci/test")
        assert isinstance(status, CommitStatus)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["state"] == "success"
        assert req_body["context"] == "ci/test"


# --- File 系 ---


class TestGetFileContent:
    def test_get(self, mock_responses, github_adapter):
        import base64 as _b64

        content_b64 = _b64.b64encode(b"file content").decode()
        mock_responses.add(
            responses.GET,
            f"{REPOS}/contents/README.md",
            json={"content": content_b64, "sha": "sha1"},
            status=200,
        )
        content, sha = github_adapter.get_file_content("README.md")
        assert content == "file content"
        assert sha == "sha1"


class TestCreateOrUpdateFile:
    def test_create_new(self, mock_responses, github_adapter):
        import base64 as _b64

        content_b64 = _b64.b64encode(b"new content").decode()
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/contents/new-file.md",
            json={
                "content": {"name": "new-file.md", "sha": "newsha"},
                "commit": {"sha": "commit-abc123"},
            },
            status=201,
        )
        result = github_adapter.create_or_update_file(
            "new-file.md", content="new content", message="Add file"
        )
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["message"] == "Add file"
        assert req_body["content"] == content_b64
        assert result == "commit-abc123"

    def test_update_existing(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/contents/existing.md",
            json={
                "content": {"name": "existing.md", "sha": "updatedsha"},
                "commit": {"sha": "commit-updated"},
            },
            status=200,
        )
        result = github_adapter.create_or_update_file(
            "existing.md", content="updated content", message="Update file", sha="oldsha"
        )
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["sha"] == "oldsha"
        assert result == "commit-updated"


class TestDeleteFile:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/contents/to-delete.md",
            json={"commit": {"sha": "commitsha"}},
            status=200,
        )
        github_adapter.delete_file("to-delete.md", sha="filsha", message="Delete file")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["sha"] == "filsha"
        assert req_body["message"] == "Delete file"


# --- Fork 系 ---


class TestForkRepository:
    def test_fork(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/forks",
            json=_repo_data(name="test-repo", full_name="forker/test-repo"),
            status=202,
        )
        repo = github_adapter.fork_repository()
        assert isinstance(repo, Repository)

    def test_fork_with_org(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/forks",
            json=_repo_data(name="test-repo", full_name="myorg/test-repo"),
            status=202,
        )
        github_adapter.fork_repository(organization="myorg")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["organization"] == "myorg"


class TestSyncFork:
    def test_sync_fork(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            REPOS,
            json=_repo_data(),
            status=200,
        )
        mock_responses.add(
            responses.POST,
            f"{REPOS}/merge-upstream",
            json={"merge_type": "fast-forward", "base_branch": "main"},
            status=200,
        )
        github_adapter.sync_fork()
        req_body = json.loads(mock_responses.calls[1].request.body)
        assert req_body["branch"] == "main"

    def test_sync_fork_with_branch(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/merge-upstream",
            json={"merge_type": "fast-forward", "base_branch": "develop"},
            status=200,
        )
        github_adapter.sync_fork(branch="develop")
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["branch"] == "develop"


# --- Webhook 系 ---


class TestListWebhooks:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/hooks",
            json=[_webhook_data()],
            status=200,
        )
        webhooks = github_adapter.list_webhooks()
        assert len(webhooks) == 1
        assert isinstance(webhooks[0], Webhook)


class TestCreateWebhook:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/hooks",
            json=_webhook_data(),
            status=201,
        )
        webhook = github_adapter.create_webhook(
            url="https://example.com/hook", events=["push", "pull_request"]
        )
        assert isinstance(webhook, Webhook)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["config"]["url"] == "https://example.com/hook"
        assert req_body["events"] == ["push", "pull_request"]


class TestDeleteWebhook:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/hooks/100",
            status=204,
        )
        github_adapter.delete_webhook(hook_id=100)
        assert mock_responses.calls[0].request.method == "DELETE"


class TestTestWebhook:
    def test_test(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/hooks/100/tests",
            status=204,
        )
        github_adapter.test_webhook(hook_id=100)
        assert mock_responses.calls[0].request.method == "POST"


class TestUpdateWebhook:
    def test_update_url(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/hooks/100",
            json={
                "id": 100,
                "config": {"url": "https://new.example.com/hook", "content_type": "json"},
                "events": ["push"],
                "active": True,
            },
            status=200,
        )
        webhook = github_adapter.update_webhook(100, url="https://new.example.com/hook")
        assert webhook.url == "https://new.example.com/hook"
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["config"]["url"] == "https://new.example.com/hook"

    def test_update_events(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/hooks/100",
            json={
                "id": 100,
                "config": {"url": "https://example.com/hook", "content_type": "json"},
                "events": ["push", "issues"],
                "active": True,
            },
            status=200,
        )
        webhook = github_adapter.update_webhook(100, events=["push", "issues"])
        assert "push" in webhook.events
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["events"] == ["push", "issues"]

    def test_update_active(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/hooks/100",
            json={
                "id": 100,
                "config": {"url": "https://example.com/hook", "content_type": "json"},
                "events": ["push"],
                "active": False,
            },
            status=200,
        )
        webhook = github_adapter.update_webhook(100, active=False)
        assert webhook.active is False


# --- DeployKey 系 ---


class TestListDeployKeys:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/keys",
            json=[_deploy_key_data()],
            status=200,
        )
        keys = github_adapter.list_deploy_keys()
        assert len(keys) == 1
        assert isinstance(keys[0], DeployKey)


class TestGetDeployKey:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/keys/200",
            json=_deploy_key_data(),
            status=200,
        )
        key = github_adapter.get_deploy_key(200)
        assert isinstance(key, DeployKey)
        assert key.id == 200
        assert key.title == "Deploy Key"

    def test_not_found(self, mock_responses, github_adapter):
        from gfo.exceptions import NotFoundError

        mock_responses.add(
            responses.GET,
            f"{REPOS}/keys/999",
            json={"message": "Not Found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_deploy_key(999)


class TestCreateDeployKey:
    def test_create(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/keys",
            json=_deploy_key_data(),
            status=201,
        )
        key = github_adapter.create_deploy_key(
            title="Deploy Key", key="ssh-rsa AAAA...", read_only=True
        )
        assert isinstance(key, DeployKey)
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["title"] == "Deploy Key"
        assert req_body["read_only"] is True


class TestDeleteDeployKey:
    def test_delete(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/keys/200",
            status=204,
        )
        github_adapter.delete_deploy_key(key_id=200)
        assert mock_responses.calls[0].request.method == "DELETE"


# --- Collaborator 系 ---


class TestListCollaborators:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/collaborators",
            json=[{"login": "collab1"}, {"login": "collab2"}],
            status=200,
        )
        collabs = github_adapter.list_collaborators()
        assert collabs == ["collab1", "collab2"]


class TestAddCollaborator:
    def test_add(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/collaborators/newuser",
            status=201,
        )
        github_adapter.add_collaborator(username="newuser")
        assert mock_responses.calls[0].request.method == "PUT"


class TestRemoveCollaborator:
    def test_remove(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/collaborators/olduser",
            status=204,
        )
        github_adapter.remove_collaborator(username="olduser")
        assert mock_responses.calls[0].request.method == "DELETE"


# --- Pipeline 系 ---


class TestListPipelines:
    def test_list(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/runs",
            json={"workflow_runs": [_pipeline_data()], "total_count": 1},
            status=200,
        )
        pipelines = github_adapter.list_pipelines()
        assert len(pipelines) == 1
        assert isinstance(pipelines[0], Pipeline)

    def test_with_ref(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/runs",
            json={"workflow_runs": [_pipeline_data()], "total_count": 1},
            status=200,
        )
        github_adapter.list_pipelines(ref="main")
        req = mock_responses.calls[0].request
        assert "branch=main" in req.url


class TestGetPipeline:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300",
            json=_pipeline_data(run_id=300),
            status=200,
        )
        pipeline = github_adapter.get_pipeline(300)
        assert isinstance(pipeline, Pipeline)
        assert pipeline.id == 300


class TestCancelPipeline:
    def test_cancel(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/runs/300/cancel",
            status=202,
        )
        github_adapter.cancel_pipeline(300)
        assert mock_responses.calls[0].request.method == "POST"


class TestTriggerPipeline:
    def test_trigger_with_workflow(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/workflows/ci.yml/dispatches",
            status=204,
        )
        pipeline = github_adapter.trigger_pipeline("main", workflow="ci.yml")
        assert pipeline.status == "pending"
        assert pipeline.ref == "main"

    def test_trigger_with_inputs(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/workflows/ci.yml/dispatches",
            status=204,
        )
        pipeline = github_adapter.trigger_pipeline(
            "main", workflow="ci.yml", inputs={"key": "value"}
        )
        req = mock_responses.calls[0].request
        body = json.loads(req.body)
        assert body["inputs"] == {"key": "value"}
        assert pipeline.status == "pending"

    def test_trigger_without_workflow_raises(self, github_adapter):
        from gfo.exceptions import GfoError

        with pytest.raises(GfoError, match="--workflow"):
            github_adapter.trigger_pipeline("main")

    def test_trigger_404(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/workflows/ci.yml/dispatches",
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.trigger_pipeline("main", workflow="ci.yml")


class TestRetryPipeline:
    def test_retry(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/runs/300/rerun",
            status=201,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300",
            json=_pipeline_data(run_id=300),
            status=200,
        )
        pipeline = github_adapter.retry_pipeline(300)
        assert isinstance(pipeline, Pipeline)
        assert pipeline.id == 300

    def test_retry_404(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/actions/runs/999/rerun",
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.retry_pipeline(999)


class TestGetPipelineLogs:
    def test_logs_with_job_id(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/jobs/42/logs",
            body="log line 1\nlog line 2",
            status=200,
        )
        logs = github_adapter.get_pipeline_logs(300, job_id=42)
        assert "log line 1" in logs

    def test_logs_all_jobs(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300/jobs",
            json={"jobs": [{"id": 1, "name": "build"}, {"id": 2, "name": "test"}]},
            status=200,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/jobs/1/logs",
            body="build log",
            status=200,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/jobs/2/logs",
            body="test log",
            status=200,
        )
        logs = github_adapter.get_pipeline_logs(300)
        assert "=== build ===" in logs
        assert "build log" in logs
        assert "=== test ===" in logs
        assert "test log" in logs

    def test_logs_404(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/actions/jobs/999/logs",
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_pipeline_logs(300, job_id=999)


# --- User / Search 系 ---


class TestGetCurrentUser:
    def test_get(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/user",
            json={"login": "testuser", "id": 1},
            status=200,
        )
        user = github_adapter.get_current_user()
        assert user["login"] == "testuser"


class TestSearchRepositories:
    def test_search(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/search/repositories",
            json={"items": [_repo_data()], "total_count": 1},
            status=200,
        )
        repos = github_adapter.search_repositories("test")
        assert len(repos) == 1
        assert isinstance(repos[0], Repository)


class TestSearchIssues:
    def test_search(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{BASE}/search/issues",
            json={"items": [_issue_data()], "total_count": 1},
            status=200,
        )
        issues = github_adapter.search_issues("bug")
        assert len(issues) == 1
        assert isinstance(issues[0], Issue)


# --- Phase 2: PR operations ---


class TestGetPullRequestDiff:
    def test_get_diff(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1",
            body="diff --git a/file.py b/file.py\n--- a/file.py\n+++ b/file.py",
            status=200,
        )
        diff = github_adapter.get_pull_request_diff(1)
        assert "diff --git" in diff
        # Accept ヘッダーに diff 形式を指定していることを検証
        assert "application/vnd.github.diff" in mock_responses.calls[0].request.headers["Accept"]


class TestListPullRequestChecks:
    def test_list_checks(self, mock_responses, github_adapter):
        pr_data = _pr_data()
        pr_data["head"]["sha"] = "abc123"
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1",
            json=pr_data,
            status=200,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/commits/abc123/check-runs",
            json={
                "total_count": 1,
                "check_runs": [
                    {
                        "name": "CI",
                        "status": "completed",
                        "conclusion": "success",
                        "html_url": "https://github.com/runs/1",
                        "started_at": "2025-01-01T00:00:00Z",
                    }
                ],
            },
            status=200,
        )
        mock_responses.add(
            responses.GET,
            f"{REPOS}/commits/abc123/status",
            json={
                "statuses": [
                    {
                        "context": "external-ci",
                        "state": "success",
                        "target_url": "https://ci.example.com",
                        "created_at": "2025-01-01T00:00:00Z",
                    }
                ]
            },
            status=200,
        )
        checks = github_adapter.list_pull_request_checks(1)
        assert len(checks) == 2
        assert isinstance(checks[0], CheckRun)
        assert checks[0].name == "CI"
        assert checks[0].status == "success"
        assert checks[1].name == "external-ci"
        assert checks[1].status == "success"


class TestListPullRequestFiles:
    def test_list_files(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1/files",
            json=[
                {
                    "filename": "src/main.py",
                    "status": "modified",
                    "additions": 10,
                    "deletions": 3,
                }
            ],
            status=200,
        )
        files = github_adapter.list_pull_request_files(1)
        assert len(files) == 1
        assert isinstance(files[0], PullRequestFile)
        assert files[0].filename == "src/main.py"
        assert files[0].status == "modified"
        assert files[0].additions == 10
        assert files[0].deletions == 3


class TestListPullRequestCommits:
    def test_list_commits(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1/commits",
            json=[
                {
                    "sha": "abc123",
                    "commit": {
                        "message": "fix bug",
                        "author": {"name": "dev1", "date": "2025-01-01T00:00:00Z"},
                    },
                    "author": {"login": "dev1"},
                }
            ],
            status=200,
        )
        commits = github_adapter.list_pull_request_commits(1)
        assert len(commits) == 1
        assert isinstance(commits[0], PullRequestCommit)
        assert commits[0].sha == "abc123"
        assert commits[0].message == "fix bug"
        assert commits[0].author == "dev1"


class TestListRequestedReviewers:
    def test_list_reviewers(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.GET,
            f"{REPOS}/pulls/1/requested_reviewers",
            json={"users": [{"login": "reviewer1"}, {"login": "reviewer2"}]},
            status=200,
        )
        reviewers = github_adapter.list_requested_reviewers(1)
        assert reviewers == ["reviewer1", "reviewer2"]


class TestRequestReviewers:
    def test_request(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.POST,
            f"{REPOS}/pulls/1/requested_reviewers",
            json={},
            status=201,
        )
        github_adapter.request_reviewers(1, reviewers=["reviewer1"])
        body = json.loads(mock_responses.calls[0].request.body)
        assert body["reviewers"] == ["reviewer1"]


class TestRemoveReviewers:
    def test_remove(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.DELETE,
            f"{REPOS}/pulls/1/requested_reviewers",
            json={},
            status=200,
        )
        github_adapter.remove_reviewers(1, reviewers=["reviewer1"])
        body = json.loads(mock_responses.calls[0].request.body)
        assert body["reviewers"] == ["reviewer1"]


class TestUpdatePullRequestBranch:
    def test_update_branch(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/update-branch",
            json={"message": "Updating pull request branch."},
            status=202,
        )
        github_adapter.update_pull_request_branch(1)
        assert mock_responses.calls[0].request.method == "PUT"


class TestDismissReview:
    def test_dismiss(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PUT,
            f"{REPOS}/pulls/1/reviews/42/dismissals",
            json={"id": 42, "state": "DISMISSED"},
            status=200,
        )
        github_adapter.dismiss_review(1, 42, message="stale review")
        body = json.loads(mock_responses.calls[0].request.body)
        assert body["message"] == "stale review"


# --- Phase 3: リポジトリ操作・リリースアセット ---


class TestUpdateRepository:
    @responses.activate
    def test_update_description(self, github_adapter):
        responses.add(responses.PATCH, f"{REPOS}", json=_repo_data(), status=200)
        repo = github_adapter.update_repository(description="new desc")
        assert isinstance(repo, Repository)
        assert json.loads(responses.calls[0].request.body)["description"] == "new desc"

    @responses.activate
    def test_update_name(self, github_adapter):
        responses.add(responses.PATCH, f"{REPOS}", json=_repo_data(), status=200)
        github_adapter.update_repository(name="new-name")
        assert json.loads(responses.calls[0].request.body)["name"] == "new-name"

    @responses.activate
    def test_update_private(self, github_adapter):
        responses.add(responses.PATCH, f"{REPOS}", json=_repo_data(), status=200)
        github_adapter.update_repository(private=True)
        assert json.loads(responses.calls[0].request.body)["private"] is True


class TestArchiveRepository:
    @responses.activate
    def test_archive(self, github_adapter):
        responses.add(responses.PATCH, f"{REPOS}", json=_repo_data(), status=200)
        github_adapter.archive_repository()
        assert json.loads(responses.calls[0].request.body)["archived"] is True


class TestGetLanguages:
    @responses.activate
    def test_get_languages(self, github_adapter):
        responses.add(
            responses.GET, f"{REPOS}/languages", json={"Python": 45678, "Go": 1234}, status=200
        )
        result = github_adapter.get_languages()
        assert result == {"Python": 45678, "Go": 1234}


class TestTopics:
    @responses.activate
    def test_list_topics(self, github_adapter):
        responses.add(
            responses.GET, f"{REPOS}/topics", json={"names": ["python", "cli"]}, status=200
        )
        result = github_adapter.list_topics()
        assert result == ["python", "cli"]

    @responses.activate
    def test_set_topics(self, github_adapter):
        responses.add(responses.PUT, f"{REPOS}/topics", json={"names": ["a", "b"]}, status=200)
        result = github_adapter.set_topics(["a", "b"])
        assert result == ["a", "b"]

    @responses.activate
    def test_add_topic(self, github_adapter):
        responses.add(responses.GET, f"{REPOS}/topics", json={"names": ["a"]}, status=200)
        responses.add(responses.PUT, f"{REPOS}/topics", json={"names": ["a", "b"]}, status=200)
        result = github_adapter.add_topic("b")
        assert "b" in result

    @responses.activate
    def test_remove_topic(self, github_adapter):
        responses.add(responses.GET, f"{REPOS}/topics", json={"names": ["a", "b"]}, status=200)
        responses.add(responses.PUT, f"{REPOS}/topics", json={"names": ["a"]}, status=200)
        result = github_adapter.remove_topic("b")
        assert "b" not in result


class TestCompare:
    @responses.activate
    def test_compare(self, github_adapter):
        from gfo.adapter.base import CompareResult

        responses.add(
            responses.GET,
            f"{REPOS}/compare/main...feature",
            json={
                "total_commits": 3,
                "ahead_by": 3,
                "behind_by": 0,
                "files": [
                    {"filename": "a.py", "status": "modified", "additions": 10, "deletions": 2}
                ],
            },
            status=200,
        )
        result = github_adapter.compare("main", "feature")
        assert isinstance(result, CompareResult)
        assert result.total_commits == 3
        assert len(result.files) == 1


class TestGetLatestRelease:
    @responses.activate
    def test_get_latest(self, github_adapter):
        responses.add(responses.GET, f"{REPOS}/releases/latest", json=_release_data(), status=200)
        release = github_adapter.get_latest_release()
        assert release.tag == "v1.0.0"


class TestReleaseAssets:
    @responses.activate
    def test_list_release_assets(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={
                **_release_data(),
                "assets": [
                    {
                        "id": 1,
                        "name": "app.zip",
                        "size": 1024,
                        "browser_download_url": "https://example.com/app.zip",
                        "created_at": "2025-01-01T00:00:00Z",
                    }
                ],
            },
            status=200,
        )
        assets = github_adapter.list_release_assets(tag="v1.0.0")
        assert len(assets) == 1
        assert assets[0].name == "app.zip"

    @responses.activate
    def test_delete_release_asset(self, github_adapter):
        responses.add(responses.DELETE, f"{REPOS}/releases/assets/1", status=204)
        github_adapter.delete_release_asset(tag="v1.0.0", asset_id=1)


class TestUpdateReleaseAsset:
    def test_update_name(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/releases/assets/1",
            json={
                "id": 1,
                "name": "renamed.zip",
                "size": 1024,
                "browser_download_url": "https://example.com/renamed.zip",
                "created_at": "2025-01-01T00:00:00Z",
            },
            status=200,
        )
        asset = github_adapter.update_release_asset(tag="v1.0.0", asset_id=1, name="renamed.zip")
        assert asset.name == "renamed.zip"
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["name"] == "renamed.zip"

    def test_not_found(self, mock_responses, github_adapter):
        from gfo.exceptions import NotFoundError

        mock_responses.add(
            responses.PATCH,
            f"{REPOS}/releases/assets/999",
            json={"message": "Not Found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.update_release_asset(tag="v1.0.0", asset_id=999, name="x.zip")


class TestListIssueTemplates:
    @responses.activate
    def test_list_templates(self, github_adapter):
        import base64

        template_content = "---\nname: Bug Report\nabout: Report a bug\ntitle: '[Bug]: '\nlabels: [bug, urgent]\n---\n## Description\n..."
        encoded = base64.b64encode(template_content.encode()).decode()
        responses.add(
            responses.GET,
            f"{REPOS}/contents/.github/ISSUE_TEMPLATE",
            json=[
                {
                    "name": "bug_report.md",
                    "url": f"{REPOS}/contents/.github/ISSUE_TEMPLATE/bug_report.md",
                },
            ],
            status=200,
        )
        responses.add(
            responses.GET,
            f"{REPOS}/contents/.github/ISSUE_TEMPLATE/bug_report.md",
            json={"content": encoded},
            status=200,
        )
        templates = github_adapter.list_issue_templates()
        assert len(templates) == 1
        assert templates[0].name == "Bug Report"
        assert templates[0].about == "Report a bug"
        assert templates[0].labels == ("bug", "urgent")
        assert templates[0].title == "[Bug]: "

    @responses.activate
    def test_empty_directory(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/contents/.github/ISSUE_TEMPLATE",
            json=[],
            status=200,
        )
        templates = github_adapter.list_issue_templates()
        assert templates == []

    @responses.activate
    def test_not_found(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/contents/.github/ISSUE_TEMPLATE",
            json={"message": "Not Found"},
            status=404,
        )
        templates = github_adapter.list_issue_templates()
        assert templates == []

    @responses.activate
    def test_skips_non_template_files(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/contents/.github/ISSUE_TEMPLATE",
            json=[
                {
                    "name": "config.json",
                    "url": f"{REPOS}/contents/.github/ISSUE_TEMPLATE/config.json",
                },
            ],
            status=200,
        )
        templates = github_adapter.list_issue_templates()
        assert templates == []


class TestParseIssueTemplate:
    def test_basic_frontmatter(self):
        content = "---\nname: Bug Report\nabout: Report a bug\ntitle: '[Bug]: '\nlabels: [bug, urgent]\n---\n## Description\n..."
        result = GitHubAdapter._parse_issue_template(content)
        assert result.name == "Bug Report"
        assert result.about == "Report a bug"
        assert result.labels == ("bug", "urgent")
        assert result.title == "[Bug]: "
        assert result.body == "## Description\n..."

    def test_no_frontmatter(self):
        content = "## Just a markdown file"
        result = GitHubAdapter._parse_issue_template(content)
        assert result.name == "Untitled"
        assert result.body == "## Just a markdown file"
        assert result.labels == ()

    def test_list_style_labels(self):
        content = "---\nname: Feature\nlabels:\n  - enhancement\n  - feature\n---\nBody"
        result = GitHubAdapter._parse_issue_template(content)
        assert result.name == "Feature"
        assert result.labels == ("enhancement", "feature")

    def test_empty_frontmatter(self):
        content = "---\n---\nBody content"
        result = GitHubAdapter._parse_issue_template(content)
        assert result.name == "Untitled"
        assert result.body == "Body content"

    def test_quoted_values(self):
        content = "---\nname: \"Bug Report\"\nabout: 'Report bugs'\n---\nBody"
        result = GitHubAdapter._parse_issue_template(content)
        assert result.name == "Bug Report"
        assert result.about == "Report bugs"


class TestMigrateRepository:
    @responses.activate
    def test_migrate(self, github_adapter):
        responses.add(
            responses.POST,
            "https://api.github.com/user/repos",
            json=_repo_data(name="migrated"),
            status=201,
        )
        responses.add(
            responses.PUT,
            "https://api.github.com/repos/test-owner/migrated/import",
            json={"status": "importing"},
            status=201,
        )
        repo = github_adapter.migrate_repository("https://other.com/old/repo.git", "migrated")
        assert repo.name == "migrated"

    @responses.activate
    def test_migrate_with_auth_token(self, github_adapter):
        responses.add(
            responses.POST,
            "https://api.github.com/user/repos",
            json=_repo_data(name="migrated"),
            status=201,
        )
        responses.add(
            responses.PUT,
            "https://api.github.com/repos/test-owner/migrated/import",
            json={"status": "importing"},
            status=201,
        )
        repo = github_adapter.migrate_repository(
            "https://other.com/old/repo.git",
            "migrated",
            auth_token="secret-token",
        )
        assert repo.name == "migrated"
        # auth_token が含まれることを確認
        import json as json_mod

        body = json_mod.loads(responses.calls[1].request.body)
        assert body["vcs_username"] == "x-access-token"
        assert body["vcs_password"] == "secret-token"


# --- Phase 5: Reaction / Search / Star / Transfer / Pin / Timeline ---


class TestIssueReactions:
    @responses.activate
    def test_list_issue_reactions(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/issues/1/reactions",
            json=[
                {
                    "id": 1,
                    "content": "+1",
                    "user": {"login": "alice"},
                    "created_at": "2024-01-01T00:00:00Z",
                }
            ],
        )
        result = github_adapter.list_issue_reactions(1)
        assert len(result) == 1
        assert result[0].content == "+1"

    @responses.activate
    def test_add_issue_reaction(self, github_adapter):
        responses.add(
            responses.POST,
            f"{REPOS}/issues/1/reactions",
            json={
                "id": 1,
                "content": "+1",
                "user": {"login": "alice"},
                "created_at": "2024-01-01T00:00:00Z",
            },
        )
        result = github_adapter.add_issue_reaction(1, "+1")
        assert result.content == "+1"


class TestSearchPullRequests:
    @responses.activate
    def test_search_pull_requests(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/search/issues",
            json={
                "items": [
                    {
                        "number": 1,
                        "title": "Fix bug",
                        "body": "",
                        "state": "open",
                        "user": {"login": "alice"},
                        "html_url": "",
                        "created_at": "2024-01-01T00:00:00Z",
                        "pull_request": {},
                        "draft": False,
                    }
                ]
            },
        )
        result = github_adapter.search_pull_requests("bug", limit=10)
        assert len(result) == 1
        assert result[0].title == "Fix bug"


class TestSearchCommits:
    @responses.activate
    def test_search_commits(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/search/commits",
            json={
                "items": [
                    {
                        "sha": "abc123",
                        "commit": {
                            "message": "fix",
                            "author": {
                                "name": "alice",
                                "date": "2024-01-01T00:00:00Z",
                            },
                        },
                        "author": {"login": "alice"},
                        "html_url": "",
                    }
                ]
            },
        )
        result = github_adapter.search_commits("fix", limit=10)
        assert len(result) == 1
        assert result[0].sha == "abc123"


class TestRepoStarUnstar:
    @responses.activate
    def test_star_repository(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{BASE}/user/starred/test-owner/test-repo",
            status=204,
        )
        github_adapter.star_repository()

    @responses.activate
    def test_unstar_repository(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{BASE}/user/starred/test-owner/test-repo",
            status=204,
        )
        github_adapter.unstar_repository()


class TestRepoTransfer:
    @responses.activate
    def test_transfer_repository(self, github_adapter):
        responses.add(
            responses.POST,
            f"{REPOS}/transfer",
            json={},
        )
        github_adapter.transfer_repository("new-owner")


class TestIssueLock:
    @responses.activate
    def test_lock_issue(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/issues/1/lock",
            status=204,
        )
        github_adapter.lock_issue(1)

    @responses.activate
    def test_lock_issue_with_reason(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/issues/1/lock",
            status=204,
        )
        github_adapter.lock_issue(1, reason="spam")
        req_body = json.loads(responses.calls[0].request.body)
        assert req_body["lock_reason"] == "spam"

    @responses.activate
    def test_unlock_issue(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{REPOS}/issues/1/lock",
            status=204,
        )
        github_adapter.unlock_issue(1)

    @responses.activate
    def test_lock_pull_request(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/issues/1/lock",
            status=204,
        )
        github_adapter.lock_pull_request(1)

    @responses.activate
    def test_unlock_pull_request(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{REPOS}/issues/1/lock",
            status=204,
        )
        github_adapter.unlock_pull_request(1)


class TestIssuePin:
    @responses.activate
    def test_pin_issue(self, github_adapter):
        responses.add(
            responses.POST,
            f"{REPOS}/issues/1/pin",
            json={},
        )
        github_adapter.pin_issue(1)

    @responses.activate
    def test_unpin_issue(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{REPOS}/issues/1/pin",
            status=204,
        )
        github_adapter.unpin_issue(1)


class TestIssueTimeline:
    @responses.activate
    def test_get_issue_timeline(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/issues/1/timeline",
            json=[
                {
                    "id": 1,
                    "event": "labeled",
                    "actor": {"login": "alice"},
                    "created_at": "2024-01-01T00:00:00Z",
                    "label": {"name": "bug"},
                }
            ],
        )
        result = github_adapter.get_issue_timeline(1)
        assert len(result) == 1
        assert result[0].event == "labeled"


# ── C-01: download_release_asset パストラバーサル防止 ──


class TestDownloadReleaseAssetPathTraversal:
    @responses.activate
    def test_traversal_name_sanitized(self, github_adapter, tmp_path):
        """アセット名に ../ を含む場合に basename でサニタイズされることを検証する。"""
        responses.add(
            responses.GET,
            f"{REPOS}/releases/assets/1",
            json={"name": "../malicious.bin", "id": 1},
        )
        responses.add(
            responses.GET,
            f"{REPOS}/releases/assets/1",
            body=b"content",
            headers={"Content-Type": "application/octet-stream"},
        )
        result = github_adapter.download_release_asset(
            tag="v1.0.0", asset_id=1, output_dir=str(tmp_path)
        )
        import os

        # ディレクトリ成分が除去され、output_dir 内に保存される
        assert os.path.basename(result) == "malicious.bin"
        assert os.path.dirname(os.path.realpath(result)) == os.path.realpath(str(tmp_path))

    @responses.activate
    def test_safe_name_accepted(self, github_adapter, tmp_path):
        """正常なアセット名はパストラバーサルチェックを通過する。"""
        responses.add(
            responses.GET,
            f"{REPOS}/releases/assets/1",
            json={"name": "app.zip", "id": 1},
        )
        responses.add(
            responses.GET,
            f"{REPOS}/releases/assets/1",
            body=b"content",
            headers={"Content-Type": "application/octet-stream"},
        )
        result = github_adapter.download_release_asset(
            tag="v1.0.0", asset_id=1, output_dir=str(tmp_path)
        )
        assert "app.zip" in result


# ── C-04: upload_release_asset が upload_url を使用する ──


class TestUploadReleaseAssetUrl:
    @responses.activate
    def test_upload_uses_upload_url_from_release(self, github_adapter, tmp_path):
        """upload_release_asset が release の upload_url を使用することを検証する。"""
        test_file = tmp_path / "asset.zip"
        test_file.write_bytes(b"zip content")
        upload_url = "https://uploads.github.com/repos/test-owner/test-repo/releases/1/assets"
        responses.add(
            responses.GET,
            f"{REPOS}/releases/tags/v1.0.0",
            json={
                "id": 1,
                "tag_name": "v1.0.0",
                "name": "v1.0.0",
                "body": "",
                "draft": False,
                "prerelease": False,
                "created_at": "2024-01-01T00:00:00Z",
                "author": {"login": "user"},
                "upload_url": upload_url + "{?name,label}",
            },
        )
        responses.add(
            responses.POST,
            upload_url,
            json={
                "id": 1,
                "name": "asset.zip",
                "size": 11,
                "browser_download_url": "https://example.com/asset.zip",
                "created_at": "2024-01-01T00:00:00Z",
            },
            status=201,
        )
        result = github_adapter.upload_release_asset(
            tag="v1.0.0", file_path=str(test_file), name="asset.zip"
        )
        assert result.name == "asset.zip"
        # アップロード先が uploads.github.com であることを検証
        assert "uploads.github.com" in responses.calls[1].request.url


class TestUpdateOrganization:
    def test_update_display_name(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            "https://api.github.com/orgs/my-org",
            json={
                "login": "my-org",
                "name": "New Name",
                "description": "desc",
                "html_url": "https://github.com/my-org",
            },
            status=200,
        )
        org = github_adapter.update_organization("my-org", display_name="New Name")
        assert org.display_name == "New Name"
        req_body = json.loads(mock_responses.calls[0].request.body)
        assert req_body["name"] == "New Name"

    def test_update_description(self, mock_responses, github_adapter):
        mock_responses.add(
            responses.PATCH,
            "https://api.github.com/orgs/my-org",
            json={
                "login": "my-org",
                "name": "My Org",
                "description": "New description",
                "html_url": "https://github.com/my-org",
            },
            status=200,
        )
        org = github_adapter.update_organization("my-org", description="New description")
        assert org.description == "New description"


# --- SSH Key 系 ---


def _ssh_key_data(*, key_id=1):
    return {
        "id": key_id,
        "title": "my-key",
        "key": "ssh-rsa AAAA...",
        "created_at": "2025-01-01T00:00:00Z",
    }


class TestGetSshKey:
    @responses.activate
    def test_get(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/user/keys/1",
            json=_ssh_key_data(),
            status=200,
        )
        from gfo.adapter.base import SshKey

        key = github_adapter.get_ssh_key(1)
        assert isinstance(key, SshKey)
        assert key.id == 1
        assert key.title == "my-key"

    @responses.activate
    def test_not_found(self, github_adapter):
        from gfo.exceptions import NotFoundError

        responses.add(
            responses.GET,
            f"{BASE}/user/keys/999",
            json={"message": "Not Found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_ssh_key(999)


# --- GPG Key 系 ---


def _gpg_key_data(*, key_id=10):
    return {
        "id": key_id,
        "primary_key_id": "ABC123",
        "public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----",
        "emails": [{"email": "user@example.com"}],
        "created_at": "2025-01-01T00:00:00Z",
    }


class TestGetGpgKey:
    @responses.activate
    def test_get(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/user/gpg_keys/10",
            json=_gpg_key_data(),
            status=200,
        )
        from gfo.adapter.base import GpgKey

        key = github_adapter.get_gpg_key(10)
        assert isinstance(key, GpgKey)
        assert key.id == 10
        assert key.primary_key_id == "ABC123"

    @responses.activate
    def test_not_found(self, github_adapter):
        from gfo.exceptions import NotFoundError

        responses.add(
            responses.GET,
            f"{BASE}/user/gpg_keys/999",
            json={"message": "Not Found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.get_gpg_key(999)


# --- Workflow ---


def _workflow_data(*, wf_id=1, state="active"):
    return {
        "id": wf_id,
        "name": "CI",
        "path": ".github/workflows/ci.yml",
        "state": state,
    }


class TestListWorkflows:
    @responses.activate
    def test_list(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/workflows",
            json={"workflows": [_workflow_data()], "total_count": 1},
            status=200,
        )
        from gfo.adapter.base import Workflow

        workflows = github_adapter.list_workflows()
        assert len(workflows) == 1
        assert isinstance(workflows[0], Workflow)
        assert workflows[0].name == "CI"
        assert workflows[0].state == "active"

    @responses.activate
    def test_disabled_fork_state_preserved(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/workflows",
            json={"workflows": [_workflow_data(state="disabled_fork")], "total_count": 1},
            status=200,
        )
        workflows = github_adapter.list_workflows()
        assert workflows[0].state == "disabled_fork"

    @responses.activate
    def test_empty(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/workflows",
            json={"workflows": [], "total_count": 0},
            status=200,
        )
        assert github_adapter.list_workflows() == []


class TestEnableWorkflow:
    @responses.activate
    def test_enable(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/actions/workflows/1/enable",
            status=204,
        )
        github_adapter.enable_workflow(1)
        assert responses.calls[0].request.method == "PUT"

    @responses.activate
    def test_enable_404(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/actions/workflows/999/enable",
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.enable_workflow(999)


class TestDisableWorkflow:
    @responses.activate
    def test_disable(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/actions/workflows/1/disable",
            status=204,
        )
        github_adapter.disable_workflow(1)
        assert responses.calls[0].request.method == "PUT"


# --- Artifact ---


def _artifact_data(*, artifact_id=11, name="build-output"):
    return {
        "id": artifact_id,
        "name": name,
        "size_in_bytes": 1024,
        "archive_download_url": f"https://api.github.com/repos/test-owner/test-repo/actions/artifacts/{artifact_id}/zip",
        "created_at": "2025-01-01T00:00:00Z",
    }


class TestListArtifacts:
    @responses.activate
    def test_list(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300/artifacts",
            json={"artifacts": [_artifact_data()], "total_count": 1},
            status=200,
        )
        from gfo.adapter.base import Artifact

        artifacts = github_adapter.list_artifacts(300)
        assert len(artifacts) == 1
        assert isinstance(artifacts[0], Artifact)
        assert artifacts[0].name == "build-output"

    @responses.activate
    def test_empty(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300/artifacts",
            json={"artifacts": [], "total_count": 0},
            status=200,
        )
        assert github_adapter.list_artifacts(300) == []


class TestDownloadArtifact:
    @responses.activate
    def test_download(self, github_adapter, tmp_path):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/artifacts/11",
            json=_artifact_data(),
            status=200,
        )
        responses.add(
            responses.GET,
            f"{REPOS}/actions/artifacts/11/zip",
            body=b"PK\x03\x04zipdata",
            status=200,
        )
        result = github_adapter.download_artifact(300, 11, output_dir=str(tmp_path))
        import os

        assert os.path.basename(result) == "build-output.zip"
        assert os.path.exists(result)

    @responses.activate
    def test_download_404(self, github_adapter):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/artifacts/999",
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.download_artifact(300, 999)


class TestDownloadRunLogs:
    @responses.activate
    def test_download_all_logs(self, github_adapter, tmp_path):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/runs/300/logs",
            body=b"PK\x03\x04logdata",
            status=200,
        )
        result = github_adapter.download_run_logs(300, output_dir=str(tmp_path))
        import os

        assert os.path.basename(result) == "logs-300.zip"
        assert os.path.exists(result)

    @responses.activate
    def test_download_job_logs(self, github_adapter, tmp_path):
        responses.add(
            responses.GET,
            f"{REPOS}/actions/jobs/42/logs",
            body="log line 1\nlog line 2",
            status=200,
        )
        result = github_adapter.download_run_logs(300, job_id=42, output_dir=str(tmp_path))
        import os

        assert os.path.basename(result) == "logs-300-job-42.txt"
        assert os.path.exists(result)
        with open(result) as f:
            assert "log line 1" in f.read()


class TestIssueSubscribe:
    @responses.activate
    def test_subscribe_issue(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/issues/1/subscription",
            status=200,
        )
        github_adapter.subscribe_issue(1)
        req_body = json.loads(responses.calls[0].request.body)
        assert req_body["subscribed"] is True

    @responses.activate
    def test_unsubscribe_issue(self, github_adapter):
        responses.add(
            responses.PUT,
            f"{REPOS}/issues/1/subscription",
            status=200,
        )
        github_adapter.unsubscribe_issue(1)
        req_body = json.loads(responses.calls[0].request.body)
        assert req_body["subscribed"] is False

    @responses.activate
    def test_subscribe_404(self, github_adapter):
        from gfo.exceptions import NotFoundError

        responses.add(
            responses.PUT,
            f"{REPOS}/issues/999/subscription",
            json={"message": "Not Found"},
            status=404,
        )
        with pytest.raises(NotFoundError):
            github_adapter.subscribe_issue(999)


class TestOrgSecrets:
    @responses.activate
    def test_list_org_secrets(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/orgs/test-org/actions/secrets",
            json={
                "secrets": [
                    {"name": "ORG_SECRET", "created_at": "2024-01-01", "updated_at": "2024-01-02"}
                ]
            },
            status=200,
        )
        secrets = github_adapter.list_secrets(scope="test-org")
        assert len(secrets) == 1
        assert secrets[0].name == "ORG_SECRET"

    @responses.activate
    def test_delete_org_secret(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{BASE}/orgs/test-org/actions/secrets/ORG_SECRET",
            status=204,
        )
        github_adapter.delete_secret("ORG_SECRET", scope="test-org")


class TestOrgVariables:
    @responses.activate
    def test_list_org_variables(self, github_adapter):
        responses.add(
            responses.GET,
            f"{BASE}/orgs/test-org/actions/variables",
            json={
                "variables": [
                    {"name": "ORG_VAR", "value": "val", "created_at": "", "updated_at": ""}
                ]
            },
            status=200,
        )
        variables = github_adapter.list_variables(scope="test-org")
        assert len(variables) == 1
        assert variables[0].name == "ORG_VAR"

    @responses.activate
    def test_delete_org_variable(self, github_adapter):
        responses.add(
            responses.DELETE,
            f"{BASE}/orgs/test-org/actions/variables/ORG_VAR",
            status=204,
        )
        github_adapter.delete_variable("ORG_VAR", scope="test-org")
