"""Plugin-provider refresh support for the credential pool (#116408).

A model-provider plugin makes its pooled OAuth rows refreshable by shipping
``ProviderProfile.refresh_credential(entry) -> Mapping | None``. The pool owns
the locking, persistence and failure classification around that hook; the
plugin owns only the token POST. This sibling module keeps that logic out of
``agent/credential_pool.py`` (near the size cap).

Contract (documented in website/docs/developer-guide/model-provider-plugin.md):

* the hook returns a mapping of rotated values — dataclass field names
  (``access_token``, ``refresh_token``, ``expires_at_ms`` …) replace the row's
  fields, every other key (``expires_in``, ``token_type``, ``scope`` — the
  natural token-endpoint shape) lands in ``entry.extra``; ``None``/empty = could not
  rotate and the pool benches the row like a failed refresh POST;
* raising ``AuthError(..., relogin_required=True)`` (or a grant-dead OAuth code)
  is terminal: the row goes DEAD with a WARNING naming ``hermes auth add``;
  any other exception is transient and only benches the row.
"""

from __future__ import annotations

import logging
from dataclasses import fields, replace
from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple

from hermes_cli.auth import _OAUTH_GRANT_DEAD_CODES
from hermes_cli.auth_constants import AuthError

if TYPE_CHECKING:  # pragma: no cover
    from agent.credential_pool import CredentialPool, PooledCredential

logger = logging.getLogger(__name__)


def apply_plugin_refresh_result(entry: "PooledCredential", result: Any) -> "PooledCredential":
    """Merge a ``refresh_credential`` return value into *entry*.

    Field names go through ``dataclasses.replace``; everything else is merged into ``extra``
    (mirroring ``PooledCredential.from_dict``). Before this split a token-endpoint-shaped mapping
    with ``expires_in`` raised ``TypeError`` inside ``replace`` and the pool benched a row whose
    single-use refresh token the server had already rotated — the login was lost.
    """
    if not result:
        return entry
    mapping: Mapping[str, Any] = dict(result)
    field_names = {f.name for f in fields(type(entry))} - {"provider", "extra"}
    field_updates = {k: v for k, v in mapping.items() if k in field_names}
    extra_updates = {k: v for k, v in mapping.items() if k not in field_names and k != "provider"}
    if extra_updates:
        field_updates["extra"] = {**entry.extra, **extra_updates}
    return replace(entry, **field_updates) if field_updates else entry


def is_terminal_plugin_refresh_error(exc: BaseException) -> bool:
    """True when retrying the same plugin refresh token cannot succeed.

    Plugins have no per-provider code table, so the predicate is the structural one every built-in
    flow shares: a structured ``AuthError`` that asks for a re-login, or one carrying a grant-dead
    OAuth code. Transport errors, 429/5xx-shaped ``RuntimeError`` and plain ``AuthError`` without
    that signal stay transient (EXHAUSTED for one cooldown).
    """
    if not isinstance(exc, AuthError):
        return False
    return bool(exc.relogin_required) or (exc.code or "") in _OAUTH_GRANT_DEAD_CODES


def recover_failed_plugin_refresh(
    pool: "CredentialPool", entry: "PooledCredential", exc: Exception,
) -> Tuple[bool, Optional["PooledCredential"]]:
    """Recovery for a plugin hook that raised: adopt a peer's rotation, or quarantine a dead grant.

    Returns ``(handled, result)``; ``handled=False`` means the caller should bench the row as a
    transient failure. A peer process may have rotated the single-use token between our in-lock
    sync and the hook's POST — adopt that pair before classifying the failure.
    """
    from agent.credential_pool import _MARK_OK

    synced = pool._sync_entry_from_pool_store(entry)
    if synced.refresh_token != entry.refresh_token and (synced.access_token or "").strip():
        logger.debug("%s refresh failed but the pool store has newer tokens — adopting", pool.provider)
        return True, pool._adopt(synced, **_MARK_OK)
    if is_terminal_plugin_refresh_error(exc):
        # WARNING, not debug: this is the moment a login is lost. Benching for a TTL would replay
        # the dead token every cooldown at DEBUG with no trace for the user.
        logger.warning(
            "%s refresh token for %s is terminally invalid (%s); the credential leaves rotation. "
            "Re-run 'hermes auth add %s' to sign in again.",
            pool.provider, entry.label or entry.id[:8], exc, pool.provider,
        )
        pool._mark_dead_refresh_grant(entry, exc)
        return True, None
    return False, None
