import json
import logging
import re
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional, Tuple

from ...core.git_utils import (
    GitError,
    git_branch,
    git_is_clean,
    run_git,
)
from ...core.injected_context import wrap_injected_context
from ...core.pr_binding_runtime import (
    binding_summary,
    find_hub_binding_context,
    upsert_pr_binding,
)
from ...core.pr_bindings import PrBinding, PrBindingStore
from ...core.prompts import build_github_issue_to_spec_prompt, build_sync_agent_prompt
from ...core.utils import (
    atomic_write,
    read_json,
    resolve_executable,
    subprocess_env,
)
from .polling import GitHubScmPollingService

logger = logging.getLogger(__name__)


class GitHubError(Exception):
    def __init__(self, message: str, *, status_code: int = 400):
        super().__init__(message)
        self.status_code = status_code


def _looks_like_rate_limit(detail: str) -> bool:
    normalized = (detail or "").strip().lower()
    if not normalized:
        return False
    return "rate limit" in normalized


def _now_ms() -> int:
    return int(time.time() * 1000)


def _json_dumps(obj: object) -> str:
    return json.dumps(obj, indent=2, sort_keys=True) + "\n"


def _run(
    args: list[str],
    *,
    cwd: Path,
    timeout_seconds: int = 30,
    check: bool = True,
    env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess[str]:
    try:
        proc = subprocess.run(
            args,
            cwd=str(cwd),
            text=True,
            capture_output=True,
            timeout=timeout_seconds,
            env=env or subprocess_env(),
            check=False,
        )
    except FileNotFoundError as exc:
        raise GitHubError(f"Missing binary: {args[0]}", status_code=500) from exc
    except subprocess.TimeoutExpired as exc:
        raise GitHubError(
            f"Command timed out: {' '.join(args)}", status_code=504
        ) from exc

    if check and proc.returncode != 0:
        stderr = (proc.stderr or "").strip()
        stdout = (proc.stdout or "").strip()
        detail = stderr or stdout or f"exit {proc.returncode}"
        status_code = 429 if _looks_like_rate_limit(detail) else 400
        raise GitHubError(
            f"Command failed: {' '.join(args)}: {detail}", status_code=status_code
        )
    return proc


def _tail_lines(text: str, *, max_lines: int = 60, max_chars: int = 6000) -> str:
    raw = (text or "").strip()
    if not raw:
        return ""
    lines = raw.splitlines()
    tail = "\n".join(lines[-max_lines:])
    if len(tail) > max_chars:
        return tail[-max_chars:]
    return tail


def _sanitize_cmd(args: list[str]) -> str:
    # Best-effort sanitization: redact obvious tokens if ever present.
    redacted: list[str] = []
    for a in args:
        if any(
            k in a.lower() for k in ("token", "apikey", "api_key", "password", "secret")
        ):
            redacted.append("<redacted>")
        else:
            redacted.append(a)
    return " ".join(redacted)


def _git_ref_exists(repo_root: Path, ref: str) -> bool:
    try:
        proc = run_git(["show-ref", "--verify", "--quiet", ref], repo_root, check=False)
    except GitError:
        return False
    return proc.returncode == 0


def _get_nested(d: Any, *keys: str, default: Any = None) -> Any:
    cur: Any = d
    for k in keys:
        if not isinstance(cur, dict):
            return default
        cur = cur.get(k)
    return cur if cur is not None else default


def _body_has_issue_close(body: str, issue_num: Optional[int]) -> bool:
    if not body or not issue_num:
        return False
    pattern = re.compile(
        rf"(?i)\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s+#?{int(issue_num)}\b"
    )
    return bool(pattern.search(body))


def _append_issue_close(body: str, issue_num: Optional[int]) -> str:
    if not issue_num:
        return body or ""
    suffix = f"Closes #{int(issue_num)}"
    if not body:
        return suffix
    trimmed = body.rstrip()
    return f"{trimmed}\n\n{suffix}"


def _run_codex_sync_agent(
    *,
    repo_root: Path,
    raw_config: dict,
    prompt: str,
) -> None:
    codex_cfg = raw_config.get("codex") if isinstance(raw_config, dict) else None
    codex_cfg = codex_cfg if isinstance(codex_cfg, dict) else {}
    binary = str(codex_cfg.get("binary") or "codex")
    base_args_raw = codex_cfg.get("args")
    base_args = base_args_raw if isinstance(base_args_raw, list) else []

    # Strip any existing --model flags from base args to avoid ambiguity; this flow
    # deliberately uses the configured "small" model (or no model when unset).
    cleaned_args: list[str] = []
    skip_next = False
    for a in [str(x) for x in base_args]:
        if skip_next:
            skip_next = False
            continue
        if a == "--model":
            skip_next = True
            continue
        cleaned_args.append(a)

    # Use the "small" model for this use-case when configured; if unset/null, omit --model.
    models = _get_nested(raw_config, "codex", "models", default=None)
    if isinstance(models, dict) and "small" in models:
        model_small = models.get("small")
    else:
        model_small = "gpt-5.1-codex-mini"
    model_flag: list[str] = ["--model", str(model_small)] if model_small else []

    cmd = [binary, *model_flag, *cleaned_args, prompt]

    github_cfg = raw_config.get("github") if isinstance(raw_config, dict) else None
    github_cfg = github_cfg if isinstance(github_cfg, dict) else {}
    timeout_seconds = int(github_cfg.get("sync_agent_timeout_seconds", 1800))

    try:
        proc = subprocess.run(
            cmd,
            cwd=str(repo_root),
            text=True,
            capture_output=True,
            timeout=timeout_seconds,
            env=subprocess_env(),
            check=False,
        )
    except FileNotFoundError as exc:
        raise GitHubError(f"Missing binary: {binary}", status_code=500) from exc
    except subprocess.TimeoutExpired as exc:
        raise GitHubError(
            f"Codex sync agent timed out after {timeout_seconds}s: {_sanitize_cmd(cmd[:-1])}",
            status_code=504,
        ) from exc

    if proc.returncode != 0:
        stdout_tail = _tail_lines(proc.stdout or "")
        stderr_tail = _tail_lines(proc.stderr or "")
        detail = stderr_tail or stdout_tail or f"exit {proc.returncode}"
        raise GitHubError(
            "Codex sync agent failed.\n"
            f"cmd: {_sanitize_cmd(cmd[:-1])}\n"
            f"detail:\n{detail}",
            status_code=400,
        )


@dataclass
class RepoInfo:
    name_with_owner: str
    url: str
    default_branch: Optional[str] = None


def _parse_repo_info(payload: dict) -> RepoInfo:
    name = payload.get("nameWithOwner") or ""
    url = payload.get("url") or ""
    default_ref = payload.get("defaultBranchRef") or {}
    default_branch = default_ref.get("name") if isinstance(default_ref, dict) else None
    if not name or not url:
        raise GitHubError("Unable to determine GitHub repo (missing nameWithOwner/url)")
    return RepoInfo(
        name_with_owner=str(name), url=str(url), default_branch=default_branch
    )


ISSUE_URL_RE = re.compile(
    r"^https?://(?:www\.)?github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/issues/(?P<num>\d+)(?:[/?#?].*)?$"
)
PR_URL_RE = re.compile(
    r"^https?://(?:www\.)?github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+)/pull/(?P<num>\d+)(?:[/?#?].*)?$"
)
GITHUB_LINK_RE = re.compile(
    r"https?://(?:www\.)?github\.com/[^/\s]+/[^/\s]+/(?:issues|pull)/\d+(?:[/?#?][^\s]*)?"
)


def parse_issue_input(issue: str) -> Tuple[Optional[str], int]:
    """
    Returns (repo_slug_or_none, issue_number).
    Accepts:
      - "123"
      - "#123"
      - "https://github.com/org/repo/issues/123"
    """
    raw = (issue or "").strip()
    if raw.startswith("#"):
        raw = raw[1:].strip()
    if not raw:
        raise GitHubError("issue is required", status_code=400)
    if raw.isdigit():
        return None, int(raw)
    m = ISSUE_URL_RE.match(raw)
    if not m:
        raise GitHubError(
            "Invalid issue reference (expected issue number or GitHub issue URL)"
        )
    slug = f"{m.group('owner')}/{m.group('repo')}"
    return slug, int(m.group("num"))


def parse_pr_input(pr: str) -> Tuple[Optional[str], int]:
    """
    Returns (repo_slug_or_none, pr_number).
    Accepts:
      - "123"
      - "#123"
      - "https://github.com/org/repo/pull/123"
    """
    raw = (pr or "").strip()
    if raw.startswith("#"):
        raw = raw[1:].strip()
    if not raw:
        raise GitHubError("pr is required", status_code=400)
    if raw.isdigit():
        return None, int(raw)
    m = PR_URL_RE.match(raw)
    if not m:
        raise GitHubError("Invalid PR reference (expected PR number or GitHub PR URL)")
    slug = f"{m.group('owner')}/{m.group('repo')}"
    return slug, int(m.group("num"))


def parse_github_url(url: str) -> Optional[tuple[str, str, int]]:
    raw = (url or "").strip()
    if not raw:
        return None
    m = ISSUE_URL_RE.match(raw)
    if m:
        slug = f"{m.group('owner')}/{m.group('repo')}"
        return slug, "issue", int(m.group("num"))
    m = PR_URL_RE.match(raw)
    if m:
        slug = f"{m.group('owner')}/{m.group('repo')}"
        return slug, "pr", int(m.group("num"))
    return None


def find_github_links(text: str) -> list[str]:
    raw = text or ""
    return [m.group(0) for m in GITHUB_LINK_RE.finditer(raw)]


def _repo_slug_dirname(slug: str) -> str:
    import hashlib

    normalized = (slug or "").strip().lower()
    safe_base = re.sub(r"[^a-z0-9._-]+", "-", normalized.replace("/", "--")).strip(".-")
    if not safe_base:
        safe_base = "unknown-repo"
    # Preserve readability while making collisions across different slugs
    # practically impossible.
    digest = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:10]
    return f"{safe_base[:80]}-{digest}"


def _normalize_optional_text(value: Any) -> Optional[str]:
    if not isinstance(value, str):
        return None
    text = value.strip()
    return text or None


def _normalize_optional_identifier_text(value: Any) -> Optional[str]:
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return str(value)
    return _normalize_optional_text(value)


def _normalize_positive_int(value: Any) -> Optional[int]:
    if isinstance(value, bool):
        return None
    try:
        normalized = int(value)
    except (TypeError, ValueError):
        return None
    return normalized if normalized > 0 else None


def _normalize_binding_pr_state(state: Any, *, is_draft: Any = False) -> Optional[str]:
    normalized = _normalize_optional_text(state)
    if normalized is None:
        return None
    lowered = normalized.lower()
    if lowered == "open":
        return "draft" if bool(is_draft) else "open"
    if lowered == "closed":
        return "closed"
    if lowered == "merged":
        return "merged"
    return None


class GitHubService:
    def __init__(self, repo_root: Path, raw_config: Optional[dict] = None):
        self.repo_root = repo_root
        self.raw_config = raw_config or {}
        self.github_path = repo_root / ".codex-autorunner" / "github.json"
        self.gh_path, self.gh_override = self._load_gh_path()

    def _binding_context(self) -> tuple[Optional[Path], Optional[str]]:
        return find_hub_binding_context(self.repo_root)

    def _pr_binding_store(self) -> Optional[PrBindingStore]:
        hub_root, _repo_id = self._binding_context()
        if hub_root is None:
            return None
        return PrBindingStore(hub_root)

    def _persist_pr_binding(
        self,
        *,
        repo_slug: str,
        summary: dict[str, Any],
        existing_binding: Optional[PrBinding] = None,
    ) -> Optional[PrBinding]:
        normalized_repo_slug = _normalize_optional_text(repo_slug)
        pr_number = _normalize_positive_int(summary.get("pr_number"))
        pr_state = _normalize_binding_pr_state(summary.get("pr_state"))
        if normalized_repo_slug is None or pr_number is None or pr_state is None:
            return None

        hub_root, repo_id = self._binding_context()
        if hub_root is None:
            return None
        return upsert_pr_binding(
            hub_root,
            provider="github",
            repo_slug=normalized_repo_slug,
            repo_id=repo_id,
            pr_number=pr_number,
            pr_state=pr_state,
            head_branch=_normalize_optional_text(summary.get("head_branch")),
            base_branch=_normalize_optional_text(summary.get("base_branch")),
            existing_binding=existing_binding,
        )

    def _load_gh_path(self) -> tuple[str, bool]:
        cfg = self.raw_config if isinstance(self.raw_config, dict) else {}
        github_cfg_raw = cfg.get("github")
        github_cfg: dict[str, Any] = (
            github_cfg_raw if isinstance(github_cfg_raw, dict) else {}
        )
        gh_path = github_cfg.get("gh_path")
        override = str(gh_path).strip() if isinstance(gh_path, str) and gh_path else ""
        return override or "gh", bool(override)

    def _gh(
        self,
        args: list[str],
        *,
        cwd: Optional[Path] = None,
        timeout_seconds: int = 30,
        check: bool = True,
    ) -> subprocess.CompletedProcess[str]:
        try:
            return _run(
                [self.gh_path] + args,
                cwd=cwd or self.repo_root,
                timeout_seconds=timeout_seconds,
                check=check,
            )
        except GitHubError as exc:
            if "Missing binary:" in str(exc):
                raise GitHubError(
                    "GitHub CLI (gh) not available", status_code=500
                ) from exc
            raise

    # ── persistence ────────────────────────────────────────────────────────────
    def read_link_state(self) -> dict:
        return read_json(self.github_path) or {}

    def write_link_state(self, data: dict) -> dict:
        payload = dict(data)
        payload.setdefault("updatedAtMs", _now_ms())
        atomic_write(self.github_path, _json_dumps(payload))
        return payload

    # ── capability/status ──────────────────────────────────────────────────────
    def gh_available(self) -> bool:
        return resolve_executable(self.gh_path) is not None

    def gh_authenticated(self) -> bool:
        if not self.gh_available():
            return False
        proc = self._gh(["auth", "status"], check=False, timeout_seconds=10)
        return proc.returncode == 0

    def rate_limit_status(self, *, cwd: Optional[Path] = None) -> dict[str, Any]:
        proc = self._gh(
            ["api", "rate_limit"],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=15,
        )
        if proc.returncode != 0:
            return {}
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError:
            return {}
        return payload if isinstance(payload, dict) else {}

    def repo_info(self) -> RepoInfo:
        proc = self._gh(
            ["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"],
            timeout_seconds=15,
            check=True,
        )
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError as exc:
            raise GitHubError(
                "Unable to parse gh repo view output", status_code=500
            ) from exc
        return _parse_repo_info(payload)

    def current_branch(self, *, cwd: Optional[Path] = None) -> str:
        branch = git_branch(cwd or self.repo_root)
        return branch or "HEAD"

    def is_clean(self, *, cwd: Optional[Path] = None) -> bool:
        return git_is_clean(cwd or self.repo_root)

    def pr_for_branch(
        self, *, branch: str, cwd: Optional[Path] = None
    ) -> Optional[dict]:
        cwd = cwd or self.repo_root
        proc = self._gh(
            [
                "pr",
                "view",
                "--json",
                "number,url,state,isDraft,title,headRefName,baseRefName",
            ],
            cwd=cwd,
            check=False,
            timeout_seconds=15,
        )
        if proc.returncode == 0:
            try:
                return json.loads(proc.stdout or "{}") or None
            except json.JSONDecodeError:
                return None
        proc2 = self._gh(
            [
                "pr",
                "list",
                "--head",
                branch,
                "--limit",
                "1",
                "--json",
                "number,url,state,isDraft,title,headRefName,baseRefName",
            ],
            cwd=cwd,
            check=False,
            timeout_seconds=15,
        )
        if proc2.returncode != 0:
            return None
        try:
            arr = json.loads(proc2.stdout or "[]") or []
        except json.JSONDecodeError:
            return None
        return arr[0] if arr else None

    def normalize_pr_binding_summary(
        self, *, pr: dict[str, Any], repo_slug: str
    ) -> Optional[dict[str, Any]]:
        normalized_repo_slug = _normalize_optional_text(repo_slug)
        if normalized_repo_slug is None:
            return None

        pr_number = _normalize_positive_int(pr.get("number"))
        pr_state = _normalize_binding_pr_state(
            pr.get("state"), is_draft=pr.get("isDraft")
        )
        if pr_number is None or pr_state is None:
            return None

        summary: dict[str, Any] = {
            "repo_slug": normalized_repo_slug,
            "pr_number": pr_number,
            "pr_state": pr_state,
        }
        head_branch = _normalize_optional_text(pr.get("headRefName"))
        if head_branch is not None:
            summary["head_branch"] = head_branch
        base_branch = _normalize_optional_text(pr.get("baseRefName"))
        if base_branch is not None:
            summary["base_branch"] = base_branch
        return summary

    def discover_pr_binding_summary(
        self, *, branch: Optional[str] = None, cwd: Optional[Path] = None
    ) -> Optional[dict[str, Any]]:
        resolved_branch = _normalize_optional_text(branch) or self.current_branch(
            cwd=cwd
        )
        if not resolved_branch or resolved_branch == "HEAD":
            return None

        hub_root, repo_id = self._binding_context()
        store = PrBindingStore(hub_root) if hub_root is not None else None
        if store is not None and repo_id is not None:
            canonical_bindings: list[PrBinding] = []
            for pr_state in ("open", "draft"):
                canonical_bindings.extend(
                    store.list_bindings(
                        provider="github",
                        repo_id=repo_id,
                        pr_state=pr_state,
                        head_branch=resolved_branch,
                        limit=1,
                    )
                )
            if canonical_bindings:
                canonical_bindings.sort(
                    key=lambda binding: (binding.updated_at, binding.pr_number),
                    reverse=True,
                )
                return binding_summary(canonical_bindings[0])

        pr = self.pr_for_branch(branch=resolved_branch, cwd=cwd)
        if not isinstance(pr, dict):
            return None

        try:
            repo_slug = self.repo_info().name_with_owner
        except Exception:
            return None

        fallback_binding: Optional[PrBinding] = None
        if store is not None:
            fallback_binding = store.find_active_binding_for_branch(
                provider="github",
                repo_slug=repo_slug,
                branch_name=resolved_branch,
            )

        summary = self.normalize_pr_binding_summary(pr=pr, repo_slug=repo_slug)
        if summary is None:
            return (
                binding_summary(fallback_binding)
                if fallback_binding is not None
                else None
            )
        return summary

    def discover_pr_binding(
        self, *, branch: Optional[str] = None, cwd: Optional[Path] = None
    ) -> Optional[PrBinding]:
        summary = self.discover_pr_binding_summary(branch=branch, cwd=cwd)
        if summary is None:
            return None

        binding_store = self._pr_binding_store()
        existing_binding: Optional[PrBinding] = None
        repo_slug = _normalize_optional_text(summary.get("repo_slug"))
        head_branch = _normalize_optional_text(summary.get("head_branch"))
        if (
            binding_store is not None
            and repo_slug is not None
            and head_branch is not None
        ):
            existing_binding = binding_store.find_active_binding_for_branch(
                provider="github",
                repo_slug=repo_slug,
                branch_name=head_branch,
            )

        return (
            self._persist_pr_binding(
                repo_slug=str(summary["repo_slug"]),
                summary=summary,
                existing_binding=existing_binding,
            )
            if repo_slug is not None
            else None
        )

    def list_open_issues(
        self, *, limit: int = 10, cwd: Optional[Path] = None
    ) -> list[dict[str, Any]]:
        proc = self._gh(
            [
                "issue",
                "list",
                "--state",
                "open",
                "--limit",
                str(int(limit)),
                "--json",
                "number,title,url",
            ],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=20,
        )
        if proc.returncode != 0:
            return []
        try:
            payload = json.loads(proc.stdout or "[]")
        except json.JSONDecodeError:
            return []
        if not isinstance(payload, list):
            return []
        return [item for item in payload if isinstance(item, dict)]

    def list_open_prs(
        self, *, limit: int = 10, cwd: Optional[Path] = None
    ) -> list[dict[str, Any]]:
        proc = self._gh(
            [
                "pr",
                "list",
                "--state",
                "open",
                "--limit",
                str(int(limit)),
                "--json",
                "number,title,url,headRefName,baseRefName",
            ],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=20,
        )
        if proc.returncode != 0:
            return []
        try:
            payload = json.loads(proc.stdout or "[]")
        except json.JSONDecodeError:
            return []
        if not isinstance(payload, list):
            return []
        return [item for item in payload if isinstance(item, dict)]

    def issue_view(
        self,
        *,
        number: int,
        cwd: Optional[Path] = None,
        repo_slug: Optional[str] = None,
    ) -> dict:
        args = [
            "issue",
            "view",
            str(number),
            "--json",
            "number,url,title,body,state,author,labels,comments",
        ]
        if repo_slug:
            args += ["-R", repo_slug]
        proc = self._gh(
            args,
            cwd=cwd or self.repo_root,
            check=True,
            timeout_seconds=20,
        )
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError as exc:
            raise GitHubError(
                "Unable to parse gh issue view output", status_code=500
            ) from exc
        return payload if isinstance(payload, dict) else {}

    def validate_issue_same_repo(self, issue_ref: str) -> int:
        repo = self.repo_info()
        slug_from_input, num = parse_issue_input(issue_ref)
        if slug_from_input and slug_from_input.lower() != repo.name_with_owner.lower():
            raise GitHubError(
                f"Issue must be in this repo ({repo.name_with_owner}); got {slug_from_input}",
                status_code=400,
            )
        return num

    def pr_view(
        self,
        *,
        number: int,
        cwd: Optional[Path] = None,
        repo_slug: Optional[str] = None,
    ) -> dict:
        args = [
            "pr",
            "view",
            str(number),
            "--json",
            "number,url,title,body,state,author,labels,files,additions,deletions,changedFiles,headRefName,baseRefName,headRefOid,isDraft",
        ]
        if repo_slug:
            args += ["-R", repo_slug]
        proc = self._gh(
            args,
            cwd=cwd or self.repo_root,
            check=True,
            timeout_seconds=30,
        )
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError as exc:
            raise GitHubError(
                "Unable to parse gh pr view output", status_code=500
            ) from exc
        return payload if isinstance(payload, dict) else {}

    def pr_reviews(
        self,
        *,
        owner: str,
        repo: str,
        number: int,
        cwd: Optional[Path] = None,
    ) -> list[dict[str, Any]]:
        proc = self._gh(
            [
                "api",
                f"repos/{owner}/{repo}/pulls/{int(number)}/reviews",
                "-F",
                "per_page=100",
            ],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=30,
        )
        if proc.returncode != 0:
            return []
        try:
            payload = json.loads(proc.stdout or "[]")
        except json.JSONDecodeError:
            return []
        if not isinstance(payload, list):
            return []
        reviews: list[dict[str, Any]] = []
        for item in payload:
            if not isinstance(item, dict):
                continue
            user = item.get("user")
            author_login = (
                _normalize_optional_text(user.get("login"))
                if isinstance(user, dict)
                else None
            )
            review = {
                "review_id": _normalize_optional_identifier_text(item.get("id")),
                "review_state": _normalize_optional_text(item.get("state")),
                "body": _normalize_optional_text(item.get("body")),
                "html_url": _normalize_optional_text(item.get("html_url")),
                "author_login": author_login,
                "commit_id": _normalize_optional_text(item.get("commit_id")),
                "submitted_at": _normalize_optional_text(item.get("submitted_at")),
            }
            reviews.append(
                {key: value for key, value in review.items() if value is not None}
            )
        return reviews

    def ensure_pr_head(
        self,
        *,
        number: int,
        branch: Optional[str] = None,
        cwd: Optional[Path] = None,
    ) -> None:
        repo_root = cwd or self.repo_root
        if branch:
            if _git_ref_exists(repo_root, f"refs/heads/{branch}"):
                return
            if _git_ref_exists(repo_root, f"refs/remotes/origin/{branch}"):
                return
        current_branch = git_branch(repo_root) or "HEAD"
        args = ["pr", "checkout", str(int(number)), "--force"]
        if branch:
            args += ["--branch", branch]
        else:
            args.append("--detach")
        self._gh(args, cwd=repo_root, check=True, timeout_seconds=60)
        if current_branch and current_branch != "HEAD":
            try:
                run_git(["checkout", current_branch], repo_root, check=False)
            except GitError:
                pass

    def pr_review_threads(
        self,
        *,
        owner: str,
        repo: str,
        number: int,
        cwd: Optional[Path] = None,
    ) -> list[dict[str, Any]]:
        query = (
            "query($owner:String!,$repo:String!,$number:Int!){"
            "repository(owner:$owner,name:$repo){"
            "pullRequest(number:$number){"
            "reviewThreads(first:50){"
            "nodes{id isResolved comments(first:20){nodes{id url authorAssociation "
            "body path line createdAt updatedAt author{__typename login}}}}"
            "}"
            "}"
            "}"
            "}"
        )
        proc = self._gh(
            [
                "api",
                "graphql",
                "-f",
                f"query={query}",
                "-F",
                f"owner={owner}",
                "-F",
                f"repo={repo}",
                "-F",
                f"number={int(number)}",
            ],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=30,
        )
        if proc.returncode != 0:
            return []
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError:
            return []
        nodes = _get_nested(
            payload, "data", "repository", "pullRequest", "reviewThreads", "nodes"
        )
        if not isinstance(nodes, list):
            return []
        threads: list[dict[str, Any]] = []
        for node in nodes:
            if not isinstance(node, dict):
                continue
            comments_nodes = _get_nested(node, "comments", "nodes")
            comments: list[dict[str, Any]] = []
            if isinstance(comments_nodes, list):
                for comment in comments_nodes:
                    if not isinstance(comment, dict):
                        continue
                    author = comment.get("author")
                    author_login = (
                        _normalize_optional_text(author.get("login"))
                        if isinstance(author, dict)
                        else None
                    )
                    author_type = (
                        _normalize_optional_text(author.get("__typename"))
                        if isinstance(author, dict)
                        else None
                    )
                    comments.append(
                        {
                            key: value
                            for key, value in {
                                "comment_id": _normalize_optional_identifier_text(
                                    comment.get("id")
                                ),
                                "html_url": _normalize_optional_text(
                                    comment.get("url")
                                ),
                                "author": (
                                    {"login": author_login}
                                    if author_login is not None
                                    else None
                                ),
                                "author_login": author_login,
                                "author_type": author_type,
                                "author_association": _normalize_optional_text(
                                    comment.get("authorAssociation")
                                ),
                                "body": _normalize_optional_text(comment.get("body")),
                                "path": _normalize_optional_text(comment.get("path")),
                                "line": _normalize_positive_int(comment.get("line")),
                                "createdAt": _normalize_optional_text(
                                    comment.get("createdAt")
                                ),
                                "updated_at": _normalize_optional_text(
                                    comment.get("updatedAt")
                                ),
                            }.items()
                            if value is not None
                        }
                    )
            threads.append(
                {
                    key: value
                    for key, value in {
                        "thread_id": _normalize_optional_text(node.get("id")),
                        "isResolved": bool(node.get("isResolved")),
                        "comments": comments,
                    }.items()
                    if value is not None
                }
            )
        return threads

    def pr_checks(
        self, *, number: int, cwd: Optional[Path] = None
    ) -> list[dict[str, Any]]:
        proc = self._gh(
            ["pr", "view", str(number), "--json", "statusCheckRollup"],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=30,
        )
        if proc.returncode != 0:
            return []
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError:
            return []
        rollup = payload.get("statusCheckRollup")
        entries: list[dict[str, Any]] = []
        if isinstance(rollup, list):
            entries = [item for item in rollup if isinstance(item, dict)]
        elif isinstance(rollup, dict):
            contexts = rollup.get("contexts") or rollup.get("nodes")
            if isinstance(contexts, list):
                entries = [item for item in contexts if isinstance(item, dict)]
        checks: list[dict[str, Any]] = []
        for entry in entries:
            name = entry.get("name") or entry.get("context") or entry.get("title")
            status = entry.get("status") or entry.get("state")
            conclusion = entry.get("conclusion") or entry.get("result")
            details_url = entry.get("detailsUrl") or entry.get("targetUrl")
            if name or status or conclusion:
                checks.append(
                    {
                        "name": name,
                        "status": status,
                        "conclusion": conclusion,
                        "details_url": details_url,
                    }
                )
        return checks

    def issue_meta(
        self, *, owner: str, repo: str, number: int, cwd: Optional[Path] = None
    ) -> dict[str, Any]:
        proc = self._gh(
            ["api", f"repos/{owner}/{repo}/issues/{int(number)}"],
            cwd=cwd or self.repo_root,
            check=False,
            timeout_seconds=20,
        )
        if proc.returncode != 0:
            return {}
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError:
            return {}
        return payload if isinstance(payload, dict) else {}

    def issue_comments(
        self,
        *,
        owner: str,
        repo: str,
        number: Optional[int] = None,
        since: Optional[str] = None,
        limit: Optional[int] = None,
        cwd: Optional[Path] = None,
    ) -> list[dict[str, Any]]:
        endpoint = (
            f"repos/{owner}/{repo}/issues/{int(number)}/comments"
            if number is not None
            else f"repos/{owner}/{repo}/issues/comments"
        )
        issue_number = int(number) if number is not None else None
        remaining = None if limit is None else max(int(limit), 0)
        if remaining == 0:
            return []

        comments: list[dict[str, Any]] = []
        page = 1
        while True:
            page_size = 100 if remaining is None else min(100, remaining)
            args = [
                "api",
                endpoint,
                "-F",
                f"per_page={page_size}",
                "-F",
                f"page={page}",
            ]
            if since:
                args += ["-F", f"since={since}"]
            proc = self._gh(
                args, cwd=cwd or self.repo_root, check=False, timeout_seconds=30
            )
            if proc.returncode != 0:
                return []
            try:
                payload = json.loads(proc.stdout or "[]")
            except json.JSONDecodeError:
                return []
            if not isinstance(payload, list):
                return []
            if not payload:
                break

            for item in payload:
                if not isinstance(item, dict):
                    continue
                user = item.get("user")
                author_login = (
                    _normalize_optional_text(user.get("login"))
                    if isinstance(user, dict)
                    else None
                )
                author_type = (
                    _normalize_optional_text(user.get("type"))
                    if isinstance(user, dict)
                    else None
                )
                comments.append(
                    {
                        key: value
                        for key, value in {
                            "comment_id": _normalize_optional_identifier_text(
                                item.get("id")
                            ),
                            "body": _normalize_optional_text(item.get("body")),
                            "html_url": _normalize_optional_text(item.get("html_url")),
                            "author_login": author_login,
                            "author_type": author_type,
                            "author_association": _normalize_optional_text(
                                item.get("author_association")
                            ),
                            "issue_number": issue_number,
                            "path": _normalize_optional_text(item.get("path")),
                            "line": _normalize_positive_int(item.get("line")),
                            "pull_request_review_id": (
                                _normalize_optional_identifier_text(
                                    item.get("pull_request_review_id")
                                )
                            ),
                            "commit_id": _normalize_optional_text(
                                item.get("commit_id")
                            ),
                            "created_at": _normalize_optional_text(
                                item.get("created_at")
                            ),
                            "updated_at": _normalize_optional_text(
                                item.get("updated_at")
                            ),
                        }.items()
                        if value is not None
                    }
                )

            if remaining is not None:
                remaining = max(0, remaining - page_size)
                if remaining == 0:
                    break
            if len(payload) < page_size:
                break
            page += 1

        return comments

    def create_issue_comment(
        self,
        *,
        owner: str,
        repo: str,
        number: int,
        body: str,
        cwd: Optional[Path] = None,
    ) -> dict[str, Any]:
        args = [
            "api",
            "-X",
            "POST",
            f"repos/{owner}/{repo}/issues/{int(number)}/comments",
            "-f",
            f"body={body}",
        ]
        proc = self._gh(args, cwd=cwd or self.repo_root, check=True, timeout_seconds=20)
        try:
            payload = json.loads(proc.stdout or "{}")
        except json.JSONDecodeError as exc:
            raise GitHubError(
                "Unable to parse gh comment creation output", status_code=500
            ) from exc
        return payload if isinstance(payload, dict) else {}

    def build_context_file_from_url(
        self, url: str, *, allow_cross_repo: bool = False
    ) -> Optional[dict]:
        parsed = parse_github_url(url)
        if not parsed:
            return None
        if not self.gh_available():
            return None
        if not self.gh_authenticated():
            return None
        slug, kind, number = parsed
        repo_slug = slug
        if not allow_cross_repo:
            repo = self.repo_info()
            if slug.lower() != repo.name_with_owner.lower():
                return None
            repo_slug = repo.name_with_owner

        if kind == "issue":
            issue_obj = self.issue_view(
                number=number,
                repo_slug=repo_slug if allow_cross_repo else None,
            )
            lines = _format_issue_context(issue_obj, repo=repo_slug)
        else:
            pr_obj = self.pr_view(
                number=number,
                repo_slug=repo_slug if allow_cross_repo else None,
            )
            owner, repo_name = repo_slug.split("/", 1)
            review_threads = self.pr_review_threads(
                owner=owner, repo=repo_name, number=number
            )
            lines = _format_pr_context(
                pr_obj, repo=repo_slug, review_threads=review_threads
            )

        rel_dir = Path(".codex-autorunner") / "github_context"
        if allow_cross_repo:
            rel_dir = rel_dir / _repo_slug_dirname(repo_slug)
        abs_dir = self.repo_root / rel_dir
        abs_dir.mkdir(parents=True, exist_ok=True)
        filename = f"{kind}-{int(number)}.md"
        rel_path = rel_dir / filename
        abs_path = self.repo_root / rel_path
        atomic_write(abs_path, "\n".join(lines).rstrip() + "\n")

        hint = wrap_injected_context(
            "Context: see "
            f"{rel_path.as_posix()} "
            "(gh available: true; use gh CLI for updates if asked)."
        )
        return {"path": rel_path.as_posix(), "hint": hint, "kind": kind}

    # ── high-level operations ──────────────────────────────────────────────
    def status_payload(self) -> dict:
        link = self.read_link_state()
        gh_ok = self.gh_available()
        authed = self.gh_authenticated() if gh_ok else False
        repo: Optional[RepoInfo] = None
        if authed:
            try:
                repo = self.repo_info()
            except Exception:
                repo = None
        branch = self.current_branch()
        clean = self.is_clean()
        is_worktree = (self.repo_root / ".git").is_file()
        pr = None
        if authed and branch != "HEAD":
            pr = self.pr_for_branch(branch=branch) or None
        payload = {
            "gh": {"available": gh_ok, "authenticated": authed},
            "repo": (
                {
                    "nameWithOwner": repo.name_with_owner,
                    "url": repo.url,
                    "defaultBranch": repo.default_branch,
                }
                if repo
                else None
            ),
            "git": {"branch": branch, "clean": clean, "is_worktree": is_worktree},
            "link": link or {},
            "pr": pr,
        }
        if pr and pr.get("url"):
            url = pr["url"]
            payload["pr_links"] = {
                "url": url,
                "files": f"{url}/files",
                "checks": f"{url}/checks",
            }
        return payload

    def link_issue(self, issue_ref: str) -> dict:
        state, _issue_obj = self._fetch_and_link_issue(issue_ref)
        return state

    def _fetch_and_link_issue(self, issue_ref: str) -> tuple[dict, dict]:
        number = self.validate_issue_same_repo(issue_ref)
        issue_obj = self.issue_view(number=number)
        repo = self.repo_info()
        state = self.read_link_state()
        state["repo"] = {"nameWithOwner": repo.name_with_owner, "url": repo.url}
        state["issue"] = {
            "number": issue_obj.get("number"),
            "url": issue_obj.get("url"),
            "title": issue_obj.get("title"),
            "state": issue_obj.get("state"),
        }
        state["updatedAtMs"] = _now_ms()
        return self.write_link_state(state), issue_obj

    def build_spec_prompt_from_issue(self, issue_ref: str) -> tuple[str, dict]:
        """
        Fetch issue details, persist link state, and build the prompt used to
        create/update SPEC based on the issue.

        Returns (prompt, link_state).
        """
        link_state, issue_obj = self._fetch_and_link_issue(issue_ref)
        issue_num = ((link_state.get("issue") or {}) or {}).get("number")
        issue_title = ((link_state.get("issue") or {}) or {}).get("title") or ""
        body = (issue_obj.get("body") or "").strip()
        prompt = build_github_issue_to_spec_prompt(
            issue_num=int(issue_num or issue_obj.get("number") or 0),
            issue_title=str(issue_title or ""),
            issue_url=str(issue_obj.get("url") or ""),
            issue_body=str(body or ""),
        )
        return prompt, link_state

    def sync_pr(
        self,
        *,
        draft: Optional[bool] = None,
        title: Optional[str] = None,
        body: Optional[str] = None,
    ) -> dict:
        if not self.gh_authenticated():
            raise GitHubError(
                "GitHub CLI not authenticated (run `gh auth login`)", status_code=401
            )

        repo = self.repo_info()
        base = repo.default_branch or "main"
        binding_store = self._pr_binding_store()
        state = self.read_link_state() or {}
        issue_num = ((state.get("issue") or {}) or {}).get("number")
        head_branch = self.current_branch()
        if head_branch == "HEAD":
            raise GitHubError(
                "Unable to determine current git branch (repo may have no commits). Create an initial commit and try again.",
                status_code=409,
            )
        cwd = self.repo_root
        meta = {"mode": "current"}
        # Decide commit behavior
        github_cfg = (
            (self.raw_config.get("github") or {})
            if isinstance(self.raw_config, dict)
            else {}
        )
        resolved_draft = (
            draft
            if draft is not None
            else bool(github_cfg.get("pr_draft_default", False))
        )
        commit_mode = str(github_cfg.get("sync_commit_mode", "auto")).lower()
        if commit_mode not in ("none", "auto", "always"):
            commit_mode = "auto"

        dirty = not self.is_clean(cwd=cwd)
        if commit_mode in ("always", "auto") and dirty:
            # Commit/push is handled by the sync agent below.
            pass
        if commit_mode == "none" and dirty:
            raise GitHubError(
                "Uncommitted changes present; commit them before syncing PR.",
                status_code=409,
            )

        # Agentic sync (format/lint/test, commit if needed, push; resolve rebase conflicts if any)
        prompt = build_sync_agent_prompt(
            repo_root=str(self.repo_root), branch=head_branch, issue_num=issue_num
        )
        _run_codex_sync_agent(
            repo_root=self.repo_root, raw_config=self.raw_config, prompt=prompt
        )

        # Find/create PR
        binding_hint = (
            binding_store.find_active_binding_for_branch(
                provider="github",
                repo_slug=repo.name_with_owner,
                branch_name=head_branch,
            )
            if binding_store is not None
            else None
        )
        pr = self.pr_for_branch(branch=head_branch, cwd=cwd)
        if not pr:
            args = ["pr", "create", "--base", base]
            if resolved_draft:
                args.append("--draft")
            if title:
                args += ["--title", title]
            if body:
                if issue_num and not _body_has_issue_close(body, issue_num):
                    body = _append_issue_close(body, issue_num)
                args += ["--body", body]
            else:
                args.append("--fill")
            proc = self._gh(args, cwd=cwd, check=True, timeout_seconds=60)
            # gh pr create returns URL on stdout typically
            url = (
                (proc.stdout or "").strip().splitlines()[-1].strip()
                if proc.stdout
                else ""
            )
            pr = {
                "url": url,
                "state": "OPEN",
                "isDraft": bool(resolved_draft),
                "headRefName": head_branch,
                "baseRefName": base,
            }
        pr_url = pr.get("url") if isinstance(pr, dict) else None

        if issue_num and pr_url:
            try:
                body_proc = self._gh(
                    ["pr", "view", pr_url, "--json", "body"],
                    cwd=cwd,
                    check=True,
                    timeout_seconds=30,
                )
                payload = json.loads(body_proc.stdout or "{}")
                body_text = payload.get("body") if isinstance(payload, dict) else ""
            except Exception:
                body_text = ""
            if body_text and not _body_has_issue_close(body_text, issue_num):
                updated = _append_issue_close(body_text, issue_num)
                try:
                    self._gh(
                        ["pr", "edit", pr_url, "--body", updated],
                        cwd=cwd,
                        check=True,
                        timeout_seconds=30,
                    )
                except Exception:
                    pass
            pr = self.pr_for_branch(branch=head_branch, cwd=cwd) or pr

        binding_summary = None
        if isinstance(pr, dict):
            binding_summary = self.normalize_pr_binding_summary(
                pr=pr, repo_slug=repo.name_with_owner
            )
        if binding_summary is not None:
            persisted_binding = self._persist_pr_binding(
                repo_slug=repo.name_with_owner,
                summary=binding_summary,
                existing_binding=binding_hint,
            )
            hub_root, _repo_id = self._binding_context()
            if persisted_binding is not None and hub_root is not None:
                try:
                    GitHubScmPollingService(
                        hub_root,
                        raw_config=(
                            self.raw_config
                            if isinstance(self.raw_config, dict)
                            else None
                        ),
                    ).arm_watch(
                        binding=persisted_binding,
                        workspace_root=self.repo_root,
                        reaction_config=self.raw_config,
                    )
                except Exception:
                    logger.warning(
                        "Failed arming SCM polling watch for %s#%s",
                        persisted_binding.repo_slug,
                        persisted_binding.pr_number,
                        exc_info=True,
                    )

        state["repo"] = {"nameWithOwner": repo.name_with_owner, "url": repo.url}
        if pr_url:
            state["pr"] = {
                "number": pr.get("number"),
                "url": pr_url,
                "title": pr.get("title"),
            }
        state["updatedAtMs"] = _now_ms()
        self.write_link_state(state)

        out = {
            "status": "ok",
            "repo": repo.name_with_owner,
            "mode": "current",
            "meta": meta,
            "pr": pr,
        }
        if pr_url:
            out["links"] = {
                "url": pr_url,
                "files": f"{pr_url}/files",
                "checks": f"{pr_url}/checks",
            }
        return out


def _safe_text(value: Any, *, max_chars: int = 8000) -> str:
    text = str(value or "").strip()
    if len(text) <= max_chars:
        return text
    return text[: max_chars - 3] + "..."


def _format_labels(labels: Any) -> str:
    if not isinstance(labels, list):
        return "none"
    names = []
    for label in labels:
        if isinstance(label, dict):
            name = label.get("name")
        else:
            name = label
        if name:
            names.append(str(name))
    return ", ".join(names) if names else "none"


def _format_author(author: Any) -> str:
    if isinstance(author, dict):
        return str(author.get("login") or author.get("name") or "unknown")
    return str(author or "unknown")


def _format_issue_context(issue: dict, *, repo: str) -> list[str]:
    number = issue.get("number") or ""
    title = issue.get("title") or ""
    url = issue.get("url") or ""
    state = issue.get("state") or ""
    body = _safe_text(issue.get("body") or "")
    labels = _format_labels(issue.get("labels"))
    author = _format_author(issue.get("author"))
    comments = issue.get("comments")
    comment_count = 0
    if isinstance(comments, dict):
        total = comments.get("totalCount")
        if isinstance(total, int):
            comment_count = total
        else:
            nodes = comments.get("nodes")
            edges = comments.get("edges")
            if isinstance(nodes, list):
                comment_count = len(nodes)
            elif isinstance(edges, list):
                comment_count = len(edges)
    elif isinstance(comments, list):
        comment_count = len(comments)

    lines = [
        "# GitHub Issue Context",
        f"Repo: {repo}",
        f"Issue: #{number} {title}".strip(),
        f"URL: {url}",
        f"State: {state}",
        f"Author: {author}",
        f"Labels: {labels}",
        f"Comments: {comment_count}",
        "",
        "Body:",
        body or "(no body)",
    ]
    return lines


def _format_review_location(path: Any, line: Any) -> str:
    path_val = str(path).strip() if path else ""
    if path_val and isinstance(line, int):
        return f"{path_val}:{line}"
    if path_val:
        return path_val
    if isinstance(line, int):
        return f"(unknown file):{line}"
    return "(unknown file)"


def _format_review_threads(review_threads: list[dict[str, Any]]) -> list[str]:
    lines: list[str] = []
    thread_index = 0
    for thread in review_threads:
        if not isinstance(thread, dict):
            continue
        comments = thread.get("comments")
        if not isinstance(comments, list) or not comments:
            continue
        thread_index += 1
        status = "resolved" if thread.get("isResolved") else "unresolved"
        lines.append(f"- Thread {thread_index} ({status})")
        for comment in comments:
            if not isinstance(comment, dict):
                continue
            author = _format_author(comment.get("author"))
            created_at = comment.get("createdAt") or ""
            location = _format_review_location(comment.get("path"), comment.get("line"))
            header = f"  - {location} {author}".strip()
            if created_at:
                header = f"{header} ({created_at})"
            lines.append(header)
            body = _safe_text(comment.get("body") or "")
            if not body:
                lines.append("    (no body)")
            else:
                for line in body.splitlines():
                    lines.append(f"    {line}")
    return lines


def _format_pr_context(
    pr: dict, *, repo: str, review_threads: Optional[list[dict[str, Any]]] = None
) -> list[str]:
    number = pr.get("number") or ""
    title = pr.get("title") or ""
    url = pr.get("url") or ""
    state = pr.get("state") or ""
    body = _safe_text(pr.get("body") or "")
    labels = _format_labels(pr.get("labels"))
    author = _format_author(pr.get("author"))
    additions = pr.get("additions") or 0
    deletions = pr.get("deletions") or 0
    changed_files = pr.get("changedFiles") or 0
    files_raw = pr.get("files")
    files = (
        [entry for entry in files_raw if isinstance(entry, dict)]
        if isinstance(files_raw, list)
        else []
    )
    file_lines = []
    for entry in files[:200]:
        if not isinstance(entry, dict):
            continue
        path = entry.get("path") or entry.get("name") or ""
        if not path:
            continue
        add = entry.get("additions")
        dele = entry.get("deletions")
        if isinstance(add, int) and isinstance(dele, int):
            file_lines.append(f"- {path} (+{add}/-{dele})")
        else:
            file_lines.append(f"- {path}")
    if len(files) > 200:
        file_lines.append(f"... ({len(files) - 200} more)")

    lines = [
        "# GitHub PR Context",
        f"Repo: {repo}",
        f"PR: #{number} {title}".strip(),
        f"URL: {url}",
        f"State: {state}",
        f"Author: {author}",
        f"Labels: {labels}",
        f"Stats: +{additions} -{deletions}; changed files: {changed_files}",
        "",
        "Body:",
        body or "(no body)",
        "",
        "Files:",
    ]
    lines.extend(file_lines or ["(no files)"])
    review_lines = (
        _format_review_threads(review_threads)
        if isinstance(review_threads, list)
        else []
    )
    if review_lines:
        lines.extend(["", "Review Threads:"])
        lines.extend(review_lines)
    return lines
