#!/usr/bin/env python3
"""
MCP OAuth 2.1 Client Support

Implements the browser-based OAuth 2.1 authorization code flow with PKCE
for MCP servers that require OAuth authentication instead of static bearer
tokens.

Uses the MCP Python SDK's ``OAuthClientProvider`` (an ``httpx.Auth`` subclass)
which handles discovery, dynamic client registration, PKCE, token exchange,
refresh, and step-up authorization automatically.

This module provides the glue:
    - ``HermesTokenStorage``: persists tokens/client-info to disk so they
      survive across process restarts.
    - Callback server: ephemeral localhost HTTP server to capture the OAuth
      redirect with the authorization code.
    - ``build_oauth_auth()``: entry point called by ``mcp_tool.py`` that wires
      everything together and returns the ``httpx.Auth`` object.

Configuration in config.yaml::

    mcp_servers:
      my_server:
        url: "https://mcp.example.com/mcp"
        auth: oauth
        oauth:                                  # all fields optional
          client_id: "pre-registered-id"        # skip dynamic registration
          client_secret: "secret"               # confidential clients only
          scope: "read write"                   # default: server-provided
          redirect_port: 0                      # 0 = auto-pick free port
          redirect_uri: "https://proxy/callback"  # default: loopback callback
          redirect_host: "localhost"            # loopback hostname (WAF-safe)
          client_name: "My Custom Client"       # default: "Hermes Agent"
"""

import asyncio
import contextvars
import json
import logging
import os
import re
import secrets
import socket
import stat
import sys
import threading
import time
import webbrowser
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from hermes_constants import secure_parent_dir

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Lazy imports -- MCP SDK with OAuth support is optional
# ---------------------------------------------------------------------------

_OAUTH_AVAILABLE=False
try:
    from mcp.client.auth import OAuthClientProvider
    from mcp.shared.auth import (
        OAuthClientInformationFull,
        OAuthClientMetadata,
        OAuthMetadata,
        OAuthToken,
    )

    _OAUTH_AVAILABLE=True
except ImportError:
    logger.debug("MCP OAuth types not available -- OAuth MCP auth disabled")

try:
    from pydantic import AnyUrl
except ImportError:
    AnyUrl = None  # type: ignore[assignment, misc]


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------


class OAuthNonInteractiveError(RuntimeError):
    """Raised when OAuth requires browser interaction in a non-interactive env."""


# ---------------------------------------------------------------------------
# Module-level state
# ---------------------------------------------------------------------------

# Port used by the most recent build_oauth_auth() call.  Exposed so that
# tests can verify the callback server and the redirect_uri share a port.
_oauth_port: int | None = None
# Interactivity gate for OAuth stdin prompts. A ContextVar (NOT threading.local)
# is required: background MCP discovery sets this on the discovery thread, but
# the actual connect+OAuth runs on the dedicated `mcp-event-loop` thread via
# run_coroutine_threadsafe. asyncio copies the *calling context* into the
# scheduled coroutine, so a ContextVar propagates across that boundary while a
# threading.local would not — see #35927. Default True (interactive allowed).
_oauth_interactive_enabled: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
    "_oauth_interactive_enabled", default=True
)

# Forces _is_interactive() past the stdin-TTY check for flows driven from a
# GUI (dashboard/desktop REST): the browser + localhost callback server do all
# the work there, and the stdin paste fallback degrades harmlessly (EOF is
# swallowed by _paste_callback_reader). Suppression still wins — background
# discovery must never start a browser flow.
_oauth_interactive_forced: "contextvars.ContextVar[bool]" = contextvars.ContextVar(
    "_oauth_interactive_forced", default=False
)


# Skip tokens accepted at the paste prompt — exit OAuth without auth.
_SKIP_TOKENS = frozenset({"skip", "cancel", "s", "n", "no", "q", "quit"})

# Sentinel value written to result["error"] when the user skipped via stdin.
# _wait_for_callback maps this to OAuthNonInteractiveError ("user_skipped")
# so the MCP setup path treats it as a non-fatal "continue without this
# server" rather than a hard failure.
_USER_SKIPPED_SENTINEL = "__hermes_user_skipped__"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _get_token_dir(hermes_home: str | Path | None = None) -> Path:
    """Return the directory for MCP OAuth token files.

    Uses HERMES_HOME so each profile gets its own OAuth tokens.
    Layout: ``HERMES_HOME/mcp-tokens/``
    """
    try:
        from hermes_constants import get_hermes_home
        base = Path(hermes_home) if hermes_home is not None else Path(get_hermes_home())
    except ImportError:
        base = Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")))
    return base / "mcp-tokens"


def _safe_filename(name: str) -> str:
    """Sanitize a server name for use as a filename (no path separators)."""
    return re.sub(r"[^\w\-]", "_", name).strip("_")[:128] or "default"


def _find_free_port() -> int:
    """Find an available TCP port on localhost."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


# Bound-but-not-listening sockets reserved for pending OAuth callback flows,
# keyed by port. Holding the socket from port-selection time until
# _wait_for_callback adopts it closes the TOCTOU window where another process
# could grab the port between _find_free_port() closing its probe socket and
# HTTPServer binding minutes later (#22161). Bounded FIFO so repeated
# build_oauth_auth calls (reconnect loops) cannot leak fds.
_reserved_sockets: "dict[int, socket.socket]" = {}
_MAX_RESERVED_SOCKETS = 8


def _reserve_callback_port() -> int:
    """Pick an ephemeral callback port and keep its socket bound.

    Returns the port. The bound (not yet listening) socket is parked in
    ``_reserved_sockets`` so no other process can bind the port before
    ``_wait_for_callback`` adopts it. Adoption (or ``server_close``) owns
    the socket's lifetime from there.
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.bind(("127.0.0.1", 0))
    except OSError:
        s.close()
        raise
    port = s.getsockname()[1]
    # Evict oldest reservations past the cap (dict preserves insertion order).
    while len(_reserved_sockets) >= _MAX_RESERVED_SOCKETS:
        _, stale = next(iter(_reserved_sockets.items()))
        _reserved_sockets.pop(next(iter(_reserved_sockets)), None)
        try:
            stale.close()
        except OSError:
            pass
    _reserved_sockets[port] = s
    return port


def _cached_redirect_port(storage: "HermesTokenStorage | None") -> int | None:
    """Return the loopback callback port from cached client registration.

    OAuth providers bind a dynamically-registered ``client_id`` to the exact
    redirect URI that was registered with it. If Hermes restarts and chooses a
    new random callback port while reusing the stored ``client_id``, providers
    such as Summ reject the authorization request with ``redirect_uri does not
    match any registered URIs``. Reusing the cached redirect port keeps the
    authorization request consistent with the stored client registration.
    """
    if storage is None:
        return None

    try:
        data = _read_json(storage._client_info_path())
    except (AttributeError, TypeError, ValueError):
        return None
    if not data:
        return None

    for uri in data.get("redirect_uris") or []:
        try:
            parsed = urlparse(str(uri))
        except (TypeError, ValueError):
            continue
        if (
            parsed.scheme == "http"
            and parsed.hostname in {"127.0.0.1", "localhost"}
            and parsed.path == "/callback"
            and parsed.port is not None
        ):
            return int(parsed.port)
    return None


def _cached_redirect_uri(storage: "HermesTokenStorage | None") -> str | None:
    """Return a cached non-loopback redirect URI, if one was registered."""
    if storage is None:
        return None
    try:
        data = _read_json(storage._client_info_path())
    except (AttributeError, TypeError, ValueError):
        return None
    for uri in (data or {}).get("redirect_uris") or []:
        try:
            parsed = urlparse(str(uri))
        except (TypeError, ValueError):
            continue
        if parsed.scheme == "https" and parsed.netloc:
            return str(uri)
    return None


def _is_interactive() -> bool:
    """Return True if we can reasonably expect to interact with a user."""
    if not _oauth_interactive_enabled.get():
        return False
    if _oauth_interactive_forced.get():
        return True
    try:
        return sys.stdin.isatty()
    except (AttributeError, ValueError):
        return False


def _raise_if_non_interactive(lead: str) -> None:
    """Raise ``OAuthNonInteractiveError`` unless an interactive session exists.

    ``lead`` is the boundary-specific first sentence; this helper appends the
    shared, actionable ``hermes mcp login`` next-step so the guidance wording
    lives in one place across every non-interactive OAuth boundary (#57836).
    """
    if not _is_interactive():
        raise OAuthNonInteractiveError(
            f"{lead} "
            "Run `hermes mcp login <server>` interactively to (re)authorize, "
            "then restart or reload the gateway."
        )


@contextmanager
def force_interactive_oauth():
    """Treat the current execution context as interactive despite no TTY.

    For GUI-driven auth (dashboard/desktop REST endpoint): the user IS present
    — just not on stdin. Opens the browser + localhost callback flow that the
    TTY heuristic would otherwise refuse. Same ContextVar propagation story as
    suppress_interactive_oauth() (#35927).
    """
    token = _oauth_interactive_forced.set(True)
    try:
        yield
    finally:
        _oauth_interactive_forced.reset(token)


@contextmanager
def suppress_interactive_oauth():
    """Disable stdin-based OAuth prompts for the current execution context.

    Uses a ContextVar so the suppression propagates from a background-discovery
    thread onto the coroutine scheduled (via run_coroutine_threadsafe) on the
    dedicated MCP event-loop thread — where the OAuth callback actually runs
    (#35927). A threading.local would not cross that thread boundary.
    """
    token = _oauth_interactive_enabled.set(False)
    try:
        yield
    finally:
        _oauth_interactive_enabled.reset(token)


def _can_open_browser() -> bool:
    """Return True if opening a browser is likely to work."""
    # Explicit SSH session → no local display
    if os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"):
        return False
    # macOS and Windows usually have a display
    if os.name == "nt":
        return True
    try:
        if os.uname().sysname == "Darwin":
            return True
    except AttributeError:
        pass
    # Linux/other posix: need DISPLAY or WAYLAND_DISPLAY
    if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
        return True
    return False


def _read_json(path: Path) -> dict | None:
    """Read a JSON file, returning None if it doesn't exist or is invalid."""
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning("Failed to read %s: %s", path, exc)
        return None


def _write_json(path: Path, data: dict) -> None:
    """Write a dict as JSON with restricted permissions (0o600).

    Uses ``os.open`` with ``O_EXCL`` and an explicit mode so the file is
    created atomically at 0o600. The previous ``write_text`` + post-write
    ``chmod`` opened a TOCTOU window where the temp file briefly inherited
    the process umask (commonly 0o644 = world-readable), exposing OAuth
    tokens to other local users between create and chmod. Mirrors the fix
    in ``agent/google_oauth.py`` (#19673).
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    # Tighten parent dir to 0o700 so siblings can't traverse to the creds.
    # No-op on Windows (POSIX mode bits aren't enforced); ignore failures.
    # secure_parent_dir refuses to chmod / or top-level dirs (#25821).
    secure_parent_dir(path)
    # Per-process random suffix avoids collisions between concurrent
    # writers and stale leftovers from a prior crashed write.
    tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
    try:
        fd = os.open(
            str(tmp),
            os.O_WRONLY | os.O_CREAT | os.O_EXCL,
            stat.S_IRUSR | stat.S_IWUSR,
        )
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            json.dump(data, fh, indent=2, default=str)
            fh.flush()
            os.fsync(fh.fileno())
        os.replace(tmp, path)
    except OSError:
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        raise


# ---------------------------------------------------------------------------
# HermesTokenStorage -- persistent token/client-info on disk
# ---------------------------------------------------------------------------


class HermesTokenStorage:
    """Persist OAuth tokens and client registration to JSON files.

    File layout::

        HERMES_HOME/mcp-tokens/<server_name>.json         -- tokens
        HERMES_HOME/mcp-tokens/<server_name>.client.json   -- client info
        HERMES_HOME/mcp-tokens/<server_name>.meta.json     -- oauth server metadata
    """

    def __init__(self, server_name: str, *, hermes_home: str | Path | None = None):
        self._server_name = _safe_filename(server_name)
        self._hermes_home = Path(hermes_home) if hermes_home is not None else None

    def _tokens_path(self) -> Path:
        return _get_token_dir(self._hermes_home) / f"{self._server_name}.json"

    def _client_info_path(self) -> Path:
        return _get_token_dir(self._hermes_home) / f"{self._server_name}.client.json"

    def _meta_path(self) -> Path:
        return _get_token_dir(self._hermes_home) / f"{self._server_name}.meta.json"

    # -- tokens ------------------------------------------------------------

    async def get_tokens(self) -> "OAuthToken | None":
        data = _read_json(self._tokens_path())
        if data is None:
            return None
        # Hermes records an absolute wall-clock ``expires_at`` alongside the
        # SDK's serialized token (see ``set_tokens``). On read we rewrite
        # ``expires_in`` to the remaining seconds so the SDK's downstream
        # ``update_token_expiry`` computes the correct absolute time and
        # ``is_token_valid()`` correctly reports False for tokens that
        # expired while the process was down.
        #
        # Legacy token files (pre-Fix-A) have ``expires_in`` but no
        # ``expires_at``. We fall back to the file's mtime as a best-effort
        # wall-clock proxy for when the token was written: if (mtime +
        # expires_in) is in the past, clamp ``expires_in`` to zero so the
        # SDK refreshes before the first request. This self-heals one-time
        # on the next successful ``set_tokens``, which writes the new
        # ``expires_at`` field. The stored ``expires_at`` is stripped before
        # model_validate because it's not part of the SDK's OAuthToken schema.
        absolute_expiry = data.pop("expires_at", None)
        if absolute_expiry is not None:
            data["expires_in"] = int(max(absolute_expiry - time.time(), 0))
        elif data.get("expires_in") is not None:
            try:
                file_mtime = self._tokens_path().stat().st_mtime
            except OSError:
                file_mtime = None
            if file_mtime is not None:
                try:
                    implied_expiry = file_mtime + int(data["expires_in"])
                    data["expires_in"] = int(max(implied_expiry - time.time(), 0))
                except (TypeError, ValueError):
                    pass
        try:
            return OAuthToken.model_validate(data)
        except (ValueError, TypeError, KeyError) as exc:
            logger.warning("Corrupt tokens at %s -- ignoring: %s", self._tokens_path(), exc)
            return None

    async def set_tokens(self, tokens: "OAuthToken") -> None:
        payload = tokens.model_dump(mode="json", exclude_none=True)
        # Persist an absolute ``expires_at`` so a process restart can
        # reconstruct the correct remaining TTL. Without this the MCP SDK's
        # ``_initialize`` reloads a relative ``expires_in`` which has no
        # wall-clock reference, leaving ``context.token_expiry_time=None``
        # and ``is_token_valid()`` falsely reporting True. See Fix A in
        # ``mcp-oauth-token-diagnosis`` skill + Claude Code's
        # ``OAuthTokens.expiresAt`` persistence (auth.ts ~180).
        expires_in = payload.get("expires_in")
        if expires_in is not None:
            try:
                payload["expires_at"] = time.time() + int(expires_in)
            except (TypeError, ValueError):
                # Mock tokens or unusual shapes: skip the expires_at write
                # rather than fail persistence.
                pass
        _write_json(self._tokens_path(), payload)
        logger.debug("OAuth tokens saved for %s", self._server_name)

    # -- client info -------------------------------------------------------

    async def get_client_info(self) -> "OAuthClientInformationFull | None":
        data = _read_json(self._client_info_path())
        if data is None:
            return None
        try:
            return OAuthClientInformationFull.model_validate(data)
        except (ValueError, TypeError, KeyError) as exc:
            logger.warning("Corrupt client info at %s -- ignoring: %s", self._client_info_path(), exc)
            return None

    async def set_client_info(self, client_info: "OAuthClientInformationFull") -> None:
        _write_json(self._client_info_path(), client_info.model_dump(mode="json", exclude_none=True))
        logger.debug("OAuth client info saved for %s", self._server_name)

    # -- oauth server metadata --------------------------------------------
    # The MCP SDK keeps discovered ``OAuthMetadata`` (token endpoint URL,
    # etc.) in memory only. Persisting it here lets a restarted process
    # refresh tokens without re-running metadata discovery. Without this,
    # cold-start refresh requests fall back to the SDK's guessed
    # ``{server_url}/token`` which returns 404 on most real providers and
    # forces a full browser re-authorization.

    def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None:
        _write_json(self._meta_path(), metadata.model_dump(exclude_none=True, mode="json"))
        logger.debug("OAuth metadata saved for %s", self._server_name)

    def load_oauth_metadata(self) -> "OAuthMetadata | None":
        data = _read_json(self._meta_path())
        if data is None:
            return None
        try:
            return OAuthMetadata.model_validate(data)
        except (ValueError, TypeError, KeyError) as exc:
            logger.warning("Corrupt OAuth metadata at %s -- ignoring: %s", self._meta_path(), exc)
            return None

    # -- cleanup -----------------------------------------------------------

    def remove(self) -> None:
        """Delete all stored OAuth state for this server."""
        for p in (self._tokens_path(), self._client_info_path(), self._meta_path()):
            p.unlink(missing_ok=True)

    def snapshot(self) -> dict[str, bytes]:
        """Capture on-disk OAuth state so a failed re-auth can restore it.

        Maps filename -> bytes for whichever of the three state files exist.
        Feed back to ``restore()`` to undo an intervening ``remove()`` when a
        re-authentication attempt fails, so a still-valid token isn't destroyed.
        """
        snap: dict[str, bytes] = {}
        for p in (self._tokens_path(), self._client_info_path(), self._meta_path()):
            try:
                snap[p.name] = p.read_bytes()
            except OSError:
                pass
        return snap

    def restore(self, snapshot: dict[str, bytes], *, only_if_absent: bool = False) -> None:
        """Revert to a snapshot without overwriting a concurrent successful write."""
        if only_if_absent and any(
            path.exists()
            for path in (self._tokens_path(), self._client_info_path(), self._meta_path())
        ):
            logger.info(
                "Skipping OAuth rollback for %s because newer state exists",
                self._server_name,
            )
            return
        self.remove()
        if not snapshot:
            return
        token_dir = _get_token_dir(self._hermes_home)
        token_dir.mkdir(parents=True, exist_ok=True)
        for fname, data in snapshot.items():
            path = token_dir / fname
            try:
                fd = os.open(
                    str(path),
                    os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
                    stat.S_IRUSR | stat.S_IWUSR,
                )
                with os.fdopen(fd, "wb") as fh:
                    fh.write(data)
            except OSError as exc:
                logger.warning("Failed to restore OAuth state %s: %s", fname, exc)

    def poison_client_registration(self) -> bool:
        """Discard a dead dynamically-registered client so it gets re-created.

        Called when the IdP rejects our cached ``client_id`` with
        ``invalid_client`` on the token endpoint — proof the server-side
        registration is gone (IdP redeploy / DB wipe / rebrand). Deleting
        ``client.json`` makes the MCP SDK's ``async_auth_flow`` take the
        ``if not client_info`` branch and re-run RFC 7591 dynamic client
        registration on the next flow. The stale ``meta.json`` is dropped
        too so discovery re-runs against a freshly fetched document.

        Tokens are intentionally left in place — the subsequent
        re-authorization overwrites them, and keeping them avoids losing a
        still-valid refresh token if the re-registration never completes.

        A single ``.bak`` copy of the client file is kept for recovery.
        Returns True if a client file was present and removed.
        """
        client_path = self._client_info_path()
        if not client_path.exists():
            return False
        backup = client_path.with_name(client_path.name + ".bak")
        try:
            backup.write_bytes(client_path.read_bytes())
        except OSError as exc:  # non-fatal — proceed with the removal anyway
            logger.warning("Could not back up client info at %s: %s", client_path, exc)
        client_path.unlink(missing_ok=True)
        self._meta_path().unlink(missing_ok=True)
        logger.warning(
            "MCP OAuth '%s': cached client registration rejected as invalid_client; "
            "removed client.json + meta.json (backup at %s) to force re-registration",
            self._server_name, backup.name,
        )
        return True

    def has_cached_tokens(self) -> bool:
        """Return True if we have tokens on disk (may be expired)."""
        return self._tokens_path().exists()


# ---------------------------------------------------------------------------
# Callback handler factory -- each invocation gets its own result dict
# ---------------------------------------------------------------------------


def _make_callback_handler() -> tuple[type, dict]:
    """Create a per-flow callback HTTP handler class with its own result dict.

    Returns ``(HandlerClass, result_dict)`` where *result_dict* is a mutable
    dict that the handler writes ``auth_code`` and ``state`` into when the
    OAuth redirect arrives.  Each call returns a fresh pair so concurrent
    flows don't stomp on each other.
    """
    result: dict[str, Any] = {"auth_code": None, "state": None, "error": None}

    class _Handler(BaseHTTPRequestHandler):
        def do_GET(self) -> None:  # noqa: N802
            params = parse_qs(urlparse(self.path).query)
            code = params.get("code", [None])[0]
            state = params.get("state", [None])[0]
            error = params.get("error", [None])[0]

            result["auth_code"] = code
            result["state"] = state
            result["error"] = error

            body = (
                "<html><body><h2>Authorization Successful</h2>"
                "<p>You can close this tab and return to Hermes.</p></body></html>"
            ) if code else (
                "<html><body><h2>Authorization Failed</h2>"
                f"<p>Error: {error or 'unknown'}</p></body></html>"
            )
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(body.encode())

        def log_message(self, fmt: str, *args: Any) -> None:
            logger.debug("OAuth callback: %s", fmt % args)

    return _Handler, result


# ---------------------------------------------------------------------------
# Async redirect + callback handlers for OAuthClientProvider
# ---------------------------------------------------------------------------


def _make_redirect_handler(port: int, redirect_uri: str | None = None):
    """Return a redirect handler closure that closes over the given port.

    Using a closure instead of reading the module-level ``_oauth_port`` avoids
    cross-server state pollution when multiple MCP servers run OAuth
    concurrently (fixes #44588).

    ``redirect_uri`` is the configured proxy callback (e.g. a Tailscale Funnel
    URL), or ``None`` for the loopback default. It tailors the remote-session
    hint: a proxied callback reaches this machine on its own, so the loopback
    SSH-tunnel guidance would be misleading.
    """
    async def _redirect_handler(authorization_url: str) -> None:
        """Show the authorization URL to the user.

        Opens the browser automatically when possible; always prints the URL
        as a fallback for headless/SSH/gateway environments.
        """
        from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow

        dashboard_flow = get_dashboard_oauth_flow()
        if dashboard_flow is not None:
            await dashboard_flow.publish_authorization_url(authorization_url)
            return

        # Fail fast at the authorization boundary in non-interactive contexts
        # (systemd gateway, cron, background MCP discovery). A cached-but-unusable
        # token (expired/revoked, refresh rejected) makes the SDK fall through to
        # the authorization-code flow even though build_oauth_auth's token-file
        # guard passed. Without this check we would print a URL and launch a
        # browser flow no operator can complete, then block in _wait_for_callback
        # for the full timeout. Raise before launching so gateway adapters start
        # promptly and the caller can skip this server with an actionable warning.
        # This intentionally re-checks interactivity here rather than trusting the
        # token-file existence guard alone. See #57836.
        _raise_if_non_interactive(
            "MCP OAuth requires browser authorization but no interactive "
            "session is available (non-interactive/background context)."
        )

        msg = (
            f"\n  MCP OAuth: authorization required.\n"
            f"  Open this URL in your browser:\n\n"
            f"    {authorization_url}\n"
        )
        print(msg, file=sys.stderr)

        on_ssh = bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"))
        if on_ssh and redirect_uri:
            # A configured proxy callback (e.g. Tailscale Funnel) forwards the
            # redirect to the listener on this machine, so no tunnel/paste is needed.
            print(
                f"  Remote session detected. After you authorize, the provider redirects to\n"
                f"    {redirect_uri}\n"
                f"  which forwards to the callback listener on this machine — no SSH tunnel needed.\n",
                file=sys.stderr,
            )
        elif on_ssh and port:
            # Loopback default: the provider redirects to
            # http://127.0.0.1:<port>/callback, which reaches the callback server on
            # the *remote* machine — not the user's local machine where the browser
            # opened. Two ways out: paste the redirect URL back (default fallback,
            # offered by _wait_for_callback on interactive TTYs), or set up an SSH
            # port forward so the redirect tunnels through.
            print(
                f"  Remote session detected. After you authorize, the provider redirects to\n"
                f"    http://127.0.0.1:{port}/callback\n"
                f"  which only the listener on THIS machine can receive. Two options:\n"
                f"\n"
                f"    1. Easiest — when your browser shows a connection error after\n"
                f"       authorizing, copy the full URL from the address bar and paste\n"
                f"       it at the prompt below. The pasted ``code=...&state=...`` is\n"
                f"       enough to complete the flow.\n"
                f"\n"
                f"    2. Or forward the port first in a separate terminal:\n"
                f"         ssh -N -L {port}:127.0.0.1:{port} <user>@<this-host>\n"
                f"       then open the URL above and let it redirect normally.\n"
                f"\n"
                f"  See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n",
                file=sys.stderr,
            )

        if _can_open_browser():
            try:
                opened = webbrowser.open(authorization_url)
                if opened:
                    print("  (Browser opened automatically.)\n", file=sys.stderr)
                else:
                    print("  (Could not open browser — please open the URL manually.)\n", file=sys.stderr)
            except Exception:
                print("  (Could not open browser — please open the URL manually.)\n", file=sys.stderr)
        else:
            print("  (Headless environment detected — open the URL manually.)\n", file=sys.stderr)

    return _redirect_handler


async def _wait_for_callback() -> tuple[str, str | None]:
    """Wait for the OAuth callback on the legacy module-level port.

    Kept for backwards compatibility with callers that never went through
    :func:`build_oauth_auth`'s per-flow wiring. New code paths receive a
    per-flow waiter from :func:`_make_callback_waiter` so concurrent OAuth
    flows cannot cross ports (#34260).

    Raises:
        RuntimeError: If ``_oauth_port`` has not been set, which would indicate
            that ``build_oauth_auth`` was skipped — the asserting form below
            was a silent bug when running Python with ``-O``/``-OO``.
    """
    if _oauth_port is None:
        raise RuntimeError(
            "OAuth callback port not set — build_oauth_auth must be called "
            "before _wait_for_oauth_callback"
        )
    return await _make_callback_waiter(_oauth_port)()


def _make_callback_waiter(port: int):
    """Return a callback waiter bound to a single OAuth flow's port.

    Closing over the port (instead of reading the module-level
    ``_oauth_port``) keeps concurrent OAuth flows isolated: flow A's waiter
    listens on flow A's port even when flow B's ``_configure_callback_port``
    overwrites the legacy global afterwards (#34260, the callback-side
    sibling of the #44588 redirect-handler fix).

    The waiter polls for the redirect without blocking the event loop. On an
    interactive TTY it races the HTTP listener against a stdin paste fallback
    so users without an SSH tunnel can paste the redirect URL (or just the
    ``code=...&state=...`` query string) from a browser on another machine.

    Raises (when awaited):
        OAuthNonInteractiveError: If the callback times out (no user present
            to complete the browser auth), or in non-interactive contexts.
    """

    async def _wait() -> tuple[str, str | None]:
        from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow

        dashboard_flow = get_dashboard_oauth_flow()
        if dashboard_flow is not None:
            return await dashboard_flow.wait_for_callback()

        # Reject before binding the callback listener in non-interactive
        # contexts. Reaching here means the SDK entered the authorization-code
        # flow (a valid or refreshable token would never call the callback
        # handler), so a cached token file is present but unusable. Binding the
        # listener here would block for the full 300s timeout and — on the next
        # connection retry — collide with the still-bound/TIME_WAIT port,
        # surfacing as ``OSError: [Errno 98] Address already in use``. Failing
        # fast keeps gateway startup independent of an unusable optional MCP
        # server. This guard holds "regardless of whether a token file exists"
        # — the point the build_oauth_auth token-file guard cannot cover.
        # See #57836.
        _raise_if_non_interactive(
            "OAuth callback requires an interactive session but none is "
            "available (non-interactive/background context); skipping browser "
            "authorization without binding a callback listener."
        )

        handler_cls, result = _make_callback_handler()

        # Start a temporary server on this flow's port, adopting the socket
        # reserved at port-selection time when one exists. Holding the bound
        # socket from _reserve_callback_port() until here closes the TOCTOU
        # window where another process could steal the port between selection
        # and bind (#22161). allow_reuse_address is set BEFORE binding (setting
        # it after the constructor has already bound is a no-op) so a lingering
        # TIME_WAIT socket from a previous flow cannot block the next one
        # (#44590).
        try:
            server = HTTPServer(
                ("127.0.0.1", port), handler_cls, bind_and_activate=False
            )
            reserved = _reserved_sockets.pop(port, None)
            if reserved is not None:
                # Adopt the reserved (already bound) socket and start listening.
                server.socket.close()
                server.socket = reserved
                server.server_address = reserved.getsockname()
                server.server_activate()
            else:
                server.allow_reuse_address = True
                server.server_bind()
                server.server_activate()
        except OSError as exc:
            # The loopback callback port is genuinely in use: a concurrent OAuth
            # flow, a leftover listener, or a fixed `oauth.redirect_port` that
            # collided. build_oauth_auth does not start its own callback server,
            # so there is nothing to poll here; surface a clear, actionable error
            # instead of a misleading "timed out".
            raise OAuthNonInteractiveError(
                f"OAuth callback port {port} is already in use ({exc}). "
                "Close any other in-progress login, or set a free `oauth.redirect_port` "
                "in the server config, then retry."
            ) from exc

        server_thread = threading.Thread(target=server.handle_request, daemon=True)
        server_thread.start()

        # Optional paste-fallback thread: only on interactive TTYs. Reads one
        # line from stdin and writes the parsed code/state into the shared
        # result dict. The HTTP listener and this thread race for the result;
        # whichever fills it first wins.
        paste_thread: threading.Thread | None = None
        if _is_interactive():
            print(
                "\n  Or paste the redirect URL here (or the ``?code=...&state=...`` "
                "portion) and press Enter. Type ``skip`` + Enter to continue "
                "without this server:",
                file=sys.stderr,
                flush=True,
            )
            paste_thread = threading.Thread(
                target=_paste_callback_reader, args=(result,), daemon=True
            )
            paste_thread.start()

        timeout = 300.0
        poll_interval = 0.5
        elapsed = 0.0
        try:
            while elapsed < timeout:
                if result["auth_code"] is not None or result["error"] is not None:
                    break
                await asyncio.sleep(poll_interval)
                elapsed += poll_interval
        finally:
            server.server_close()

        if result["error"] == _USER_SKIPPED_SENTINEL:
            raise OAuthNonInteractiveError("user_skipped")
        if result["error"]:
            raise RuntimeError(f"OAuth authorization failed: {result['error']}")
        if result["auth_code"] is None:
            raise OAuthNonInteractiveError(
                "OAuth callback timed out — no authorization code received. "
                "Ensure you completed the browser authorization flow."
            )

        return result["auth_code"], result["state"]

    return _wait


def _paste_callback_reader(result: dict) -> None:
    """Read one line from stdin, parse it as an OAuth redirect, write to result.

    Accepts any of:
      - Full redirect URL: ``http://127.0.0.1:37949/callback?code=...&state=...``
      - The provider's own callback URL: ``https://mcp.example.com/callback?code=...&state=...``
      - Just the query string: ``?code=...&state=...`` or ``code=...&state=...``
      - A skip token (``skip``, ``cancel``, ``s``, ``n``, ``no``, ``q``, ``quit``)
        — exits the OAuth flow cleanly without auth. Caller raises
        :class:`OAuthNonInteractiveError` so MCP connection setup treats this
        as a non-fatal "user opted out" and continues without that server.

    Failures to parse, EOF, or interrupts are swallowed — this is best-effort
    fallback alongside the HTTP listener, which remains the primary path.
    """
    try:
        line = sys.stdin.readline()
    except (KeyboardInterrupt, OSError, ValueError):
        return
    if not line:
        return  # EOF
    line = line.strip()
    if not line:
        return

    # Skip if HTTP listener already won.
    if result.get("auth_code") is not None or result.get("error") is not None:
        return

    # Skip token: user explicitly opted out of authorization. Mark the
    # result with a sentinel error string that _wait_for_callback maps
    # to OAuthNonInteractiveError (already handled by mcp_tool.py as a
    # non-fatal "skip this server and continue startup" path).
    if line.lower() in _SKIP_TOKENS:
        if result.get("auth_code") is not None or result.get("error") is not None:
            return
        result["error"] = _USER_SKIPPED_SENTINEL
        print(
            "  OAuth skipped. Run `hermes mcp login <server>` later to "
            "authenticate, or set ``enabled: false`` on that server in "
            "config.yaml to disable persistently.",
            file=sys.stderr,
        )
        return

    # Strip a leading "?" if user pasted just a query string.
    query = line
    if "?" in line:
        # Either a full URL or "?code=...". Take everything after the first "?".
        query = line.split("?", 1)[1]
    if query.startswith("?"):
        query = query[1:]

    try:
        params = parse_qs(query)
    except (ValueError, TypeError):
        print(
            "  Could not parse pasted input as an OAuth redirect — ignoring.",
            file=sys.stderr,
        )
        return

    code = params.get("code", [None])[0]
    state = params.get("state", [None])[0]
    error = params.get("error", [None])[0]

    if not code and not error:
        print(
            "  Pasted input did not contain ``code=`` or ``error=`` — ignoring.",
            file=sys.stderr,
        )
        return

    # One more race-check before writing.
    if result.get("auth_code") is not None or result.get("error") is not None:
        return

    result["auth_code"] = code
    result["state"] = state
    result["error"] = error
    if code:
        print("  Got authorization code from paste — completing flow.", file=sys.stderr)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def remove_oauth_tokens(
    server_name: str,
    *,
    hermes_home: str | Path | None = None,
) -> None:
    """Delete stored OAuth tokens and client info for a server."""
    storage = HermesTokenStorage(server_name, hermes_home=hermes_home)
    storage.remove()
    logger.info("OAuth tokens removed for '%s'", server_name)


# ---------------------------------------------------------------------------
# Extracted helpers (Task 3 of MCP OAuth consolidation)
#
# These compose into ``build_oauth_auth`` below, and are also used by
# ``tools.mcp_oauth_manager.MCPOAuthManager._build_provider`` so the two
# construction paths share one implementation.
# ---------------------------------------------------------------------------


def _configure_callback_port(
    cfg: dict,
    storage: "HermesTokenStorage | None" = None,
) -> int:
    """Pick or validate the OAuth callback port.

    Stores the resolved port into ``cfg['_resolved_port']`` so sibling
    helpers (and the manager) can read it from the same dict. Returns the
    resolved port.

    Port choice precedence:
    1. explicit ``oauth.redirect_port`` config
    2. cached client registration redirect URI port
    3. newly allocated free port

    NOTE: also sets the legacy module-level ``_oauth_port`` so existing
    calls to ``_wait_for_callback`` keep working. The legacy global is
    the root cause of issue #5344 (port collision on concurrent OAuth
    flows); replacing it with a ContextVar is out of scope for this
    consolidation PR.
    """
    global _oauth_port
    from tools.mcp_dashboard_oauth import get_dashboard_oauth_flow

    dashboard_flow = get_dashboard_oauth_flow()
    if dashboard_flow is not None:
        cfg["_resolved_port"] = 0
        cfg["redirect_uri"] = cfg.get("redirect_uri") or dashboard_flow.redirect_uri
        return 0
    cached_redirect_uri = _cached_redirect_uri(storage)
    if not cfg.get("redirect_uri") and cached_redirect_uri:
        cfg["redirect_uri"] = cached_redirect_uri
        cfg["_resolved_port"] = 0
        return 0
    requested = int(cfg.get("redirect_port", 0))
    # Precedence: explicit config port → cached client-registration port →
    # fresh ephemeral port. The cached port keeps re-auth consistent with the
    # redirect URI pinned at dynamic client registration (providers reject a
    # mismatched URI). Only a truly fresh ephemeral pick goes through
    # _reserve_callback_port(), which keeps the socket bound until
    # _wait_for_callback adopts it — closing the select→bind TOCTOU race
    # (#22161). Explicit and cached ports are fixed, known values and bind
    # via the reuse_address path instead.
    port = requested or _cached_redirect_port(storage) or _reserve_callback_port()
    cfg["_resolved_port"] = port
    _oauth_port = port  # legacy consumer: _wait_for_callback reads this
    return port


def _resolve_redirect_uri(cfg: dict, port: int) -> str:
    """Resolve the OAuth callback URL: configured ``redirect_uri`` or loopback.

    A configured ``redirect_uri`` lets the callback go through a proxy (e.g. a
    Tailscale Funnel exposing a public HTTPS URL that forwards to localhost);
    otherwise we default to ``http://<redirect_host>:<port>/callback``. An empty
    value is treated as unset. Both the client metadata and any pre-registered
    client info must derive the redirect_uri here so they stay identical — a
    mismatch makes the authorization server reject the callback.

    ``redirect_host`` (default ``127.0.0.1``) tweaks only the hostname of the
    loopback callback. Some providers' WAFs (e.g. Reclaim.ai's AWS API Gateway)
    reject any authorize request whose query string contains a literal
    ``127.0.0.1``, returning ``{"message":"Forbidden"}``; ``redirect_host:
    localhost`` works around that. The callback listener still binds
    ``127.0.0.1`` either way.
    """
    configured = cfg.get("redirect_uri")
    if configured:
        return configured
    host = cfg.get("redirect_host") or "127.0.0.1"
    return f"http://{host}:{port}/callback"


def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata":
    """Build OAuthClientMetadata from the oauth config dict.

    Requires ``cfg['_resolved_port']`` to have been populated by
    :func:`_configure_callback_port` first.
    """
    port = cfg.get("_resolved_port")
    if port is None:
        raise ValueError(
            "_configure_callback_port() must be called before _build_client_metadata()"
        )
    client_name = cfg.get("client_name", "Hermes Agent")
    scope = cfg.get("scope")
    redirect_uri = _resolve_redirect_uri(cfg, port)

    metadata_kwargs: dict[str, Any] = {
        "client_name": client_name,
        "redirect_uris": [AnyUrl(redirect_uri)],
        "grant_types": ["authorization_code", "refresh_token"],
        "response_types": ["code"],
        "token_endpoint_auth_method": "none",
    }
    if scope:
        metadata_kwargs["scope"] = scope
    if cfg.get("client_secret"):
        metadata_kwargs["token_endpoint_auth_method"] = "client_secret_post"

    return OAuthClientMetadata.model_validate(metadata_kwargs)


def _maybe_preregister_client(
    storage: "HermesTokenStorage",
    cfg: dict,
    client_metadata: "OAuthClientMetadata",
) -> None:
    """If cfg has a pre-registered client_id, persist it to storage."""
    client_id = cfg.get("client_id")
    if not client_id:
        return
    port = cfg["_resolved_port"]
    redirect_uri = _resolve_redirect_uri(cfg, port)

    info_dict: dict[str, Any] = {
        "client_id": client_id,
        "redirect_uris": [redirect_uri],
        "grant_types": client_metadata.grant_types,
        "response_types": client_metadata.response_types,
        "token_endpoint_auth_method": client_metadata.token_endpoint_auth_method,
    }
    if cfg.get("client_secret"):
        info_dict["client_secret"] = cfg["client_secret"]
    if cfg.get("client_name"):
        info_dict["client_name"] = cfg["client_name"]
    if cfg.get("scope"):
        info_dict["scope"] = cfg["scope"]

    client_info = OAuthClientInformationFull.model_validate(info_dict)
    _write_json(storage._client_info_path(), client_info.model_dump(mode="json", exclude_none=True))
    logger.debug("Pre-registered client_id=%s for '%s'", client_id, storage._server_name)


def build_oauth_auth(
    server_name: str,
    server_url: str,
    oauth_config: dict | None = None,
) -> "OAuthClientProvider | None":
    """Build an ``httpx.Auth``-compatible OAuth handler for an MCP server.

    Public API preserved for backwards compatibility. New code should use
    :func:`tools.mcp_oauth_manager.get_manager` so OAuth state is shared
    across config-time, runtime, and reconnect paths.

    Args:
        server_name: Server key in mcp_servers config (used for storage).
        server_url: MCP server endpoint URL.
        oauth_config: Optional dict from the ``oauth:`` block in config.yaml.

    Returns:
        An ``OAuthClientProvider`` instance, or None if the MCP SDK lacks
        OAuth support.
    """
    if not _OAUTH_AVAILABLE:
        logger.warning(
            "MCP OAuth requested for '%s' but SDK auth types are not available. "
            "Install with: pip install 'mcp>=1.26.0'",
            server_name,
        )
        return None

    cfg = dict(oauth_config or {})  # copy — we mutate _resolved_port
    storage = HermesTokenStorage(server_name)

    if not _is_interactive() and not storage.has_cached_tokens():
        raise OAuthNonInteractiveError(
            "MCP OAuth for "
            f"'{server_name}': non-interactive environment and no cached tokens "
            "found. The OAuth flow requires browser authorization. Run "
            f"`hermes mcp login {server_name}` interactively first to complete "
            "initial authorization, then cached tokens will be reused."
        )

    _configure_callback_port(cfg, storage)
    client_metadata = _build_client_metadata(cfg)
    _maybe_preregister_client(storage, cfg, client_metadata)

    # Use closure factories to avoid global state pollution (#44588, #34260).
    resolved_port = cfg.get("_resolved_port", _oauth_port)
    redirect_handler = _make_redirect_handler(
        resolved_port, redirect_uri=cfg.get("redirect_uri") or None
    )
    callback_handler = _make_callback_waiter(resolved_port)

    return OAuthClientProvider(
        server_url=server_url,
        client_metadata=client_metadata,
        storage=storage,
        redirect_handler=redirect_handler,
        callback_handler=callback_handler,
        timeout=float(cfg.get("timeout", 300)),
    )
