from __future__ import annotations

import json
import re
import subprocess
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import cast
from urllib.parse import urlparse

from gh_llm.invocation import display_command, display_command_with
from gh_llm.models import CheckItem, PageInfo, PullRequestMeta, PullRequestRef, TimelineEvent, TimelinePage

MAX_INLINE_TEXT = 8000
MAX_INLINE_LINES = 200
DEFAULT_REVIEW_DIFF_HUNK_LINES = 12
GRAPHQL_MAX_ATTEMPTS = 4
GRAPHQL_BACKOFF_BASE_SECONDS = 0.25
GRAPHQL_BACKOFF_MAX_SECONDS = 2.0
DETAILS_BLOCK_RE = re.compile(r"(?is)<details\b[^>]*>(.*?)</details>")
SUMMARY_RE = re.compile(r"(?is)<summary\b[^>]*>(.*?)</summary>")
HTML_TAG_RE = re.compile(r"(?is)<[^>]+>")


@dataclass(frozen=True)
class ReferenceSubject:
    type: str
    number: int
    repo: str
    detail: str


FORWARD_TIMELINE_QUERY = """
query($owner:String!,$name:String!,$number:Int!,$pageSize:Int!,$after:String){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      timelineItems(first:$pageSize,after:$after,itemTypes:[ISSUE_COMMENT,PULL_REQUEST_REVIEW,PULL_REQUEST_COMMIT,CROSS_REFERENCED_EVENT,REFERENCED_EVENT,LABELED_EVENT,UNLABELED_EVENT,HEAD_REF_FORCE_PUSHED_EVENT,MERGED_EVENT,CLOSED_EVENT,REOPENED_EVENT]){
        totalCount
        pageInfo{hasNextPage hasPreviousPage startCursor endCursor}
        nodes{
          __typename
          ... on IssueComment{
            id
            url
            createdAt
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
            reactionGroups{content users{totalCount}}
          }
          ... on PullRequestReview{
            id
            submittedAt
            state
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
          }
          ... on PullRequestCommit{ commit{ oid committedDate messageHeadline message authors(first:1){nodes{name user{login}}} } }
          ... on CrossReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            source{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on ReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            subject{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on LabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on UnlabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on HeadRefForcePushedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            ref{name}
            beforeCommit{oid}
            afterCommit{oid}
          }
          ... on MergedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ClosedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ReopenedEvent{ id createdAt actor{login ... on User{name}} }
        }
      }
    }
  }
}
""".strip()

ISSUE_FORWARD_TIMELINE_QUERY = """
query($owner:String!,$name:String!,$number:Int!,$pageSize:Int!,$after:String){
  repository(owner:$owner,name:$name){
    issue(number:$number){
      timelineItems(first:$pageSize,after:$after,itemTypes:[ISSUE_COMMENT,CROSS_REFERENCED_EVENT,REFERENCED_EVENT,LABELED_EVENT,UNLABELED_EVENT,CLOSED_EVENT,REOPENED_EVENT]){
        totalCount
        pageInfo{hasNextPage hasPreviousPage startCursor endCursor}
        nodes{
          __typename
          ... on IssueComment{
            id
            url
            createdAt
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
            reactionGroups{content users{totalCount}}
          }
          ... on CrossReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            source{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on ReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            subject{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on LabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on UnlabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on ClosedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ReopenedEvent{ id createdAt actor{login ... on User{name}} }
        }
      }
    }
  }
}
""".strip()

BACKWARD_TIMELINE_QUERY = """
query($owner:String!,$name:String!,$number:Int!,$pageSize:Int!,$before:String){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      timelineItems(last:$pageSize,before:$before,itemTypes:[ISSUE_COMMENT,PULL_REQUEST_REVIEW,PULL_REQUEST_COMMIT,CROSS_REFERENCED_EVENT,REFERENCED_EVENT,LABELED_EVENT,UNLABELED_EVENT,HEAD_REF_FORCE_PUSHED_EVENT,MERGED_EVENT,CLOSED_EVENT,REOPENED_EVENT]){
        totalCount
        pageInfo{hasNextPage hasPreviousPage startCursor endCursor}
        nodes{
          __typename
          ... on IssueComment{
            id
            url
            createdAt
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
            reactionGroups{content users{totalCount}}
          }
          ... on PullRequestReview{
            id
            submittedAt
            state
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
          }
          ... on PullRequestCommit{ commit{ oid committedDate messageHeadline message authors(first:1){nodes{name user{login}}} } }
          ... on CrossReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            source{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on ReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            subject{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on LabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on UnlabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on HeadRefForcePushedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            ref{name}
            beforeCommit{oid}
            afterCommit{oid}
          }
          ... on MergedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ClosedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ReopenedEvent{ id createdAt actor{login ... on User{name}} }
        }
      }
    }
  }
}
""".strip()

ISSUE_BACKWARD_TIMELINE_QUERY = """
query($owner:String!,$name:String!,$number:Int!,$pageSize:Int!,$before:String){
  repository(owner:$owner,name:$name){
    issue(number:$number){
      timelineItems(last:$pageSize,before:$before,itemTypes:[ISSUE_COMMENT,CROSS_REFERENCED_EVENT,REFERENCED_EVENT,LABELED_EVENT,UNLABELED_EVENT,CLOSED_EVENT,REOPENED_EVENT]){
        totalCount
        pageInfo{hasNextPage hasPreviousPage startCursor endCursor}
        nodes{
          __typename
          ... on IssueComment{
            id
            url
            createdAt
            body
            isMinimized
            minimizedReason
            author{login ... on User{name}}
            reactionGroups{content users{totalCount}}
          }
          ... on CrossReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            source{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on ReferencedEvent{
            id
            createdAt
            actor{login ... on User{name}}
            isCrossRepository
            subject{
              __typename
              ... on PullRequest{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
              ... on Issue{
                number
                title
                author{login ... on User{name}}
                repository{nameWithOwner}
              }
            }
          }
          ... on LabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on UnlabeledEvent{ id createdAt actor{login ... on User{name}} label{name} }
          ... on ClosedEvent{ id createdAt actor{login ... on User{name}} }
          ... on ReopenedEvent{ id createdAt actor{login ... on User{name}} }
        }
      }
    }
  }
}
""".strip()

REVIEW_THREADS_QUERY = """
query($owner:String!,$name:String!,$number:Int!,$after:String){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      reviewThreads(first:100,after:$after){
        pageInfo{hasNextPage endCursor}
        nodes{
          id
          isResolved
          comments(first:100){
            nodes{
              id
              path
              body
              line
              originalLine
              startLine
              originalStartLine
              diffHunk
              createdAt
              outdated
              isMinimized
              minimizedReason
              author{login ... on User{name}}
              reactionGroups{content users{totalCount}}
              pullRequestReview{id}
            }
          }
        }
      }
    }
  }
}
""".strip()

CHECKS_QUERY = """
query($owner:String!,$name:String!,$number:Int!){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      commits(last:1){
        nodes{
          commit{
            statusCheckRollup{
              contexts(first:100){
                nodes{
                  __typename
                  ... on CheckRun{
                    name
                    status
                    conclusion
                    detailsUrl
                    databaseId
                  }
                  ... on StatusContext{
                    context
                    state
                    targetUrl
                    description
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
""".strip()

PR_NODE_ID_QUERY = """
query($owner:String!,$name:String!,$number:Int!){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      id
    }
  }
}
""".strip()

ADD_PULL_REQUEST_REVIEW_QUERY = """
mutation($pullRequestId:ID!,$event:PullRequestReviewEvent!,$body:String){
  addPullRequestReview(input:{pullRequestId:$pullRequestId,event:$event,body:$body}){
    pullRequestReview{
      id
      state
    }
  }
}
""".strip()

PENDING_REVIEWS_QUERY = """
query($owner:String!,$name:String!,$number:Int!){
  repository(owner:$owner,name:$name){
    pullRequest(number:$number){
      reviews(last:50){
        nodes{
          id
          state
          author{login}
        }
      }
    }
  }
}
""".strip()

SUBMIT_PULL_REQUEST_REVIEW_QUERY = """
mutation($id:ID!,$event:PullRequestReviewEvent!,$body:String){
  submitPullRequestReview(input:{pullRequestReviewId:$id,event:$event,body:$body}){
    pullRequestReview{
      id
      state
    }
  }
}
""".strip()

ADD_PULL_REQUEST_REVIEW_THREAD_QUERY = """
mutation($pullRequestId:ID!,$path:String!,$line:Int!,$side:DiffSide!,$body:String!){
  addPullRequestReviewThread(input:{pullRequestId:$pullRequestId,path:$path,line:$line,side:$side,body:$body}){
    thread{
      id
      comments(first:1){
        nodes{id}
      }
    }
  }
}
""".strip()


class GitHubClient:
    def __init__(self) -> None:
        self._review_threads_cache: dict[tuple[str, str, int], dict[str, list[dict[str, object]]]] = {}
        self._viewer_login: str | None = None

    def resolve_pull_request(self, selector: str | None, repo: str | None) -> PullRequestMeta:
        fields = [
            "number",
            "title",
            "url",
            "author",
            "state",
            "isDraft",
            "body",
            "updatedAt",
            "reactionGroups",
        ]
        cmd = ["gh", "pr", "view"]
        if selector:
            cmd.append(selector)
        if repo:
            cmd.extend(["--repo", repo])
        cmd.extend(["--json", ",".join(fields)])

        payload = _run_command_json(cmd)
        number = _as_int(payload.get("number"), context="number")
        title = _as_optional_str(payload.get("title")) or ""
        url = _as_optional_str(payload.get("url")) or ""
        author = _get_login(payload.get("author"))
        state = _as_optional_str(payload.get("state")) or "UNKNOWN"
        is_draft = bool(payload.get("isDraft"))
        body = _as_optional_str(payload.get("body")) or ""
        updated_at = _as_optional_str(payload.get("updatedAt")) or ""
        reactions_summary = _format_reactions(payload.get("reactionGroups"))

        owner, name = _parse_owner_repo(url)
        ref = PullRequestRef(owner=owner, name=name, number=number)
        if self._viewer_login is None:
            self._viewer_login = self._get_viewer_login()
        can_edit_body = author == (self._viewer_login or "")
        return PullRequestMeta(
            ref=ref,
            title=title,
            url=url,
            author=author,
            state=state,
            is_draft=is_draft,
            body=body,
            updated_at=updated_at,
            kind="pr",
            reactions_summary=reactions_summary,
            can_edit_body=can_edit_body,
        )

    def resolve_issue(self, selector: str | None, repo: str | None) -> PullRequestMeta:
        fields = [
            "number",
            "title",
            "url",
            "author",
            "state",
            "body",
            "updatedAt",
            "reactionGroups",
        ]
        cmd = ["gh", "issue", "view"]
        if selector:
            cmd.append(selector)
        if repo:
            cmd.extend(["--repo", repo])
        cmd.extend(["--json", ",".join(fields)])

        payload = _run_command_json(cmd)
        number = _as_int(payload.get("number"), context="number")
        title = _as_optional_str(payload.get("title")) or ""
        url = _as_optional_str(payload.get("url")) or ""
        author = _get_login(payload.get("author"))
        state = _as_optional_str(payload.get("state")) or "UNKNOWN"
        body = _as_optional_str(payload.get("body")) or ""
        updated_at = _as_optional_str(payload.get("updatedAt")) or ""
        reactions_summary = _format_reactions(payload.get("reactionGroups"))

        owner, name = _parse_owner_repo(url)
        ref = PullRequestRef(owner=owner, name=name, number=number)
        if self._viewer_login is None:
            self._viewer_login = self._get_viewer_login()
        can_edit_body = author == (self._viewer_login or "")
        return PullRequestMeta(
            ref=ref,
            title=title,
            url=url,
            author=author,
            state=state,
            is_draft=False,
            body=body,
            updated_at=updated_at,
            kind="issue",
            reactions_summary=reactions_summary,
            can_edit_body=can_edit_body,
        )

    def fetch_timeline_forward(
        self,
        ref: PullRequestRef,
        page_size: int,
        after: str | None,
        *,
        show_resolved_details: bool = False,
        show_minimized_details: bool = False,
        show_details_blocks: bool = False,
        diff_hunk_lines: int | None = DEFAULT_REVIEW_DIFF_HUNK_LINES,
        kind: str = "pr",
    ) -> TimelinePage:
        variables: dict[str, str | int] = {
            "owner": ref.owner,
            "name": ref.name,
            "number": ref.number,
            "pageSize": page_size,
        }
        if after is not None:
            variables["after"] = after

        if kind == "issue":
            connection = _run_graphql_connection(ISSUE_FORWARD_TIMELINE_QUERY, variables, subject_key="issue")
            threads_by_review: dict[str, list[dict[str, object]]] = {}
        else:
            connection = _run_graphql_connection(FORWARD_TIMELINE_QUERY, variables, subject_key="pullRequest")
            threads_by_review = self._get_review_threads_by_review(ref)
        return _parse_timeline_page(
            connection,
            ref=ref,
            threads_by_review=threads_by_review,
            show_resolved_details=show_resolved_details,
            show_minimized_details=show_minimized_details,
            show_details_blocks=show_details_blocks,
            diff_hunk_lines=diff_hunk_lines,
            viewer_login=self._viewer_login or "",
            subject_kind=kind,
        )

    def fetch_timeline_backward(
        self,
        ref: PullRequestRef,
        page_size: int,
        before: str | None,
        *,
        show_resolved_details: bool = False,
        show_minimized_details: bool = False,
        show_details_blocks: bool = False,
        diff_hunk_lines: int | None = DEFAULT_REVIEW_DIFF_HUNK_LINES,
        kind: str = "pr",
    ) -> TimelinePage:
        variables: dict[str, str | int] = {
            "owner": ref.owner,
            "name": ref.name,
            "number": ref.number,
            "pageSize": page_size,
        }
        if before is not None:
            variables["before"] = before

        if kind == "issue":
            connection = _run_graphql_connection(ISSUE_BACKWARD_TIMELINE_QUERY, variables, subject_key="issue")
            threads_by_review: dict[str, list[dict[str, object]]] = {}
        else:
            connection = _run_graphql_connection(BACKWARD_TIMELINE_QUERY, variables, subject_key="pullRequest")
            threads_by_review = self._get_review_threads_by_review(ref)
        return _parse_timeline_page(
            connection,
            ref=ref,
            threads_by_review=threads_by_review,
            show_resolved_details=show_resolved_details,
            show_minimized_details=show_minimized_details,
            show_details_blocks=show_details_blocks,
            diff_hunk_lines=diff_hunk_lines,
            viewer_login=self._viewer_login or "",
            subject_kind=kind,
        )

    def _get_review_threads_by_review(self, ref: PullRequestRef) -> dict[str, list[dict[str, object]]]:
        key = (ref.owner, ref.name, ref.number)
        cached = self._review_threads_cache.get(key)
        if cached is not None:
            return cached

        by_review: dict[str, list[dict[str, object]]] = {}
        after: str | None = None
        while True:
            variables: dict[str, str | int] = {
                "owner": ref.owner,
                "name": ref.name,
                "number": ref.number,
            }
            if after is not None:
                variables["after"] = after
            payload = _run_graphql_payload(REVIEW_THREADS_QUERY, variables)
            data_obj = _as_dict(payload.get("data"), context="graphql data")
            repo_obj = _as_dict(data_obj.get("repository"), context="repository")
            pr_obj = _as_dict(repo_obj.get("pullRequest"), context="pullRequest")
            threads_obj = _as_dict(pr_obj.get("reviewThreads"), context="reviewThreads")

            for raw_thread in _as_list(threads_obj.get("nodes")):
                thread = _as_dict(raw_thread, context="reviewThread")
                thread_id = _as_optional_str(thread.get("id")) or ""
                is_resolved = bool(thread.get("isResolved"))
                comments_obj = _as_dict_optional(thread.get("comments"))
                if comments_obj is None:
                    continue
                thread_comments: list[dict[str, object]] = []
                review_ids: set[str] = set()
                for raw_comment in _as_list(comments_obj.get("nodes")):
                    comment = _as_dict(raw_comment, context="reviewThread comment")
                    thread_comments.append(comment)
                    review_obj = _as_dict_optional(comment.get("pullRequestReview"))
                    review_id = _as_optional_str(review_obj.get("id")) if review_obj is not None else None
                    if review_id:
                        review_ids.add(review_id)

                if not thread_comments or not review_ids:
                    continue

                thread_payload: dict[str, object] = {
                    "id": thread_id,
                    "isResolved": is_resolved,
                    "comments": thread_comments,
                }
                for review_id in review_ids:
                    by_review.setdefault(review_id, []).append(thread_payload)

            page_info = _as_dict(threads_obj.get("pageInfo"), context="reviewThreads pageInfo")
            has_next = bool(page_info.get("hasNextPage"))
            after = _as_optional_str(page_info.get("endCursor"))
            if not has_next:
                break

        self._review_threads_cache[key] = by_review
        return by_review

    def reply_review_thread(self, thread_id: str, body: str) -> str:
        query = """
mutation($threadId:ID!,$body:String!){
  addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){
    comment{id}
  }
}
""".strip()
        payload = _run_graphql_payload(query, {"threadId": thread_id, "body": body})
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        reply_obj = _as_dict(data_obj.get("addPullRequestReviewThreadReply"), context="addPullRequestReviewThreadReply")
        comment_obj = _as_dict(reply_obj.get("comment"), context="reply comment")
        return _as_optional_str(comment_obj.get("id")) or ""

    def resolve_review_thread(self, thread_id: str) -> bool:
        query = """
mutation($threadId:ID!){
  resolveReviewThread(input:{threadId:$threadId}){
    thread{id isResolved}
  }
}
""".strip()
        payload = _run_graphql_payload(query, {"threadId": thread_id})
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        resolved_obj = _as_dict(data_obj.get("resolveReviewThread"), context="resolveReviewThread")
        thread_obj = _as_dict(resolved_obj.get("thread"), context="resolved thread")
        return bool(thread_obj.get("isResolved"))

    def unresolve_review_thread(self, thread_id: str) -> bool:
        query = """
mutation($threadId:ID!){
  unresolveReviewThread(input:{threadId:$threadId}){
    thread{id isResolved}
  }
}
""".strip()
        payload = _run_graphql_payload(query, {"threadId": thread_id})
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        unresolved_obj = _as_dict(data_obj.get("unresolveReviewThread"), context="unresolveReviewThread")
        thread_obj = _as_dict(unresolved_obj.get("thread"), context="unresolved thread")
        return bool(thread_obj.get("isResolved"))

    def edit_comment(self, comment_id: str, body: str) -> str:
        if comment_id.startswith("PRRC_"):
            updated_id = self._try_update_pull_request_review_comment(comment_id=comment_id, body=body)
            if updated_id:
                return updated_id
            updated_id = self._try_update_issue_comment(comment_id=comment_id, body=body)
            if updated_id:
                return updated_id
            raise RuntimeError("failed to edit review comment")

        updated_id = self._try_update_issue_comment(comment_id=comment_id, body=body)
        if updated_id:
            return updated_id
        updated_id = self._try_update_pull_request_review_comment(comment_id=comment_id, body=body)
        if updated_id:
            return updated_id
        raise RuntimeError("failed to edit comment")

    def _try_update_issue_comment(self, *, comment_id: str, body: str) -> str | None:
        query = """
mutation($id:ID!,$body:String!){
  updateIssueComment(input:{id:$id,body:$body}){
    issueComment{id}
  }
}
""".strip()
        payload = _run_graphql_payload(query, {"id": comment_id, "body": body})
        if _has_graphql_errors(payload):
            return None
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        updated_obj = _as_dict_optional(data_obj.get("updateIssueComment"))
        if updated_obj is None:
            return None
        comment_obj = _as_dict_optional(updated_obj.get("issueComment"))
        if comment_obj is None:
            return None
        updated_id = _as_optional_str(comment_obj.get("id"))
        return updated_id or None

    def _try_update_pull_request_review_comment(self, *, comment_id: str, body: str) -> str | None:
        query = """
mutation($id:ID!,$body:String!){
  updatePullRequestReviewComment(input:{pullRequestReviewCommentId:$id,body:$body}){
    pullRequestReviewComment{id}
  }
}
""".strip()
        payload = _run_graphql_payload(query, {"id": comment_id, "body": body})
        if _has_graphql_errors(payload):
            return None
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        updated_obj = _as_dict_optional(data_obj.get("updatePullRequestReviewComment"))
        if updated_obj is None:
            return None
        comment_obj = _as_dict_optional(updated_obj.get("pullRequestReviewComment"))
        if comment_obj is None:
            return None
        updated_id = _as_optional_str(comment_obj.get("id"))
        return updated_id or None

    def _get_viewer_login(self) -> str:
        payload = _run_command_json(["gh", "api", "user"])
        login = _as_optional_str(payload.get("login"))
        return login or ""

    def fetch_checks(self, ref: PullRequestRef) -> list[CheckItem]:
        payload = _run_graphql_payload(
            CHECKS_QUERY,
            {"owner": ref.owner, "name": ref.name, "number": ref.number},
        )
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        repo_obj = _as_dict(data_obj.get("repository"), context="repository")
        pr_obj = _as_dict(repo_obj.get("pullRequest"), context="pullRequest")
        commits_obj = _as_dict(pr_obj.get("commits"), context="commits")
        nodes = _as_list(commits_obj.get("nodes"))
        if not nodes:
            return []
        head = _as_dict(nodes[0], context="commit node")
        commit_obj = _as_dict(head.get("commit"), context="commit")
        rollup_obj = _as_dict_optional(commit_obj.get("statusCheckRollup"))
        if rollup_obj is None:
            return []
        contexts_obj = _as_dict_optional(rollup_obj.get("contexts"))
        if contexts_obj is None:
            return []

        items: list[CheckItem] = []
        for raw in _as_list(contexts_obj.get("nodes")):
            node = _as_dict(raw, context="check context")
            typename = _as_optional_str(node.get("__typename")) or ""
            if typename == "CheckRun":
                name = (_as_optional_str(node.get("name")) or "").strip() or "(unnamed check run)"
                status = _as_optional_str(node.get("status")) or "UNKNOWN"
                conclusion = _as_optional_str(node.get("conclusion"))
                label = f"{status}/{(conclusion or 'NONE')}"
                details_url = _as_optional_str(node.get("detailsUrl"))
                run_id, job_id = _extract_actions_run_and_job_ids(details_url)
                items.append(
                    CheckItem(
                        name=name,
                        kind="check-run",
                        status=label,
                        passed=_is_check_run_passed(status=status, conclusion=conclusion),
                        details_url=details_url,
                        run_id=run_id,
                        job_id=job_id,
                    )
                )
                continue
            if typename == "StatusContext":
                name = (_as_optional_str(node.get("context")) or "").strip() or "(unnamed status)"
                state = _as_optional_str(node.get("state")) or "UNKNOWN"
                items.append(
                    CheckItem(
                        name=name,
                        kind="status-context",
                        status=state,
                        passed=(state == "SUCCESS"),
                        details_url=_as_optional_str(node.get("targetUrl")),
                        run_id=None,
                    )
                )
        return items

    def fetch_pr_diff(self, selector: str | None, repo: str | None) -> str:
        cmd = ["gh", "pr", "diff"]
        if selector:
            cmd.append(selector)
        if repo:
            cmd.extend(["--repo", repo])
        return _run_command_text(cmd)

    def submit_pull_request_review(
        self,
        *,
        ref: PullRequestRef,
        event: str,
        body: str | None = None,
    ) -> tuple[str, str]:
        pending_review_id = self._find_pending_review_id(ref)
        if pending_review_id is not None:
            payload = _run_graphql_payload_any(
                SUBMIT_PULL_REQUEST_REVIEW_QUERY,
                {
                    "id": pending_review_id,
                    "event": event,
                    "body": body or "",
                },
            )
            data_obj = _as_dict(payload.get("data"), context="graphql data")
            submitted_obj = _as_dict(data_obj.get("submitPullRequestReview"), context="submitPullRequestReview")
            review_obj = _as_dict(submitted_obj.get("pullRequestReview"), context="pullRequestReview")
            review_id = _as_optional_str(review_obj.get("id")) or ""
            review_state = _as_optional_str(review_obj.get("state")) or ""
            if not review_id:
                raise RuntimeError("failed to submit pending pull request review")
            return review_id, review_state

        pr_id = self._resolve_pull_request_node_id(ref)
        variables: dict[str, object] = {"pullRequestId": pr_id, "event": event}
        if body is not None:
            variables["body"] = body
        review_payload = _run_graphql_payload_any(ADD_PULL_REQUEST_REVIEW_QUERY, variables)
        review_data_obj = _as_dict(review_payload.get("data"), context="graphql data")
        added_obj = _as_dict(review_data_obj.get("addPullRequestReview"), context="addPullRequestReview")
        review_obj = _as_dict(added_obj.get("pullRequestReview"), context="pullRequestReview")
        review_id = _as_optional_str(review_obj.get("id")) or ""
        review_state = _as_optional_str(review_obj.get("state")) or ""
        if not review_id:
            raise RuntimeError("failed to create pull request review")
        return review_id, review_state

    def _find_pending_review_id(self, ref: PullRequestRef) -> str | None:
        viewer = self._viewer_login
        if viewer is None:
            viewer = self._get_viewer_login()
            self._viewer_login = viewer
        payload = _run_graphql_payload(
            PENDING_REVIEWS_QUERY,
            {"owner": ref.owner, "name": ref.name, "number": ref.number},
        )
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        repo_obj = _as_dict(data_obj.get("repository"), context="repository")
        pr_obj = _as_dict(repo_obj.get("pullRequest"), context="pullRequest")
        reviews_obj = _as_dict(pr_obj.get("reviews"), context="reviews")
        for raw in _as_list(reviews_obj.get("nodes")):
            review = _as_dict(raw, context="review")
            state = _as_optional_str(review.get("state")) or ""
            author_login = _get_login(review.get("author"))
            review_id = _as_optional_str(review.get("id")) or ""
            if state == "PENDING" and author_login == viewer and review_id:
                return review_id
        return None

    def add_pull_request_review_thread_comment(
        self,
        *,
        ref: PullRequestRef,
        path: str,
        line: int,
        side: str,
        body: str,
    ) -> tuple[str, str]:
        pr_id = self._resolve_pull_request_node_id(ref)
        payload = _run_graphql_payload_any(
            ADD_PULL_REQUEST_REVIEW_THREAD_QUERY,
            {
                "pullRequestId": pr_id,
                "path": path,
                "line": line,
                "side": side,
                "body": body,
            },
        )
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        added_obj = _as_dict(data_obj.get("addPullRequestReviewThread"), context="addPullRequestReviewThread")
        thread_obj = _as_dict(added_obj.get("thread"), context="thread")
        thread_id = _as_optional_str(thread_obj.get("id")) or ""
        comments_obj = _as_dict(thread_obj.get("comments"), context="thread comments")
        comment_nodes = _as_list(comments_obj.get("nodes"))
        comment_id = ""
        if comment_nodes:
            first = _as_dict(comment_nodes[0], context="thread comment")
            comment_id = _as_optional_str(first.get("id")) or ""
        if not thread_id:
            raise RuntimeError("failed to create review thread comment")
        return thread_id, comment_id

    def _resolve_pull_request_node_id(self, ref: PullRequestRef) -> str:
        payload = _run_graphql_payload(
            PR_NODE_ID_QUERY,
            {"owner": ref.owner, "name": ref.name, "number": ref.number},
        )
        data_obj = _as_dict(payload.get("data"), context="graphql data")
        repo_obj = _as_dict(data_obj.get("repository"), context="repository")
        pr_obj = _as_dict(repo_obj.get("pullRequest"), context="pullRequest")
        pr_id = _as_optional_str(pr_obj.get("id")) or ""
        if not pr_id:
            raise RuntimeError("failed to resolve pull request node id")
        return pr_id


def _run_graphql_connection(
    query: str, variables: dict[str, str | int], *, subject_key: str = "pullRequest"
) -> dict[str, object]:
    payload = _run_graphql_payload(query, variables)
    data_obj = _as_dict(payload.get("data"), context="graphql data")
    repo_obj = _as_dict(data_obj.get("repository"), context="repository")
    subject_obj = _as_dict(repo_obj.get(subject_key), context=subject_key)
    return _as_dict(subject_obj.get("timelineItems"), context="timelineItems")


def _run_graphql_payload(query: str, variables: dict[str, str | int]) -> dict[str, object]:
    cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
    for key, value in variables.items():
        cmd.extend(["-F", f"{key}={value}"])
    return _run_command_json(
        cmd,
        max_attempts=GRAPHQL_MAX_ATTEMPTS,
        backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
        backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
    )


def _run_graphql_payload_any(query: str, variables: dict[str, object]) -> dict[str, object]:
    cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
    for key, value in variables.items():
        if isinstance(value, (dict, list)):
            cmd.extend(["-F", f"{key}={json.dumps(value, ensure_ascii=False)}"])
        else:
            cmd.extend(["-F", f"{key}={value}"])
    return _run_command_json(
        cmd,
        max_attempts=GRAPHQL_MAX_ATTEMPTS,
        backoff_base_seconds=GRAPHQL_BACKOFF_BASE_SECONDS,
        backoff_max_seconds=GRAPHQL_BACKOFF_MAX_SECONDS,
    )


def _run_command_json(
    cmd: list[str],
    *,
    max_attempts: int = 1,
    backoff_base_seconds: float = 0.0,
    backoff_max_seconds: float = 0.0,
) -> dict[str, object]:
    attempts = max(1, max_attempts)
    for attempt in range(1, attempts + 1):
        result = subprocess.run(cmd, check=False, capture_output=True, text=True)
        if result.returncode == 0:
            parsed: object = json.loads(result.stdout)
            if not isinstance(parsed, dict):
                raise RuntimeError("unexpected non-object JSON response")
            raw = cast("dict[object, object]", parsed)
            return {str(k): v for k, v in raw.items()}

        stderr = result.stderr.strip()
        if attempt >= attempts or not _is_retryable_gh_error(stderr):
            raise RuntimeError(stderr or f"command failed: {' '.join(cmd)}")
        delay = min(backoff_max_seconds, backoff_base_seconds * (2 ** (attempt - 1)))
        if delay > 0:
            time.sleep(delay)

    raise RuntimeError(f"command failed after {attempts} attempts: {' '.join(cmd)}")


def _run_command_text(
    cmd: list[str],
    *,
    max_attempts: int = 1,
    backoff_base_seconds: float = 0.0,
    backoff_max_seconds: float = 0.0,
) -> str:
    attempts = max(1, max_attempts)
    for attempt in range(1, attempts + 1):
        result = subprocess.run(cmd, check=False, capture_output=True, text=True)
        if result.returncode == 0:
            return result.stdout
        stderr = result.stderr.strip()
        if attempt >= attempts or not _is_retryable_gh_error(stderr):
            raise RuntimeError(stderr or f"command failed: {' '.join(cmd)}")
        delay = min(backoff_max_seconds, backoff_base_seconds * (2 ** (attempt - 1)))
        if delay > 0:
            time.sleep(delay)
    raise RuntimeError(f"command failed after {attempts} attempts: {' '.join(cmd)}")


def _parse_timeline_page(
    connection: dict[str, object],
    *,
    ref: PullRequestRef,
    threads_by_review: dict[str, list[dict[str, object]]],
    show_resolved_details: bool,
    show_minimized_details: bool,
    show_details_blocks: bool,
    diff_hunk_lines: int | None,
    viewer_login: str,
    subject_kind: str = "pr",
) -> TimelinePage:
    total_count = _as_int_default(connection.get("totalCount"), default=0)
    page_info_obj = _as_dict(connection.get("pageInfo"), context="pageInfo")
    page_info = PageInfo(
        has_next_page=bool(page_info_obj.get("hasNextPage")),
        has_previous_page=bool(page_info_obj.get("hasPreviousPage")),
        start_cursor=_as_optional_str(page_info_obj.get("startCursor")),
        end_cursor=_as_optional_str(page_info_obj.get("endCursor")),
    )

    items: list[TimelineEvent] = []
    for node in _as_list(connection.get("nodes")):
        parsed = _parse_node(
            _as_dict(node, context="timeline node"),
            ref=ref,
            threads_for_review=threads_by_review,
            show_resolved_details=show_resolved_details,
            show_minimized_details=show_minimized_details,
            show_details_blocks=show_details_blocks,
            diff_hunk_lines=diff_hunk_lines,
            viewer_login=viewer_login,
            subject_kind=subject_kind,
        )
        if parsed is not None:
            items.append(parsed)

    items.sort(key=lambda value: value.timestamp)
    return TimelinePage(items=items, total_count=total_count, page_info=page_info)


def _parse_node(
    node: dict[str, object],
    *,
    ref: PullRequestRef,
    threads_for_review: dict[str, list[dict[str, object]]],
    show_resolved_details: bool,
    show_minimized_details: bool,
    show_details_blocks: bool,
    diff_hunk_lines: int | None,
    viewer_login: str,
    subject_kind: str,
) -> TimelineEvent | None:
    typename = str(node.get("__typename") or "")
    if typename == "IssueComment":
        body = _as_optional_str(node.get("body"))
        details_collapsed_count = 0
        display_body = body
        if not show_details_blocks:
            display_body, details_collapsed_count = _collapse_details_blocks(body)
        is_minimized = bool(node.get("isMinimized"))
        minimized_reason = _format_minimized_reason(node.get("minimizedReason"))
        if is_minimized and not show_minimized_details:
            summary = f"(comment hidden: {minimized_reason})"
            is_truncated = True
        else:
            summary, is_truncated = _clip_text(display_body, "(no comment body)")
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="comment",
            actor=_get_actor_display(node.get("author")),
            summary=summary,
            source_id=_as_optional_str(node.get("id")) or "comment",
            full_text=display_body,
            is_truncated=is_truncated,
            editable_comment_id=(
                _as_optional_str(node.get("id")) if _get_login(node.get("author")) == viewer_login else None
            ),
            reactions_summary=_format_reactions(node.get("reactionGroups")),
            details_collapsed_count=details_collapsed_count,
        )

    if typename == "PullRequestReview":
        state = _as_optional_str(node.get("state")) or "COMMENTED"
        review_id = _as_optional_str(node.get("id")) or "review"
        is_minimized = bool(node.get("isMinimized"))
        minimized_reason = _format_minimized_reason(node.get("minimizedReason"))
        if is_minimized and not show_minimized_details:
            return TimelineEvent(
                timestamp=_parse_datetime(_as_optional_str(node.get("submittedAt"))),
                kind=f"review/{state.lower()}",
                actor=_get_actor_display(node.get("author")),
                summary=f"(review hidden: {minimized_reason})",
                source_id=review_id,
                full_text=_as_optional_str(node.get("body")),
                is_truncated=True,
                minimized_hidden_count=1,
                minimized_hidden_reasons=minimized_reason,
            )
        full_review, resolved_hidden_count, has_clipped_diff_hunk, details_collapsed_count = _build_review_text(
            node=node,
            ref=ref,
            state=state,
            threads_for_review=threads_for_review.get(review_id, []),
            show_resolved_details=show_resolved_details,
            show_minimized_details=show_minimized_details,
            show_details_blocks=show_details_blocks,
            diff_hunk_lines=diff_hunk_lines,
            viewer_login=viewer_login,
        )
        minimized_hidden_count, minimized_hidden_reasons = _build_review_minimized_summary(
            threads_for_review=threads_for_review.get(review_id, []),
            show_resolved_details=show_resolved_details,
            show_minimized_details=show_minimized_details,
        )
        summary, is_truncated = _clip_text(full_review, f"review state: {state.lower()}")
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("submittedAt"))),
            kind=f"review/{state.lower()}",
            actor=_get_actor_display(node.get("author")),
            summary=summary,
            source_id=review_id,
            full_text=full_review,
            is_truncated=is_truncated or has_clipped_diff_hunk,
            resolved_hidden_count=resolved_hidden_count,
            minimized_hidden_count=minimized_hidden_count,
            minimized_hidden_reasons=minimized_hidden_reasons,
            details_collapsed_count=details_collapsed_count,
        )

    if typename == "PullRequestCommit":
        commit = _as_dict(node.get("commit"), context="commit")
        full_message = _as_optional_str(commit.get("message"))
        message = _first_non_empty_line(full_message) or _as_optional_str(commit.get("messageHeadline"))
        oid = _as_optional_str(commit.get("oid")) or "commit"
        actor = _get_commit_actor(commit)
        summary, is_truncated = _clip_text(message, "(empty commit message)")
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(commit.get("committedDate"))),
            kind="push/commit",
            actor=actor,
            summary=summary,
            source_id=oid,
            full_text=full_message or message,
            is_truncated=is_truncated,
        )

    if typename == "ReferencedEvent":
        source = _as_dict_optional(node.get("subject"))
        subject = _reference_subject_summary(source)

        is_cross = bool(node.get("isCrossRepository"))
        actor = _get_actor_display(node.get("actor"))
        summary_lines: list[str] = []
        if subject is not None and subject.type == "PullRequest":
            source_number = subject.number
            source_repo = subject.repo
            detail = subject.detail
            summary_lines.append(f"referenced by PR #{source_number} {detail} ({source_repo})")
            summary_lines.append(f"⏎ view: `{display_command_with(f'pr view {source_number} --repo {source_repo}')}`")
        elif subject is not None and subject.type == "Issue":
            source_number = subject.number
            source_repo = subject.repo
            detail = subject.detail
            summary_lines.append(f"referenced by issue #{source_number} {detail} ({source_repo})")
            summary_lines.append(
                f"⏎ view (reserved): `{display_command_with(f'issue view {source_number} --repo {source_repo}')}`"
            )
        else:
            summary_lines.append("referenced by another item")
        if is_cross:
            summary_lines.append("cross-repository reference")
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="reference",
            actor=actor,
            summary="\n".join(summary_lines),
            source_id=_as_optional_str(node.get("id")) or "referenced",
        )

    if typename == "CrossReferencedEvent":
        source = _as_dict_optional(node.get("source"))
        subject = _reference_subject_summary(source)

        is_cross = bool(node.get("isCrossRepository"))
        actor = _get_actor_display(node.get("actor"))
        summary_lines: list[str] = []
        if subject is not None and subject.type == "PullRequest":
            source_number = subject.number
            source_repo = subject.repo
            detail = subject.detail
            summary_lines.append(f"cross-referenced by PR #{source_number} {detail} ({source_repo})")
            summary_lines.append(f"⏎ view: `{display_command_with(f'pr view {source_number} --repo {source_repo}')}`")
        elif subject is not None and subject.type == "Issue":
            source_number = subject.number
            source_repo = subject.repo
            detail = subject.detail
            summary_lines.append(f"cross-referenced by issue #{source_number} {detail} ({source_repo})")
            summary_lines.append(
                f"⏎ view (reserved): `{display_command_with(f'issue view {source_number} --repo {source_repo}')}`"
            )
        else:
            summary_lines.append("cross-referenced by another item")
        if is_cross:
            summary_lines.append("cross-repository reference")
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="cross-reference",
            actor=actor,
            summary="\n".join(summary_lines),
            source_id=_as_optional_str(node.get("id")) or "cross-referenced",
        )

    if typename == "LabeledEvent":
        label_obj = _as_dict_optional(node.get("label"))
        label_name = _as_optional_str(label_obj.get("name")) if label_obj is not None else None
        label = label_name or "(unknown label)"
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="label/add",
            actor=_get_actor_display(node.get("actor")),
            summary=f"added label `{label}`",
            source_id=_as_optional_str(node.get("id")) or "label/add",
        )

    if typename == "UnlabeledEvent":
        label_obj = _as_dict_optional(node.get("label"))
        label_name = _as_optional_str(label_obj.get("name")) if label_obj is not None else None
        label = label_name or "(unknown label)"
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="label/remove",
            actor=_get_actor_display(node.get("actor")),
            summary=f"removed label `{label}`",
            source_id=_as_optional_str(node.get("id")) or "label/remove",
        )

    if typename == "HeadRefForcePushedEvent":
        before_obj = _as_dict_optional(node.get("beforeCommit"))
        after_obj = _as_dict_optional(node.get("afterCommit"))
        ref_obj = _as_dict_optional(node.get("ref"))
        before = (_as_optional_str(before_obj.get("oid")) if before_obj is not None else "") or "unknown"
        after = (_as_optional_str(after_obj.get("oid")) if after_obj is not None else "") or "unknown"
        ref_name = (_as_optional_str(ref_obj.get("name")) if ref_obj is not None else "") or "unknown"
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="push/force",
            actor=_get_actor_display(node.get("actor")),
            summary=f"force-pushed `{ref_name}`\n{before[:7]} -> {after[:7]}",
            source_id=_as_optional_str(node.get("id")) or "push/force",
        )

    if typename == "MergedEvent":
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind="pr/merged",
            actor=_get_actor_display(node.get("actor")),
            summary="pull request merged",
            source_id=_as_optional_str(node.get("id")) or "pr/merged",
        )

    if typename in {"ClosedEvent", "ReopenedEvent"}:
        noun = "issue" if subject_kind == "issue" else "pull request"
        kind_map = {
            "ClosedEvent": f"{subject_kind}/closed",
            "ReopenedEvent": f"{subject_kind}/reopened",
        }
        summary_map = {
            "ClosedEvent": f"{noun} closed",
            "ReopenedEvent": f"{noun} reopened",
        }
        return TimelineEvent(
            timestamp=_parse_datetime(_as_optional_str(node.get("createdAt"))),
            kind=kind_map[typename],
            actor=_get_actor_display(node.get("actor")),
            summary=summary_map[typename],
            source_id=_as_optional_str(node.get("id")) or kind_map[typename],
        )

    return None


def _get_commit_actor(commit: dict[str, object]) -> str:
    authors = _as_dict(commit.get("authors"), context="commit authors")
    nodes = _as_list(authors.get("nodes"))
    if not nodes:
        return "unknown"
    first = _as_dict(nodes[0], context="commit author")
    user = _as_dict_optional(first.get("user"))
    if user is not None:
        login = _as_optional_str(user.get("login"))
        if login:
            return login
    name = _as_optional_str(first.get("name"))
    return name or "unknown"


def _get_login(value: object) -> str:
    obj = _as_dict_optional(value)
    if obj is None:
        return "unknown"
    login = _as_optional_str(obj.get("login"))
    return login or "unknown"


def _get_actor_display(value: object) -> str:
    obj = _as_dict_optional(value)
    if obj is None:
        return "unknown"
    login = _as_optional_str(obj.get("login")) or "unknown"
    name = _as_optional_str(obj.get("name"))
    if name:
        normalized_name = name.strip()
        if normalized_name and normalized_name != login:
            return f"{login} ({normalized_name})"
    return login


def _parse_owner_repo(pr_url: str) -> tuple[str, str]:
    parsed = urlparse(pr_url)
    parts = [segment for segment in parsed.path.split("/") if segment]
    if len(parts) < 2:
        raise RuntimeError(f"failed to parse owner/repo from url: {pr_url}")
    return parts[0], parts[1]


def _clip_text(text: str | None, fallback: str, limit: int = MAX_INLINE_TEXT) -> tuple[str, bool]:
    if not text:
        return fallback, False
    trimmed = text.strip()
    if not trimmed:
        return fallback, False
    lines = [line.rstrip() for line in trimmed.splitlines()]
    normalized = "\n".join(lines)
    if len(normalized) <= limit and len(lines) <= MAX_INLINE_LINES:
        return normalized, False

    clipped = normalized[:limit].rstrip()
    clipped_lines = clipped.splitlines()[:MAX_INLINE_LINES]
    return "\n".join(clipped_lines).rstrip() + "...", True


def _first_non_empty_line(text: str | None) -> str | None:
    if text is None:
        return None
    for raw in text.splitlines():
        line = raw.strip()
        if line:
            return line
    return None


def _build_review_text(
    node: dict[str, object],
    ref: PullRequestRef,
    state: str,
    *,
    threads_for_review: list[dict[str, object]],
    show_resolved_details: bool,
    show_minimized_details: bool,
    show_details_blocks: bool,
    diff_hunk_lines: int | None,
    viewer_login: str,
) -> tuple[str, int, bool, int]:
    body = (_as_optional_str(node.get("body")) or "").strip()
    details_collapsed_count = 0
    if not show_details_blocks:
        body, body_details_count = _collapse_details_blocks(body)
        details_collapsed_count += body_details_count
    total_count = sum(
        len(_as_list(_as_dict(thread, context="thread").get("comments"))) for thread in threads_for_review
    )
    detail_lines: list[str] = []
    resolved_hidden_count = 0
    rendered_comments = 0
    rendered_thread_index = 0
    has_clipped_diff_hunk = False
    for raw_thread in threads_for_review:
        thread = _as_dict(raw_thread, context="review thread")
        is_resolved = bool(thread.get("isResolved"))
        thread_id = _as_optional_str(thread.get("id")) or "(unknown thread id)"
        comment_nodes = _as_list(thread.get("comments"))
        if is_resolved and not show_resolved_details:
            resolved_hidden_count += len(comment_nodes)
            continue
        rendered_thread_index += 1
        thread_lines, visible_comments, thread_has_clipped_diff_hunk, thread_details_collapsed_count = (
            _render_review_thread_block(
                thread_id=thread_id,
                is_resolved=is_resolved,
                thread_index=rendered_thread_index,
                comments=comment_nodes,
                ref=ref,
                viewer_login=viewer_login,
                show_minimized_details=show_minimized_details,
                show_details_blocks=show_details_blocks,
                diff_hunk_lines=diff_hunk_lines,
            )
        )
        detail_lines.extend(thread_lines)
        rendered_comments += visible_comments
        has_clipped_diff_hunk = has_clipped_diff_hunk or thread_has_clipped_diff_hunk
        details_collapsed_count += thread_details_collapsed_count

    chunks: list[str] = []
    if body:
        chunks.append(body)
    if total_count > 0:
        chunks.append(f"Review comments ({rendered_comments}/{total_count} shown):")
        if detail_lines:
            chunks.extend(detail_lines)
        hidden_count = total_count - rendered_comments
        if hidden_count > 0:
            chunks.append(f"... {hidden_count} review comments hidden.")
    elif threads_for_review:
        chunks.append("Review comments (0/0 shown):")
        chunks.append("(review threads exist but contain no comment nodes)")

    if not chunks:
        return f"review state: {state.lower()}", resolved_hidden_count, has_clipped_diff_hunk, details_collapsed_count
    return "\n".join(chunks), resolved_hidden_count, has_clipped_diff_hunk, details_collapsed_count


def _render_review_thread_block(
    *,
    thread_id: str,
    is_resolved: bool,
    thread_index: int,
    comments: list[object],
    ref: PullRequestRef,
    viewer_login: str,
    show_minimized_details: bool,
    show_details_blocks: bool,
    diff_hunk_lines: int | None,
) -> tuple[list[str], int, bool, int]:
    lines = [f"- Thread[{thread_index}] {thread_id}"]
    visible_comments = 0
    minimized_hidden_count = 0
    minimized_reasons: set[str] = set()
    has_clipped_diff_hunk = False
    details_collapsed_count = 0
    for comment_index, raw_comment in enumerate(comments, start=1):
        comment = _as_dict(raw_comment, context="review comment")
        is_minimized = bool(comment.get("isMinimized"))
        is_outdated = bool(comment.get("outdated")) or bool(comment.get("isOutdated"))
        if (is_minimized or is_outdated) and not show_minimized_details:
            minimized_hidden_count += 1
            if is_outdated:
                minimized_reasons.add("outdated")
            if is_minimized:
                minimized_reasons.add(_format_minimized_reason(comment.get("minimizedReason")))
            continue
        comment_lines, comment_has_clipped_diff_hunk, comment_details_collapsed_count = _render_review_comment_block(
            comment=comment,
            index=comment_index,
            include_diff_hunk=(comment_index == 1),
            ref=ref,
            viewer_login=viewer_login,
            show_details_blocks=show_details_blocks,
            diff_hunk_lines=diff_hunk_lines,
        )
        lines.extend(comment_lines)
        visible_comments += 1
        has_clipped_diff_hunk = has_clipped_diff_hunk or comment_has_clipped_diff_hunk
        details_collapsed_count += comment_details_collapsed_count
    if minimized_hidden_count > 0:
        reason_text = ", ".join(sorted(minimized_reasons))
        lines.append(f"  ... {minimized_hidden_count} hidden review comments (reason: {reason_text}).")
    reply_cmd = display_command_with(
        f"pr thread-reply {thread_id} --body '<reply>' --pr {ref.number} --repo {ref.owner}/{ref.name}"
    )
    unresolve_cmd = display_command_with(
        f"pr thread-unresolve {thread_id} --pr {ref.number} --repo {ref.owner}/{ref.name}"
    )
    resolve_cmd = display_command_with(f"pr thread-resolve {thread_id} --pr {ref.number} --repo {ref.owner}/{ref.name}")
    lines.append(f"  ◌ thread_id: {thread_id}")
    lines.append("  ⌨ reply_body: '<reply>'")
    lines.append(f"  ⏎ Reply via {display_command()}: `{reply_cmd}`")
    if is_resolved:
        lines.append(f"  ⏎ Unresolve via {display_command()}: `{unresolve_cmd}`")
    else:
        lines.append(f"  ⏎ Resolve via {display_command()}: `{resolve_cmd}`")
    return lines, visible_comments, has_clipped_diff_hunk, details_collapsed_count


def _build_review_minimized_summary(
    *,
    threads_for_review: list[dict[str, object]],
    show_resolved_details: bool,
    show_minimized_details: bool,
) -> tuple[int, str | None]:
    if show_minimized_details:
        return 0, None
    reasons: set[str] = set()
    count = 0
    for raw_thread in threads_for_review:
        thread = _as_dict(raw_thread, context="review thread for minimized summary")
        if bool(thread.get("isResolved")) and not show_resolved_details:
            continue
        for raw_comment in _as_list(thread.get("comments")):
            comment = _as_dict(raw_comment, context="review comment for minimized summary")
            is_minimized = bool(comment.get("isMinimized"))
            is_outdated = bool(comment.get("outdated")) or bool(comment.get("isOutdated"))
            if not (is_minimized or is_outdated):
                continue
            count += 1
            if is_outdated:
                reasons.add("outdated")
            if is_minimized:
                reasons.add(_format_minimized_reason(comment.get("minimizedReason")))
    if count == 0:
        return 0, None
    return count, ", ".join(sorted(reasons))


def _format_minimized_reason(value: object) -> str:
    raw = (_as_optional_str(value) or "unknown").strip()
    if not raw:
        return "unknown"
    return raw.lower().replace("_", " ")


def _render_review_comment_block(
    comment: dict[str, object],
    index: int,
    *,
    include_diff_hunk: bool = True,
    ref: PullRequestRef,
    viewer_login: str,
    show_details_blocks: bool,
    diff_hunk_lines: int | None,
) -> tuple[list[str], bool, int]:
    path = _as_optional_str(comment.get("path")) or "(unknown path)"
    line = _as_line_ref(comment)
    author = _get_actor_display(comment.get("author"))
    created_at = _as_optional_str(comment.get("createdAt")) or "unknown time"
    body = (_as_optional_str(comment.get("body")) or "").strip()
    diff_hunk = (_as_optional_str(comment.get("diffHunk")) or "").strip()
    suggestion_lines = _extract_suggestion_lines(body)
    rendered_body = _strip_suggestion_blocks(body).strip()
    if not rendered_body and not suggestion_lines:
        rendered_body = body
    details_collapsed_count = 0
    if rendered_body and not show_details_blocks:
        rendered_body, details_collapsed_count = _collapse_details_blocks(rendered_body)

    lines = [f"- [{index}] {path}{line} by @{author} at {created_at}"]
    if rendered_body:
        lines.append("  Comment:")
        lines.extend(_indented_tag_block("comment", rendered_body, indent="  "))
    has_clipped_diff_hunk = False
    if diff_hunk and include_diff_hunk:
        rendered_diff_hunk = diff_hunk
        shown_lines = len(diff_hunk.splitlines())
        total_lines = shown_lines
        if diff_hunk_lines is not None and diff_hunk_lines > 0:
            rendered_diff_hunk, has_clipped_diff_hunk, shown_lines, total_lines = _clip_diff_hunk_lines(
                diff_hunk=diff_hunk,
                max_lines=diff_hunk_lines,
            )
        lines.append("  Diff Hunk:")
        lines.extend(_indented_fenced_block("diff", rendered_diff_hunk, indent="  "))
        if has_clipped_diff_hunk:
            lines.append(f"  ... diff hunk clipped ({shown_lines}/{total_lines} lines).")

    suggestion_diff = _suggestion_to_diff(path=path, line_ref=line, body=body)
    if suggestion_diff:
        lines.append("  Suggested Change:")
        lines.extend(_indented_fenced_block("diff", suggestion_diff, indent="  "))
    reactions_summary = _format_reactions(comment.get("reactionGroups"))
    if reactions_summary:
        lines.append(f"  Reactions: {reactions_summary}")
    comment_id = _as_optional_str(comment.get("id")) or ""
    if comment_id and author == viewer_login:
        edit_cmd = display_command_with(
            f"pr comment-edit {comment_id} --body '<comment_body>' --pr {ref.number} --repo {ref.owner}/{ref.name}"
        )
        lines.append(f"  ◌ comment_id: {comment_id}")
        lines.append("  ⌨ comment_body: '<comment_body>'")
        lines.append(f"  ⏎ Edit comment via {display_command()}: `{edit_cmd}`")

    if not body and not diff_hunk:
        lines.append("  (empty review comment)")
    return lines, has_clipped_diff_hunk, details_collapsed_count


def _clip_diff_hunk_lines(diff_hunk: str, max_lines: int) -> tuple[str, bool, int, int]:
    lines = diff_hunk.splitlines()
    total = len(lines)
    if total <= max_lines or max_lines <= 0:
        return diff_hunk, False, total, total

    head = max_lines // 2
    tail = max_lines - head
    if head <= 0:
        head = 1
        tail = max_lines - 1
    if tail <= 0:
        tail = 1
        head = max_lines - 1
    clipped = [*lines[:head], f"... ({total - max_lines} lines omitted) ...", *lines[-tail:]]
    return "\n".join(clipped), True, max_lines, total


def _as_line_ref(comment: dict[str, object]) -> str:
    line = _as_int_default(comment.get("line"), default=0)
    original_line = _as_int_default(comment.get("originalLine"), default=0)
    start_line = _as_int_default(comment.get("startLine"), default=0)
    original_start_line = _as_int_default(comment.get("originalStartLine"), default=0)

    if line > 0:
        return f":L{line}"
    if original_line > 0:
        return f":L{original_line}"
    if start_line > 0:
        return f":L{start_line}"
    if original_start_line > 0:
        return f":L{original_start_line}"
    return ""


def _indented_fenced_block(language: str, content: str, indent: str = "") -> list[str]:
    out = [f"{indent}```{language}"]
    out.extend(f"{indent}{line}" for line in content.splitlines())
    out.append(f"{indent}```")
    return out


def _indented_tag_block(tag: str, content: str, indent: str = "") -> list[str]:
    out = [f"{indent}<{tag}>"]
    out.extend(f"{indent}{line}" for line in content.splitlines())
    out.append(f"{indent}</{tag}>")
    return out


def _suggestion_to_diff(path: str, line_ref: str, body: str) -> str | None:
    suggestion_lines = _extract_suggestion_lines(body)
    if not suggestion_lines:
        return None
    header = f"@@ {path}{line_ref} @@"
    plus_lines = [f"+{line}" for line in suggestion_lines]
    return "\n".join([header, *plus_lines])


def _extract_suggestion_lines(text: str) -> list[str]:
    lines = text.splitlines()
    start = -1
    end = -1
    for idx, line in enumerate(lines):
        if line.strip().startswith("```suggestion"):
            start = idx + 1
            continue
        if start >= 0 and line.strip().startswith("```"):
            end = idx
            break
    if start < 0:
        return []
    if end < 0:
        end = len(lines)
    return [line.rstrip() for line in lines[start:end]]


def _strip_suggestion_blocks(text: str) -> str:
    lines = text.splitlines()
    out: list[str] = []
    in_suggestion = False
    for line in lines:
        marker = line.strip().lower()
        if not in_suggestion and marker.startswith("```suggestion"):
            in_suggestion = True
            continue
        if in_suggestion and marker.startswith("```"):
            in_suggestion = False
            continue
        if not in_suggestion:
            out.append(line.rstrip())
    return "\n".join(out)


def _collapse_details_blocks(text: str | None) -> tuple[str, int]:
    if not text:
        return "", 0
    count = 0

    def repl(match: re.Match[str]) -> str:
        nonlocal count
        count += 1
        block = match.group(1) or ""
        summary_match = SUMMARY_RE.search(block)
        summary = _strip_html_tags(summary_match.group(1) if summary_match else "") or "details"
        return f"\n<details>\n<summary>{summary}</summary>\n(details body collapsed)\n</details>\n"

    collapsed = DETAILS_BLOCK_RE.sub(repl, text)
    return collapsed, count


def _strip_html_tags(text: str) -> str:
    no_tags = HTML_TAG_RE.sub("", text or "")
    return " ".join(no_tags.split())


def _parse_datetime(value: str | None) -> datetime:
    if not value:
        return datetime.fromtimestamp(0, tz=UTC)
    normalized = value.replace("Z", "+00:00")
    return datetime.fromisoformat(normalized)


def _as_dict(value: object, *, context: str) -> dict[str, object]:
    if not isinstance(value, dict):
        raise RuntimeError(f"invalid {context} structure")
    raw = cast("dict[object, object]", value)
    return {str(k): v for k, v in raw.items()}


def _as_dict_optional(value: object) -> dict[str, object] | None:
    if isinstance(value, dict):
        raw = cast("dict[object, object]", value)
        return {str(k): v for k, v in raw.items()}
    return None


def _as_list(value: object) -> list[object]:
    if isinstance(value, list):
        return cast("list[object]", value)
    return []


def _as_optional_str(value: object) -> str | None:
    if value is None:
        return None
    if isinstance(value, str):
        return value
    return str(value)


def _as_int(value: object, *, context: str) -> int:
    if isinstance(value, int):
        return value
    if isinstance(value, str):
        try:
            return int(value)
        except ValueError as error:
            raise RuntimeError(f"invalid integer for {context}: {value}") from error
    raise RuntimeError(f"invalid integer value for {context}")


def _as_int_default(value: object, *, default: int) -> int:
    if isinstance(value, int):
        return value
    if isinstance(value, str):
        try:
            return int(value)
        except ValueError:
            return default
    return default


def _has_graphql_errors(payload: dict[str, object]) -> bool:
    return len(_as_list(payload.get("errors"))) > 0


def _format_reactions(value: object) -> str | None:
    groups = _as_list(value)
    parts: list[str] = []
    for raw_group in groups:
        group = _as_dict_optional(raw_group)
        if group is None:
            continue
        users_obj = _as_dict_optional(group.get("users"))
        if users_obj is None:
            continue
        count = _as_int_default(users_obj.get("totalCount"), default=0)
        if count <= 0:
            continue
        content = _as_optional_str(group.get("content")) or ""
        emoji = _reaction_emoji(content)
        if emoji:
            parts.append(f"{emoji} x{count}")
    if not parts:
        return None
    return " ".join(parts)


def _reaction_emoji(content: str) -> str:
    mapping = {
        "THUMBS_UP": "👍",
        "THUMBS_DOWN": "👎",
        "LAUGH": "😄",
        "HOORAY": "🎉",
        "CONFUSED": "😕",
        "HEART": "❤️",
        "ROCKET": "🚀",
        "EYES": "👀",
    }
    return mapping.get(content, "")


def _is_retryable_gh_error(stderr: str) -> bool:
    lowered = stderr.lower()
    retryable_patterns = (
        'post "https://api.github.com/graphql": eof',
        "eof",
        "timeout",
        "tls handshake timeout",
        "connection reset",
        "connection refused",
        "temporary failure",
    )
    return any(pattern in lowered for pattern in retryable_patterns)


def _is_check_run_passed(*, status: str, conclusion: str | None) -> bool:
    if status != "COMPLETED":
        return False
    return (conclusion or "").upper() in {"SUCCESS", "NEUTRAL", "SKIPPED"}


def _extract_actions_run_and_job_ids(details_url: str | None) -> tuple[int | None, int | None]:
    if not details_url:
        return None, None
    parsed = urlparse(details_url)
    parts = [segment for segment in parsed.path.split("/") if segment]
    # Expected shape: /<owner>/<repo>/actions/runs/<run_id>/job/<job_id>
    run_id: int | None = None
    job_id: int | None = None
    for idx, part in enumerate(parts):
        if part == "runs" and idx + 1 < len(parts):
            run_id = _parse_positive_int(parts[idx + 1])
        if part == "job" and idx + 1 < len(parts):
            job_id = _parse_positive_int(parts[idx + 1])
    return run_id, job_id


def _parse_positive_int(raw: str) -> int | None:
    try:
        value = int(raw)
    except ValueError:
        return None
    return value if value > 0 else None


def _reference_subject_summary(source: dict[str, object] | None) -> ReferenceSubject | None:
    if source is None:
        return None
    source_type = _as_optional_str(source.get("__typename")) or ""
    source_number = _as_int_default(source.get("number"), default=0)
    if source_number <= 0:
        return None
    repo_obj = _as_dict_optional(source.get("repository"))
    source_repo = _as_optional_str(repo_obj.get("nameWithOwner")) if repo_obj is not None else None
    if not source_repo:
        return None
    title = (_as_optional_str(source.get("title")) or "").strip()
    author = _get_actor_display(source.get("author"))
    detail_parts: list[str] = []
    if title:
        detail_parts.append(f'"{title}"')
    if author != "unknown":
        detail_parts.append(f"by @{author}")
    detail = " ".join(detail_parts).strip()
    return ReferenceSubject(type=source_type, number=source_number, repo=source_repo, detail=detail)
