"""Anthropic credential sources, OAuth flows, and token resolution.

``resolve_anthropic_token()`` order: ``ANTHROPIC_TOKEN`` / ``CLAUDE_CODE_OAUTH_TOKEN``,
``ANTHROPIC_API_KEY``, Hermes-owned OAuth grants in the ``auth.json`` credential
pool, then ``~/.claude/.credentials.json`` / macOS Keychain as a borrowed fallback.
``~/.hermes/.anthropic_oauth.json`` (Hermes PKCE) and
the Claude Code file are *singletons*: ``credential_pool._seed_from_singletons()``
re-reads them on every ``load_pool()``, so a failed write here is a failed refresh
(``CredentialPersistError``), not a cache miss.
"""

import base64
import contextlib
import functools
import hashlib
import json
import logging
import os
import platform
import re
import secrets
import subprocess
import threading
import time
from collections import OrderedDict
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlparse

from hermes_constants import get_hermes_home
from utils import atomic_json_write
from agent.secret_scope import get_secret as _get_secret

logger = logging.getLogger(__name__)

_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
# platform.claude.com is the live token host; console.anthropic.com 404s but is kept as a fallback.
_OAUTH_TOKEN_URLS = [
    "https://platform.claude.com/v1/oauth/token", "https://console.anthropic.com/v1/oauth/token"
]
# Anthropic 429s token-endpoint requests whose UA starts with ``claude-code/`` (or Mozilla); the real CLI uses
# bare axios there. Inference (build_anthropic_kwargs) still needs claude-code/.
_OAUTH_TOKEN_USER_AGENT = "axios/1.7.9"
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
# Claude Code's macOS Keychain entry (generic password). Hermes reads it
# (_read_claude_code_credentials_from_keychain) and, since #98334, mirrors the
# refresh write into it so the two stores stop diverging on a single-use rotation.
_CLAUDE_CODE_KEYCHAIN_SERVICE = "Claude Code-credentials"


def _getenv(name: str, default: str = "") -> str:
    """Profile-scoped os.getenv for credential reads (fail-closed on unscoped reads when multiplexing)."""
    val = _get_secret(name, default)
    return val if val is not None else default


def _first_env(*names: str) -> str:
    """First non-blank (stripped) value among *names*, else ''."""
    return next((v for v in (_getenv(n).strip() for n in names) if v), "")


def _is_oauth_token(key: str) -> bool:
    """True for Anthropic OAuth/setup tokens (sk-ant-*, eyJ JWTs, cc-); False for sk-ant-api* Console keys."""
    if not key or key.startswith("sk-ant-api"):
        return False
    return key.startswith(("sk-ant-", "eyJ", "cc-"))


def anthropic_route_is_oauth(base_url: Any, credential: Any, *, provider: Optional[str] = None) -> bool:
    """Claude Code OAuth identity for one Anthropic Messages route (#114967).

    The route qualifies when it is the ``anthropic`` provider itself or its host is exactly
    ``api.anthropic.com`` (an empty base_url is the native default) — a named custom provider
    pointed at the native host carries the same identity, while third-party Anthropic-protocol
    endpoints never do (Claude Code headers and tool-name transforms 401/403 there). ``credential``
    is a static string or a ``key_cmd``/per-request callable token source; a callable is
    materialized once for the shape test (``CommandTokenSource`` caches, so this never double-mints)
    and a mint failure classifies as non-OAuth — the wire client surfaces the real error.
    """
    text = str(base_url or "").strip()
    native_host = not text or (urlparse(text).hostname or "").lower().rstrip(".") == "api.anthropic.com"
    if not (native_host or (provider or "").strip().lower() == "anthropic"):
        return False
    if callable(credential) and not isinstance(credential, str):
        try:
            credential = credential()
        except Exception:  # noqa: BLE001 — classification must never raise
            return False
    return isinstance(credential, str) and _is_oauth_token(credential)


class CredentialPersistError(RuntimeError):
    """A rotated single-use credential could not be durably committed. The refresh POST already spent the old
    refresh token, so a swallowed write failure leaves a consumed pair on disk that later replays as invalid_grant."""

    def __init__(self, path: Any, cause: BaseException) -> None:
        super().__init__(f"failed to durably persist rotated Anthropic credentials to {path}: {cause}")
        self.path = path


def _load_json_if_exists(path: Path, what: str) -> Optional[Any]:
    """Parsed JSON from *path*, or None when missing/unreadable/corrupt (debug-logged)."""
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8-sig"))
    except (json.JSONDecodeError, OSError) as e:
        logger.debug("Failed to read %s: %s", what, e)
        return None


def _atomic_write_private_json(path: Path, payload: Any) -> None:
    """0600-from-creation temp file + fsync + atomic replace (the token is never briefly umask-readable).
    The parent dir's mode is left alone (~/.claude/ is owned by Claude Code)."""
    atomic_json_write(path, payload, mode=0o600)


def _commit_private_json(path: Path, payload: Any, what: str) -> None:
    """Atomic private write; any failure becomes ``CredentialPersistError`` (the commit step of a rotation)."""
    try:
        _atomic_write_private_json(path, payload)
    except (OSError, ValueError) as e:
        logger.error("Failed to write refreshed %s to %s: %s", what, path, e)
        raise CredentialPersistError(path, e) from e


# ── Spent-rotation registry: fingerprints of secrets whose refresh POST succeeded but whose replacement never
# reached its store. Two scopes: process-local (OrderedDict) and a durable sidecar next to the shared singleton
# file so OTHER processes fail closed too. Non-reversible digests; never cleared.
_SPENT_ROTATION_LOCK = threading.Lock()
# Fingerprints of Claude Code refresh tokens the endpoint rejected terminally: the WARNING fires once per token
# per process and later attempts skip the POST (a re-login rotates the token, so a new one is tried normally).
_DEAD_REFRESH_TOKEN_FINGERPRINTS: set = set()
_SPENT_ROTATION_FINGERPRINTS: "OrderedDict[str, None]" = OrderedDict()
_SPENT_ROTATION_MAX_TRACKED = 64
_SPENT_ROTATION_SIDECAR_COMMENT = (
    "Non-secret one-way fingerprints of Anthropic OAuth credentials whose rotation was "
    "consumed server-side but never durably committed. Written by Hermes so sibling "
    "processes sharing this credential source fail closed instead of replaying a spent "
    "single-use refresh token."
)


def _spent_rotation_sidecar_path(source_path: Path) -> Path:
    return source_path.with_name(source_path.name + ".hermes-spent-rotations.json")


def spent_rotation_source_path(source: Any) -> Optional[Path]:
    """Map a pool-entry source to the shared singleton file it borrows from (or None)."""
    getter = _SINGLETON_SOURCE_PATHS.get(source) if isinstance(source, str) else None
    return getter() if getter else None


def _read_spent_rotation_sidecar(source_path: Optional[Path]) -> set:
    if source_path is None:
        return set()
    try:
        raw = json.loads(
            _spent_rotation_sidecar_path(source_path).read_text(encoding="utf-8-sig")
        )
    except (OSError, ValueError):
        return set()
    fingerprints = raw.get("fingerprints") if isinstance(raw, dict) else None
    return {fp for fp in fingerprints if isinstance(fp, str) and fp} if isinstance(fingerprints, list) else set()


def _append_spent_rotation_sidecar(source_path: Path, fingerprints: list) -> None:
    """Merge fingerprints into the sidecar (atomic replace; caller holds the path lock). Fail-soft: a sidecar
    write failure must never mask the process-local verdict."""
    sidecar = _spent_rotation_sidecar_path(source_path)
    try:
        merged = _read_spent_rotation_sidecar(source_path)
        merged.update(fingerprints)
        payload = json.dumps({
            "version": 1,
            "comment": _SPENT_ROTATION_SIDECAR_COMMENT,
            "fingerprints": sorted(merged)[-_SPENT_ROTATION_MAX_TRACKED * 4 :],
        }, indent=2)
        sidecar.parent.mkdir(parents=True, exist_ok=True)
        tmp = sidecar.with_name(sidecar.name + ".tmp")
        tmp.write_text(payload, encoding="utf-8")
        os.replace(tmp, sidecar)
    except Exception:
        logger.debug("Failed to persist spent-rotation fingerprints to %s", sidecar, exc_info=True)


def _fingerprint(secret: Any) -> Optional[str]:
    from agent.credential_persistence import fingerprint_secret_value
    value = str(secret or "").strip()
    return fingerprint_secret_value(value) if value else None


def mark_rotation_consumed_uncommitted(*secrets: Any, source_path: Optional[Path] = None) -> None:
    """Record the pre-rotation pair of a refresh whose replacement never committed; with ``source_path`` the
    verdict is also persisted to that singleton's sidecar."""
    recorded = [fp for fp in map(_fingerprint, secrets) if fp]
    with _SPENT_ROTATION_LOCK:
        for fingerprint in recorded:
            _SPENT_ROTATION_FINGERPRINTS.pop(fingerprint, None)
            _SPENT_ROTATION_FINGERPRINTS[fingerprint] = None
            while len(_SPENT_ROTATION_FINGERPRINTS) > _SPENT_ROTATION_MAX_TRACKED:
                _SPENT_ROTATION_FINGERPRINTS.popitem(last=False)
    if recorded and source_path is not None:
        _append_spent_rotation_sidecar(source_path, recorded)


def is_rotation_consumed_uncommitted(secret: Any, *, source_path: Optional[Path] = None) -> bool:
    """True when *secret* belongs to a rotation that was spent but not committed."""
    fingerprint = _fingerprint(secret)
    if not fingerprint:
        return False
    with _SPENT_ROTATION_LOCK:
        if fingerprint in _SPENT_ROTATION_FINGERPRINTS:
            return True
    return fingerprint in _read_spent_rotation_sidecar(source_path)


# ── Claude Code credentials (Keychain / ~/.claude/.credentials.json) ──
# Only singleton-backed pool sources have a cross-process authority boundary.
_SINGLETON_SOURCE_PATHS = {
    "claude_code": lambda: claude_code_credentials_path(), "hermes_pkce": lambda: _get_hermes_oauth_file()
}


def _claude_oauth_record(data: Any, source: str) -> Optional[Dict[str, Any]]:
    """Normalise a ``{"claudeAiOauth": {...}}`` payload into our credential dict."""
    oauth_data = data.get("claudeAiOauth")
    access_token = oauth_data.get("accessToken", "") if isinstance(oauth_data, dict) else ""
    if not access_token:
        return None
    return {
        "accessToken": access_token, "refreshToken": oauth_data.get("refreshToken", ""),
        "expiresAt": oauth_data.get("expiresAt", 0), "source": source,
    }


_KEYCHAIN_ATTR = r'(?:0x(?P<hex>[0-9A-Fa-f]+)\b.*|"(?P<text>.*)")'


def _decode_keychain_attr(match: Optional["re.Match[str]"]) -> str:
    """``security`` prints an attribute as ``"text"`` when it is plain printable ASCII and as
    ``0x<HEX>  "<octal-escaped echo>"`` otherwise; the quoted form is NOT escaped (an embedded
    ``"`` appears raw), so the text group must run to the last quote on the line."""
    if match is None:
        return ""
    if match.group("hex"):
        try:
            return bytes.fromhex(match.group("hex")).decode("utf-8")
        except ValueError:
            return ""
    return match.group("text") or ""


def _find_claude_code_keychain_item() -> Optional[tuple[str, Dict[str, Any]]]:
    """``(account, payload)`` of the ``Claude Code-credentials`` login Keychain item, or None.

    One ``find-generic-password -g`` call: attributes on stdout, ``password: …`` on stderr. The
    account matters because ``add-generic-password -U`` matches on account AND service — writing
    under another account would create a second item instead of updating the one Claude Code reads.
    """
    if platform.system() != "Darwin":
        return None
    try:
        result = subprocess.run(
            ["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-g"],
            capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, stdin=subprocess.DEVNULL,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    if result.returncode != 0:
        return None
    account = _decode_keychain_attr(re.search(r'^\s*"acct"<blob>=' + _KEYCHAIN_ATTR + r"\s*$", result.stdout, re.M))
    raw = _decode_keychain_attr(re.search(r"^password: " + _KEYCHAIN_ATTR + r"\s*$", result.stderr, re.M))
    if not account or not raw:
        return None
    try:
        payload = json.loads(raw)
    except ValueError:
        return None
    return (account, payload) if isinstance(payload, dict) else None


def _read_claude_code_keychain_payload() -> Optional[Dict[str, Any]]:
    """Raw ``{"claudeAiOauth": {...}, ...}`` payload from the macOS Keychain, or None.

    Returns the full entry (not the normalised credential record) so a refresh
    write can merge the rotated token triple over the existing metadata
    (``subscriptionType`` / ``rateLimitTier`` / ``scopes``) instead of clobbering it.
    """
    if platform.system() != "Darwin":
        return None
    try:
        result = subprocess.run(
            ["security", "find-generic-password", "-s", _CLAUDE_CODE_KEYCHAIN_SERVICE, "-w"],
            capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5, stdin=subprocess.DEVNULL,
        )
    except (OSError, subprocess.TimeoutExpired):
        logger.debug("Keychain: security command not available or timed out")
        return None
    if result.returncode != 0:
        logger.debug("Keychain: no entry found for %r", _CLAUDE_CODE_KEYCHAIN_SERVICE)
        return None
    raw = result.stdout.strip()
    if not raw:
        return None
    try:
        payload = json.loads(raw)
    except json.JSONDecodeError:
        logger.debug("Keychain: credentials payload is not valid JSON")
        return None
    return payload if isinstance(payload, dict) else None


def _keychain_mirror_command(account: str, payload: Dict[str, Any]) -> tuple[list[str], str]:
    """``(argv, stdin)`` that updates the Claude Code Keychain item with ``payload``.

    The command line goes to ``security -i`` on stdin, with the secret hex-encoded (``-X``):
    a bare ``-w`` prompts twice on /dev/tty when a terminal exists (hangs the CLI) and, with
    no terminal, reads only the first line and stores an EMPTY password when the confirmation
    read hits EOF — either way the live token must never sit on argv.
    """
    def quoted(value: str) -> str:  # the ``security -i`` tokenizer: double quotes, backslash escapes
        return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'

    encoded = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8").hex()
    line = f"add-generic-password -U -a {quoted(account)} -s {quoted(_CLAUDE_CODE_KEYCHAIN_SERVICE)} -X {encoded}\n"
    return ["security", "-i"], line


def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
    """Read the "Claude Code-credentials" macOS Keychain entry (Claude Code >=2.1.114)."""
    payload = _read_claude_code_keychain_payload()
    return _claude_oauth_record(payload, "macos_keychain") if payload else None


def claude_code_credentials_path() -> Path:
    """Claude Code's shared OAuth file; every profile reads/writes this same path. Honours ``CLAUDE_CONFIG_DIR``
    like the Claude CLI itself (blank = unset, as in ``hermes_cli.foreign_sessions``). The supported opt-out of
    borrowing the login is ``auth.adopt_external_logins: false`` in config.yaml."""
    override = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
    root = Path(override).expanduser() if override else Path.home() / ".claude"
    return root / ".credentials.json"


def _read_claude_code_credentials_from_file() -> Optional[Dict[str, Any]]:
    data = _load_json_if_exists(claude_code_credentials_path(), "~/.claude/.credentials.json")
    return _claude_oauth_record(data, "claude_code_credentials_file") if data is not None else None


def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
    """Read refreshable Claude Code OAuth credentials (Keychain and/or file). When both exist: prefer the only
    non-expired one (Claude Code 2.1.x refreshes one source but not the other), else the later ``expiresAt`` so a
    refresh uses the freshest refreshToken. ~/.claude.json primaryApiKey is deliberately excluded.

    This is the only reader of the borrowed login, so ``auth.adopt_external_logins: false`` is enforced here:
    every resolver, pool seed/sync and 401 refresher then sees "no Claude Code login" and never touches the file."""
    from agent.credential_sources import adopt_external_logins_enabled
    if not adopt_external_logins_enabled():
        return None
    kc_creds = _read_claude_code_credentials_from_keychain()
    file_creds = _read_claude_code_credentials_from_file()
    if not (kc_creds and file_creds):
        return kc_creds or file_creds
    kc_valid, file_valid = is_claude_code_token_valid(kc_creds), is_claude_code_token_valid(file_creds)
    if kc_valid != file_valid:
        return kc_creds if kc_valid else file_creds
    return kc_creds if (kc_creds.get("expiresAt", 0) or 0) >= (file_creds.get("expiresAt", 0) or 0) else file_creds


def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
    """Non-expired access token (60s buffer); no expiresAt means managed key → valid if present."""
    expires_at = creds.get("expiresAt", 0)
    return int(time.time() * 1000) < (expires_at - 60_000) if expires_at else bool(creds.get("accessToken"))


# ── OAuth token endpoint ──


# OAuth ``error`` codes (RFC 6749 §5.2 + the provider's reuse detection) after which replaying the
# same refresh token can never succeed; only a fresh login recovers.
_OAUTH_GRANT_DEAD_CODES = frozenset({"invalid_grant", "invalid_token", "refresh_token_reused"})


class AnthropicOAuthError(ValueError):
    """Token endpoint rejected the request. ``code`` is the OAuth ``error`` field of the response body."""

    def __init__(self, status: int, code: str, description: str, *, what: str) -> None:
        self.status = status
        self.code = code
        detail = f" ({description})" if description else ""
        super().__init__(f"Anthropic token {what} failed: HTTP {status} {code or 'error'}{detail}")

    @property
    def relogin_required(self) -> bool:
        return self.status in (400, 401) and self.code in _OAUTH_GRANT_DEAD_CODES


def is_terminal_anthropic_refresh_error(exc: BaseException) -> bool:
    """True when retrying the same Anthropic refresh token cannot succeed (dead grant)."""
    return isinstance(exc, AnthropicOAuthError) and exc.relogin_required


def _oauth_http_error(exc: Any, *, what: str) -> AnthropicOAuthError:
    """``urllib.error.HTTPError`` -> structured error carrying the body's OAuth ``error`` code."""
    code, description = "", ""
    try:
        payload = json.loads(exc.read().decode() or "{}")
        code = str(payload.get("error") or "")
        description = str(payload.get("error_description") or "")
    except Exception:
        pass
    return AnthropicOAuthError(int(exc.code), code, description, what=what)


def _post_oauth_token(
    data: bytes, *, content_type: str, timeout: int, what: str, user_agent: str = _OAUTH_TOKEN_USER_AGENT
) -> Dict[str, Any]:
    """POST to the token endpoints in order; raise the last error if all fail."""
    import urllib.error
    import urllib.request
    last_error = None
    for endpoint in _OAUTH_TOKEN_URLS:
        req = urllib.request.Request(
            endpoint, data=data, method="POST", headers={"Content-Type": content_type, "User-Agent": user_agent}
        )
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                return json.loads(resp.read().decode())
        except urllib.error.HTTPError as exc:
            last_error = _oauth_http_error(exc, what=what)
            logger.debug("Anthropic token %s failed at %s: %s", what, endpoint, last_error)
            if last_error.relogin_required:
                break  # a dead grant is dead at every endpoint; do not replay it
        except Exception as exc:
            last_error = exc
            logger.debug("Anthropic token %s failed at %s: %s", what, endpoint, exc)
    raise last_error or ValueError(f"Anthropic token {what} failed")


def _oauth_token_state(result: Dict[str, Any], *, fallback_refresh_token: str = "") -> Dict[str, Any]:
    """Token-endpoint JSON -> ``{access_token, refresh_token, expires_at_ms}`` (expires_in defaults to 3600s)."""
    return {
        "access_token": result.get("access_token", ""),
        "refresh_token": result.get("refresh_token", fallback_refresh_token),
        "expires_at_ms": int(time.time() * 1000) + (result.get("expires_in", 3600) * 1000),
    }


def refresh_anthropic_oauth_pure(refresh_token: str, *, use_json: bool = False) -> Dict[str, Any]:
    """Refresh an Anthropic OAuth token without mutating local credential files."""
    import urllib.parse
    if not refresh_token:
        raise ValueError("refresh_token is required")
    payload = {"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": _OAUTH_CLIENT_ID}
    encode, content_type = ((json.dumps, "application/json") if use_json
                            else (urllib.parse.urlencode, "application/x-www-form-urlencoded"))
    result = _post_oauth_token(encode(payload).encode(), content_type=content_type, timeout=10, what="refresh",
                               user_agent=_OAUTH_TOKEN_USER_AGENT)
    if not result.get("access_token"):
        raise ValueError("Anthropic refresh response was missing access_token")
    return _oauth_token_state(result, fallback_refresh_token=refresh_token)


def _refresh_oauth_token(creds: Dict[str, Any]) -> Optional[str]:
    """Refresh an expired Claude Code OAuth token, returning the new access token. Refresh tokens are single-use and
    Claude Code refreshes on its own schedule, so we first re-read the live sources and adopt an already-rotated
    token instead of racing it into ``invalid_grant``. Read, decision, POST and write-back share the pool's
    path-keyed cross-process lock (else two profiles can spend one refresh token)."""
    try:
        from hermes_cli.auth import AUTH_LOCK_TIMEOUT_SECONDS, _auth_store_lock, env_float
        refresh_timeout_seconds = env_float("HERMES_ANTHROPIC_REFRESH_TIMEOUT_SECONDS", 20)
        lock_timeout_seconds = max(float(AUTH_LOCK_TIMEOUT_SECONDS), float(refresh_timeout_seconds) + 5.0)
        cred_path = claude_code_credentials_path()
        with _auth_store_lock(timeout_seconds=lock_timeout_seconds, target_path=cred_path):
            # Adopt only a DIFFERENT token with a real future expiry (0/absent expiresAt = managed key/unknown).
            current = read_claude_code_credentials() or {}
            current_token = current.get("accessToken", "")
            if (current_token and current_token != creds.get("accessToken", "")
                    and (current.get("expiresAt", 0) or 0) > 0 and is_claude_code_token_valid(current)):
                logger.debug("Adopted Claude Code's already-refreshed OAuth token")
                return current_token

            refresh_token = current.get("refreshToken", "") or creds.get("refreshToken", "")
            if not refresh_token:
                logger.debug("No refresh token available — cannot refresh")
                return None
            # Another process may have spent this token and lost the commit; its sidecar verdict is authoritative.
            if is_rotation_consumed_uncommitted(refresh_token, source_path=cred_path):
                logger.debug("Refresh token was already consumed by an uncommitted rotation "
                             "- refusing to replay it; run 'hermes auth add anthropic'")
                return None
            fingerprint = hashlib.sha256(refresh_token.encode("utf-8")).hexdigest()[:32]
            if fingerprint in _DEAD_REFRESH_TOKEN_FINGERPRINTS:
                logger.debug("Claude Code refresh token was already rejected as terminally invalid - not replaying it")
                return None
            try:
                refreshed = refresh_anthropic_oauth_pure(refresh_token, use_json=False)
            except Exception as e:
                if is_terminal_anthropic_refresh_error(e):
                    _DEAD_REFRESH_TOKEN_FINGERPRINTS.add(fingerprint)
                    logger.warning(
                        "Claude Code OAuth refresh token is terminally invalid (%s); Hermes cannot use this "
                        "login. Run 'hermes auth add anthropic' to give Hermes its own login.", e)
                else:
                    logger.debug("Failed to refresh Claude Code token: %s", e)
                return None
            # The POST spent ``refresh_token``; this write is the commit step. On failure, fail closed and
            # mark the pre-rotation pair as spent.
            try:
                _write_claude_code_credentials(
                    refreshed["access_token"], refreshed["refresh_token"], refreshed["expires_at_ms"],
                    spent_refresh_token=refresh_token,
                )
            except Exception as e:
                logger.error(
                    "Anthropic OAuth refresh rotated the single-use token but could not "
                    "commit it to %s (%s) — treating the refresh as failed; "
                    "run 'hermes auth add anthropic' to give Hermes its own login",
                    cred_path, e,
                )
                mark_rotation_consumed_uncommitted(
                    refresh_token, creds.get("accessToken", ""), current.get("accessToken", ""),
                    current.get("refreshToken", ""), source_path=cred_path,
                )
                return None
            logger.debug("Successfully refreshed Claude Code OAuth token")
            return refreshed["access_token"]
    except Exception as e:
        # Lock/read failures keep the resolver's fail-soft contract.
        logger.debug("Failed to acquire Claude Code refresh lock: %s", e)
        return None


def _write_claude_code_credentials(
    access_token: str, refresh_token: str, expires_at_ms: int, *, scopes: Optional[list] = None,
    spent_refresh_token: str = "",
) -> None:
    """Commit refreshed credentials to ~/.claude/.credentials.json; ``CredentialPersistError`` on any failure (a
    corrupt existing file included). *scopes* (or the previously stored scopes) are persisted because Claude Code
    >=2.1.81 gates on ``"user:inference"`` being present."""
    cred_path = claude_code_credentials_path()
    try:
        existing = json.loads(cred_path.read_text(encoding="utf-8-sig")) if cred_path.exists() else {}
    except (OSError, ValueError) as e:
        logger.error("Failed to write refreshed credentials to %s: %s", cred_path, e)
        raise CredentialPersistError(cred_path, e) from e
    oauth_data: Dict[str, Any] = {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms}
    if scopes is not None:
        oauth_data["scopes"] = scopes
    elif "claudeAiOauth" in existing and "scopes" in existing["claudeAiOauth"]:
        oauth_data["scopes"] = existing["claudeAiOauth"]["scopes"]
    existing["claudeAiOauth"] = oauth_data
    _commit_private_json(cred_path, existing, "credentials")
    _mirror_claude_code_credentials_to_keychain(
        access_token, refresh_token, expires_at_ms, spent_refresh_token=spent_refresh_token)


def _merge_keychain_credential_payload(
    existing_payload: Dict[str, Any], access_token: str, refresh_token: str, expires_at_ms: int
) -> Dict[str, Any]:
    """Rotate the ``claudeAiOauth`` token triple over the existing Keychain payload,
    preserving its metadata (``subscriptionType`` / ``rateLimitTier`` / ``scopes``).

    Pure and host-agnostic so the merge semantics are unit-testable without a Keychain.
    """
    merged = dict(existing_payload)
    oauth = dict(existing_payload.get("claudeAiOauth") or {})
    oauth.update({"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms})
    merged["claudeAiOauth"] = oauth
    return merged


def _mirror_claude_code_credentials_to_keychain(
    access_token: str, refresh_token: str, expires_at_ms: int, *, spent_refresh_token: str
) -> None:
    """After a Hermes refresh, write the rotated pair into the Claude Code Keychain item too (#98334).

    Claude Code on macOS reads the login Keychain first. Refresh tokens are single-use, so a refresh
    that only updates the file leaves the Keychain holding a spent token and Claude Code logs itself
    out. Only the item that held the pair we just spent is updated — a different pair there means a
    different login (``CLAUDE_CONFIG_DIR``) or a rotation Claude Code already made, and clobbering it
    would be the bug in the other direction. Best-effort: never raises, never creates an item.
    """
    if platform.system() != "Darwin":
        return
    try:
        item = _find_claude_code_keychain_item()
        if item is None:
            return
        account, existing = item
        oauth = existing.get("claudeAiOauth")
        if not isinstance(oauth, dict) or oauth.get("refreshToken") != spent_refresh_token:
            logger.debug("Keychain mirror skipped: item does not hold the pair that was just rotated")
            return
        argv, line = _keychain_mirror_command(
            account, _merge_keychain_credential_payload(existing, access_token, refresh_token, expires_at_ms))
        result = subprocess.run(
            argv, input=line, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10,
        )
    except Exception as e:  # the file commit already succeeded; a Keychain hiccup must not fail the rotation
        logger.debug("Keychain mirror skipped (%s)", e)
        return
    if result.returncode != 0:
        logger.debug("Keychain mirror failed (rc=%s): %s", result.returncode, (result.stderr or "").strip()[:200])


# ── Resolution ──


def _resolve_claude_code_token_from_credentials(creds: Optional[Dict[str, Any]] = None) -> Optional[str]:
    """Resolve a token from Claude Code credential files, refreshing if needed."""
    creds = creds or read_claude_code_credentials()
    if not creds:
        return None
    if is_rotation_consumed_uncommitted(creds.get("accessToken", ""), source_path=claude_code_credentials_path()):
        # The file still holds the spent pre-rotation copy of a failed commit.
        logger.debug("Claude Code credentials hold a rotated-but-uncommitted token - refusing")
        return None
    if is_claude_code_token_valid(creds):
        logger.debug("Using Claude Code credentials (auto-detected)")
        return creds["accessToken"]
    logger.debug("Claude Code credentials expired — attempting refresh")
    refreshed = _refresh_oauth_token(creds)
    if not refreshed:
        logger.debug("Token refresh failed — run 'hermes auth add anthropic' to give Hermes its own login")
    return refreshed or None


def _prefer_refreshable_claude_code_token(env_token: str, creds: Optional[Dict[str, Any]]) -> Optional[str]:
    """Prefer refreshable Claude Code creds over a static env OAuth token: Hermes historically persisted setup tokens
    into ANTHROPIC_TOKEN, and that static token would otherwise win before the refreshable file is inspected."""
    if not (env_token and _is_oauth_token(env_token) and isinstance(creds, dict) and creds.get("refreshToken")):
        return None
    resolved = _resolve_claude_code_token_from_credentials(creds)
    if resolved and resolved != env_token:
        logger.debug("Preferring Claude Code credential file over static env OAuth token so refresh can proceed")
        return resolved
    return None


def _resolve_anthropic_pool_token(*, skip_borrowed: bool = False) -> Optional[str]:
    """First available Anthropic OAuth token from credential_pool, read-only: enumerates with ``clear_expired=False,
    refresh=False`` (never ``select()``) so diagnostic call sites (account_usage, ``hermes models``) never mutate
    auth.json or hit the network; refresh-on-expiry belongs to the API call path's pool recovery."""
    try:
        from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool
        entries, _pending = load_pool("anthropic")._available_entries(clear_expired=False, refresh=False)
    except Exception:
        logger.debug("Failed to read Anthropic credential_pool", exc_info=True)
        return None
    for entry in entries:
        if skip_borrowed and entry.source == "claude_code":
            continue
        # access_token may be an explicit null on a persisted entry; None.strip() would crash the resolver.
        token = (getattr(entry, "access_token", None) or "").strip()
        if getattr(entry, "auth_type", None) != AUTH_TYPE_OAUTH or not token:
            continue
        # load_pool() re-seeds rows from the singleton files, so a spent-but-uncommitted rotation
        # (possibly from another process) looks healthy here.
        entry_source_path = spent_rotation_source_path(getattr(entry, "source", None))
        if any(
            is_rotation_consumed_uncommitted(secret, source_path=entry_source_path)
            for secret in (token, getattr(entry, "refresh_token", None))
        ):
            logger.debug("Skipping Anthropic pool entry %s: rotated-but-uncommitted credential", getattr(entry, "id", "?"))
            continue
        return token
    return None


def _available_anthropic_token(token: Optional[str], model: Optional[str]) -> Optional[str]:
    """Return *token* unless the pool holds an active cooldown for it on *model*.

    Only model-aware callers (the API-call paths) are gated: diagnostics that
    resolve a token without a model (usage display, model discovery) keep it.
    """
    if not token or not model:
        return token or None
    try:
        from agent.credential_pool import load_pool
        if load_pool("anthropic").token_is_blocked(token, model=model):
            return None
    except Exception:
        # Credential discovery must remain available when the pool store is
        # unavailable or malformed.
        logger.debug("Failed to check Anthropic model cooldown", exc_info=True)
    return token


def resolve_anthropic_token(*, model: Optional[str] = None) -> Optional[str]:
    """Resolve an Anthropic token from all sources in priority order (see module docstring).

    With *model*, a token the credential pool has benched for that model resolves to ``None``
    instead of being handed straight back to the caller that just saw it rate-limited."""
    _read_creds = functools.cache(read_claude_code_credentials)  # read the file at most once per resolve
    token = _first_env("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
    if token:
        return _available_anthropic_token(
            _prefer_refreshable_claude_code_token(token, _read_creds()) or token, model,
        )
    api_key = _first_env("ANTHROPIC_API_KEY")  # an explicit API key must not be shadowed by discovered OAuth creds
    if api_key:
        return _available_anthropic_token(api_key, model)
    # The pool's claude_code row mirrors the same externally owned refresh grant.
    return _available_anthropic_token(
        _resolve_anthropic_pool_token(skip_borrowed=True) or _resolve_claude_code_token_from_credentials(_read_creds()),
        model,
    )


def run_oauth_setup_token() -> Optional[str]:
    """Run 'claude setup-token' interactively; the resulting token or None. FileNotFoundError if no 'claude' CLI."""
    import shutil
    claude_path = shutil.which("claude")
    if not claude_path:
        raise FileNotFoundError("The 'claude' CLI is not installed. Install it with: npm install -g @anthropic-ai/claude-code")
    # Interactive: stdio inherited so the user can complete the OAuth prompt.  noqa: subprocess-stdin
    try:
        subprocess.run([claude_path, "setup-token"])
    except (KeyboardInterrupt, EOFError):
        return None
    creds = read_claude_code_credentials()
    if creds and is_claude_code_token_valid(creds):
        return creds["accessToken"]
    return _first_env("CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_TOKEN") or None


# ── Hermes-native PKCE OAuth flow (~/.hermes/.anthropic_oauth.json); mirrors Claude Code / pi-ai / OpenCode ──


def _get_hermes_oauth_file() -> Path:
    return get_hermes_home() / ".anthropic_oauth.json"


def _root_hermes_oauth_file() -> Optional[Path]:
    """Global-root ``.anthropic_oauth.json`` inside a named profile (None in classic mode); used to commit a
    rotation of a grant the profile borrowed via the pool's root fallback."""
    try:
        from hermes_constants import get_default_hermes_root
        root = get_default_hermes_root()
        return None if root.resolve(strict=False) == get_hermes_home().resolve(strict=False) else root / ".anthropic_oauth.json"
    except Exception:
        return None


def _generate_pkce() -> tuple:
    """Generate PKCE code_verifier and code_challenge (S256)."""
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    return verifier, challenge


def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
    """Run Hermes-native OAuth PKCE flow and return credential state."""
    import webbrowser
    from urllib.parse import urlencode
    verifier, challenge = _generate_pkce()
    oauth_state = secrets.token_urlsafe(32)
    params = {
        "code": "true", "client_id": _OAUTH_CLIENT_ID, "response_type": "code", "redirect_uri": _OAUTH_REDIRECT_URI,
        "scope": _OAUTH_SCOPES, "code_challenge": challenge, "code_challenge_method": "S256", "state": oauth_state,
    }
    auth_url = f"https://claude.ai/oauth/authorize?{urlencode(params)}"
    print("\n".join([
        "", "Authorize Hermes with your Claude Pro/Max subscription.", "",
        "╭─ Claude Pro/Max Authorization ────────────────────╮",
        "│                                                   │",
        "│  Open this link in your browser:                  │",
        "╰───────────────────────────────────────────────────╯",
        "", f"  {auth_url}", "",
    ]))
    try:
        from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
    except Exception:
        _can_open_gui = lambda: True  # noqa: E731 — degrade to prior behavior
    if _can_open_gui():
        with contextlib.suppress(Exception):
            webbrowser.open(auth_url)
            print("  (Browser opened automatically)")
    print("\nAfter authorizing, you'll see a code. Paste it below.\n")
    try:
        auth_code = input("Authorization code: ").strip()
    except (KeyboardInterrupt, EOFError):
        return None
    if not auth_code:
        print("No code entered.")
        return None
    splits = auth_code.split("#")
    code, received_state = splits[0], (splits[1] if len(splits) > 1 else "")
    if received_state != oauth_state:  # CSRF guard (RFC 6749 §10.12)
        logger.warning("OAuth state mismatch — possible CSRF, aborting")
        return None
    try:
        exchange_data = json.dumps({
            "grant_type": "authorization_code", "client_id": _OAUTH_CLIENT_ID, "code": code, "state": received_state,
            "redirect_uri": _OAUTH_REDIRECT_URI, "code_verifier": verifier,
        }).encode()
        result = _post_oauth_token(exchange_data, content_type="application/json", timeout=15, what="exchange")
    except Exception as e:
        print(f"Token exchange failed: {e}")
        return None
    if not result.get("access_token"):
        print("No access token in response.")
        return None
    return _oauth_token_state(result)


def read_hermes_oauth_credentials() -> Optional[Dict[str, Any]]:
    """Read Hermes-managed OAuth credentials from ~/.hermes/.anthropic_oauth.json."""
    data = _load_json_if_exists(_get_hermes_oauth_file(), "Hermes OAuth credentials")
    return data if data is not None and data.get("accessToken") else None


def _write_hermes_oauth_credentials(
    access_token: str, refresh_token: Optional[str], expires_at_ms: Optional[int], *, target: Optional[Path] = None
) -> None:
    """Commit refreshed hermes_pkce tokens to ~/.hermes/.anthropic_oauth.json (``CredentialPersistError`` on failure).
    ``target`` lets a named profile commit a grant it BORROWED from the global root back to the ROOT singleton
    instead of forking a copy under its own HERMES_HOME; without this write-through the next ``load_pool()``
    re-seeds the stale (consumed) pair from the file over the rotated pool entry."""
    _commit_private_json(
        target if target is not None else _get_hermes_oauth_file(),
        {"accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms},
        "Hermes OAuth credentials",
    )
