"""Dashboard OAuth/login-status helpers: provider catalog, per-provider device pollers,
Anthropic/Copilot/Claude-Code status probes.
"""

import contextlib
import logging
import functools
import os
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional

# Same logger the code used before extraction (record parity).
_log = logging.getLogger("hermes_cli.web_server")


_LOGGED_OUT: Dict[str, Any] = {"logged_in": False, "source": None}


def _truncate_token(value: Optional[str], visible: int = 6) -> str:
    """``…XXXXXX`` (last N chars) for UI display. JWTs show only the tail of the signature
    segment. A callable (Azure Foundry Entra-ID bearer provider) is NEVER invoked — it yields
    the ``<entra-id-bearer>`` placeholder."""
    if not value:
        return ""
    if callable(value) and not isinstance(value, str):
        return "<entra-id-bearer>"
    s = str(value)
    if s.count(".") >= 2:
        s = s.rsplit(".", 1)[-1]
    return s if len(s) <= visible else f"…{s[-visible:]}"


def _token_status(source: str, source_label: str, creds: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "logged_in": True, "source": source, "source_label": source_label,
        "token_preview": _truncate_token(creds.get("accessToken")),
        "expires_at": creds.get("expiresAt"), "has_refresh_token": bool(creds.get("refreshToken")),
    }


def _anthropic_oauth_status() -> Dict[str, Any]:
    """Status for the "Anthropic Account" card: Hermes-managed PKCE file first, then the
    registry-ordered env vars (process env — where Bitwarden-sourced secrets land — then .env).

    Claude Code's ``~/.claude/.credentials.json`` is deliberately NOT read here; it has its own
    ``claude-code`` entry, and counting it here would shadow a real ANTHROPIC_API_KEY.
    """
    try:
        from agent.anthropic_credentials import read_hermes_oauth_credentials, _get_hermes_oauth_file
        hermes_creds = read_hermes_oauth_credentials()
    except Exception:
        hermes_creds = None
    if hermes_creds and hermes_creds.get("accessToken"):
        return _token_status("hermes_pkce", f"Hermes PKCE ({_get_hermes_oauth_file()})", hermes_creds)

    env_var_order: tuple = ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
    try:
        from hermes_cli.auth import PROVIDER_REGISTRY
        env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars
    except (ImportError, KeyError):
        pass
    from hermes_cli.config import get_env_value
    from hermes_cli.env_loader import format_secret_source_suffix
    for var in env_var_order:
        value = get_env_value(var) or os.getenv(var)
        if value:
            return {
                "logged_in": True, "source": "env_var", "source_label": f"{var}{format_secret_source_suffix(var)}",
                "token_preview": _truncate_token(value), "expires_at": None, "has_refresh_token": False,
            }
    return dict(_LOGGED_OUT)


def _claude_code_only_status() -> Dict[str, Any]:
    """Claude Code CLI credentials as their own entry, independent of the Anthropic card.

    Connected follows the same local validity gate as Anthropic resolution. A
    persisted access token that has already expired is not a usable login.
    """
    try:
        from agent.anthropic_credentials import (
            is_claude_code_token_valid,
            read_claude_code_credentials,
        )
        creds = read_claude_code_credentials()
        if creds and is_claude_code_token_valid(creds):
            return _token_status("claude_code_cli", "~/.claude/.credentials.json", creds)
    except Exception:
        pass
    return dict(_LOGGED_OUT)


def _copilot_acp_status() -> Dict[str, Any]:
    """Status for copilot-acp. ``logged_in`` only on positive evidence (env token or known on-disk
    store); the CLI may hold its session in an OS keychain Hermes can't read, so the unverified
    state reads "managed by the Copilot CLI" — never signed out."""
    try:
        from hermes_cli.auth import get_external_process_provider_status
        status = get_external_process_provider_status("copilot-acp") or {}
    except Exception:
        status = {}
    verified = bool(status.get("auth_verified"))
    configured = bool(status.get("configured"))
    if verified:
        source_label = status.get("auth_source") or "Copilot credentials detected"
    elif configured:
        found = status.get("resolved_command") or status.get("command") or "copilot"
        source_label = f"Managed by the GitHub Copilot CLI ({found})"
    else:
        source_label = "GitHub Copilot CLI not found on PATH"
    return {
        "logged_in": verified, "source": "copilot_cli", "source_label": source_label, "token_preview": None,
        "expires_at": None, "has_refresh_token": False, "configured": configured,
    }


def _external_process_cli_command(provider_id: str, default: str) -> str:
    """Render an external-process provider's sign-in command with the CLI actually configured
    (``HERMES_COPILOT_ACP_COMMAND`` / ``COPILOT_CLI_PATH``); others get ``default`` untouched."""
    try:
        from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status
        pconfig = PROVIDER_REGISTRY.get(provider_id)
        if not pconfig or pconfig.auth_type != "external_process":
            return default
        status = get_external_process_provider_status(provider_id) or {}
        command = str(status.get("command") or "").strip()
        if command:
            parts = default.split(" ", 1)
            tail = f" {parts[1]}" if len(parts) > 1 else ""
            return f"{command}{tail}"
    except Exception:
        pass
    return default


# Hand-tuned OAuth/account cards: the bits not derivable from the unified provider catalog
# (``flow``, ``status_fn``, ``cli_command``, display order). OVERRIDE BASE for
# ``_build_oauth_catalog()``, which unions them with every accounts-tab provider so new
# providers appear automatically. Also carries two non-catalog rows the Accounts tab needs:
# the Anthropic credential-status card and the synthetic ``claude-code`` row.
# ``flow``: ``device_code`` = show code + URL + poll; ``external`` = delegated to a terminal/CLI.
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
    # status_fn None → dispatched via auth.get_<provider>_auth_status.
    {"id": "nous", "name": "Nous Portal", "flow": "device_code", "cli_command": "hermes auth add nous",
     "docs_url": "https://portal.nousresearch.com", "status_fn": None},
    {"id": "openai-codex", "name": "ChatGPT or Codex Subscription", "flow": "device_code",
     "cli_command": "hermes auth add openai-codex", "docs_url": "https://platform.openai.com/docs",
     "status_fn": None},
    {"id": "qwen-oauth", "name": "Qwen (via Qwen CLI)", "flow": "external",
     "cli_command": "hermes auth add qwen-oauth", "docs_url": "https://github.com/QwenLM/qwen-code",
     "status_fn": None},
    # Structurally device-code (verification URI + user code + token polling) with a PKCE
    # code-binding extension that doesn't change the operator UX.
    {"id": "minimax-oauth", "name": "MiniMax (OAuth)", "flow": "device_code",
     "cli_command": "hermes auth add minimax-oauth", "docs_url": "https://www.minimax.io", "status_fn": None},
    # Device code works in remote shells/containers without a reachable 127.0.0.1 callback.
    {"id": "xai-oauth", "name": "xAI Grok OAuth (SuperGrok / Premium+)", "flow": "device_code",
     "cli_command": "hermes auth add xai-oauth",
     "docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth", "status_fn": None},
    # `copilot login` is the non-interactive subcommand; `copilot /login` is not valid
    # (slash-commands only exist inside an interactive session).
    {"id": "copilot-acp", "name": "GitHub Copilot (ACP)", "flow": "external", "cli_command": "copilot login",
     "docs_url": "https://docs.github.com/en/copilot", "status_fn": _copilot_acp_status},
    # Anthropic / Claude entries sit at the bottom. Deliberately flow == "external": an
    # in-dashboard Connect button would let a scriptable HTTP endpoint mint Claude Pro/Max
    # subscription tokens outside Anthropic's own client, against its OAuth usage policies.
    # Login works via the terminal (`hermes auth add anthropic`) or a plain API key.
    {"id": "anthropic", "name": "Anthropic Account", "flow": "external", "cli_command": "hermes auth add anthropic",
     "docs_url": "https://docs.claude.com/en/api/getting-started", "status_fn": _anthropic_oauth_status},
    {"id": "claude-code", "name": "Anthropic OAuth: Required Extra Usage Credits to Use Subscription",
     "flow": "external", "cli_command": "claude setup-token",
     "docs_url": "https://docs.claude.com/en/docs/claude-code", "status_fn": _claude_code_only_status},
)
_oauth_sessions: Dict[str, Dict[str, Any]] = {}
_oauth_sessions_lock = threading.Lock()


def _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
    requested = (profile or "").strip()
    if not requested or requested.lower() == "current":
        return None
    return requested


def _oauth_poller(label: str):
    """Wrap a device-code poller body ``fn(session_id, sess)``: vanished session is a no-op,
    success marks ``approved``, any exception records ``error`` + ``error_message`` on the
    session instead of raising (the thread has no caller; the dashboard reads the status)."""
    def deco(fn):
        @functools.wraps(fn)
        def poller(session_id: str) -> None:
            with _oauth_sessions_lock:
                sess = _oauth_sessions.get(session_id)
            if not sess:
                return
            try:
                fn(session_id, sess)
                with _oauth_sessions_lock:
                    # A body that already settled the session (a sign-in the user declined in the
                    # browser is ``denied`` with a ``reason``) keeps its verdict.
                    settled = sess["status"] != "pending"
                    if not settled:
                        sess["status"] = "approved"
                if settled:
                    _log.info("oauth/device: %s login ended %s (session=%s)", label, sess["status"], session_id)
                else:
                    _log.info("oauth/device: %s login completed (session=%s)", label, session_id)
            except Exception as e:
                _log.warning("%s device-code poll failed (session=%s): %s", label, session_id, e)
                with _oauth_sessions_lock:
                    sess["status"] = "error"
                    sess["error_message"] = str(e)
        return poller
    return deco


def _record_sign_in_state(sess: Dict[str, Any], state: Any) -> None:
    """Write one ``anon_auth.SignInState`` onto the dashboard session, under the sessions lock.

    The whole desktop mapping lives here: the state carries its own copy, so nothing below turns a
    reason into a string. ``completed`` deliberately leaves ``status`` on ``"pending"`` so the
    :func:`_oauth_poller` wrapper stamps ``"approved"`` when the poller returns.
    """
    kind = getattr(state, "kind", "")
    if kind in ("code", "waiting"):
        return          # the start route already published the code
    with _oauth_sessions_lock:
        if kind == "completed":
            sess["account_email"] = state.email or None
            # None when the config was left on the user's own model, as the poll route documents.
            sess["model"] = state.model if state.model_changed else None
            return
        if kind == "declined":
            sess["status"], sess["reason"] = "denied", "user_declined"
            sess["error_message"] = state.copy
            return
        if kind == "timed_out":
            sess["status"], sess["reason"] = "error", "timeout"
            # The enriched device-auth guidance, which the dashboard has room for.
            sess["error_message"] = state.detail or state.copy
            return
        if kind == "retired":
            sess["status"], sess["reason"] = "error", "account_retired"
            sess["error_message"] = state.copy
            return
        if kind == "superseded":
            # Two producers, two screens: the user's own DELETE is a cancellation, while a sign-in
            # started somewhere else (a chat, the terminal) voided this code and is an error the
            # renderer has a dedicated screen for.
            if sess.get("cancelled"):
                sess["status"] = "cancelled"
            else:
                sess["status"], sess["reason"] = "error", "superseded"
                sess["error_message"] = state.copy
            return
        if kind == "failed":
            sess["status"] = "error"
            sess["reason"] = state.reason or "error"
            sess["error_message"] = state.copy    # the chat form: no raw exception reaches the UI
            # Whether a later attempt can succeed, and the wait the service named (seconds).
            sess["retryable"] = bool(getattr(state, "retryable", False))
            sess["retry_after"] = int(getattr(state, "retry_after", 0) or 0)
            return
        # already_signed_in / unavailable: the start route refuses these, so this is unreachable
        # through the dashboard; record rather than crash.
        sess["status"] = "error"
        sess["reason"] = kind or "error"
        sess["error_message"] = state.copy


@_oauth_poller("nous")
def _nous_promotion_poller(session_id: str, sess: Dict[str, Any]) -> None:
    """Drain the sign-in the start route began: one shared flow, rendered onto the session.

    The generator was created and advanced to its ``Code`` state by ``_start_nous_device_code``, so
    it is already holding the transfer's codes and its HTTP client. Nothing here is wrapped in
    ``_profile_scope``: that context manager holds a process-global lock and swaps module
    attributes across its ``yield``, and this loop can last the sign-in code's whole expiry. The
    generator scopes its own short config/auth-store sections instead.
    """
    gen = sess.get("_sign_in")
    if gen is None:
        return
    try:
        for state in gen:
            _record_sign_in_state(sess, state)
    finally:
        with contextlib.suppress(Exception):
            gen.close()     # unwinds the suspended HTTP client if we leave early


@_oauth_poller("nous")
def _nous_plain_poller(session_id: str, sess: Dict[str, Any]) -> None:
    """Background poller for a plain Nous device-code login (no free-tier identity to transfer).

    A sign-in that carries the free tier's connectors runs through ``anon_auth.run_sign_in`` and
    ``_nous_promotion_poller`` instead; this is the "connect another Nous account" path.
    """
    from hermes_cli.web_server_profiles import _profile_scope
    from hermes_cli.auth import _poll_for_token, persist_nous_credentials, refresh_nous_oauth_from_state
    from hermes_cli import anon_auth
    import httpx
    portal_base_url, client_id = sess["portal_base_url"], sess["client_id"]

    def _cancelled() -> bool:
        # The user abandoned this sign-in (DELETE /sessions/{id}) while this thread was blocked
        # on the portal: nothing it learns afterwards may reach the auth store.
        with _oauth_sessions_lock:
            if sess.get("cancelled"):
                sess["status"] = "cancelled"
                return True
            return False

    with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
        token_data = _poll_for_token(
            client=client, portal_base_url=portal_base_url, client_id=client_id,
            device_code=sess["device_code"], expires_in=max(60, int(sess["expires_at"] - time.time())),
            poll_interval=sess["interval"],
        )
    if _cancelled():
        return
    # Same post-processing as _nous_device_code_login (validate/refresh JWT)
    now = datetime.now(timezone.utc)
    token_ttl = int(token_data.get("expires_in") or 0)
    auth_state = {
        "portal_base_url": portal_base_url,
        "inference_base_url": token_data.get("inference_base_url"),
        "client_id": client_id,
        "scope": token_data.get("scope") or sess.get("scope"),
        "token_type": token_data.get("token_type", "Bearer"),
        "access_token": token_data["access_token"],
        "refresh_token": token_data.get("refresh_token"),
        "obtained_at": now.isoformat(),
        "expires_at": (
            datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat()
            if token_ttl else None
        ),
        "expires_in": token_ttl,
    }
    # The profile comes from the poller's own session dict: a cancel or the 15-minute sweep drops
    # the registry entry, and a lookup by id would then save into the dashboard's launch profile.
    with _profile_scope(sess.get("profile")):
        full_state = refresh_nous_oauth_from_state(auth_state, timeout_seconds=15.0, force_refresh=False)
        # The final cancellation check and the save share the session lock, so a cancel cannot
        # land between them.
        with _oauth_sessions_lock:
            if sess.get("cancelled"):
                sess["status"] = "cancelled"
                return
            persist_nous_credentials(full_state)
        # A config left on the free tier's route by a retired identity still has to move.
        settled = anon_auth.settle_after_upgrade(full_state)
    with _oauth_sessions_lock:
        sess["account_email"] = None
        sess["model"] = settled.get("model") or None


@_oauth_poller("minimax")
def _minimax_poller(session_id: str, sess: Dict[str, Any]) -> None:
    """MiniMax poller: PKCE-style ``code_verifier`` + ``user_code`` instead of Nous's
    ``device_code``. Builds the same auth_state as the CLI's ``_minimax_oauth_login`` and persists
    via ``_minimax_save_auth_state`` so the system ends up as after ``hermes auth add minimax-oauth``.
    Region is fixed to "global" here; cn-region operators use the CLI's ``--region cn``."""
    from hermes_cli.web_server_profiles import _profile_scope
    from hermes_cli.auth import (
        _minimax_poll_token, _minimax_resolve_token_expiry_unix, _minimax_save_auth_state,
        MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE,
    )
    import httpx
    portal_base_url, client_id = sess["portal_base_url"], sess["client_id"]
    with httpx.Client(
        timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True
    ) as client:
        token_data = _minimax_poll_token(
            client=client, portal_base_url=portal_base_url, client_id=client_id,
            user_code=sess["user_code"], code_verifier=sess["code_verifier"],
            expired_in=sess["expired_in_raw"], interval_ms=sess.get("interval_ms"),
        )
    now = datetime.now(timezone.utc)
    expires_at_ts = _minimax_resolve_token_expiry_unix(int(token_data["expired_in"]), now=now)
    auth_state = {
        "provider": "minimax-oauth",
        "region": sess.get("region", "global"),
        "portal_base_url": portal_base_url,
        "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
        "client_id": client_id,
        "scope": MINIMAX_OAUTH_SCOPE,
        "token_type": token_data.get("token_type", "Bearer"),
        "access_token": token_data["access_token"],
        "refresh_token": token_data["refresh_token"],
        "resource_url": token_data.get("resource_url"),
        "obtained_at": now.isoformat(),
        "expires_at": datetime.fromtimestamp(expires_at_ts, tz=timezone.utc).isoformat(),
        "expires_in": max(0, int(expires_at_ts - now.timestamp())),
    }
    with _profile_scope(sess.get("profile")):
        # The cancellation check and the save share the session lock, so a cancel cannot land
        # between them (the same contract as the Nous and Codex savers).
        with _oauth_sessions_lock:
            if sess.get("cancelled"):
                sess["status"] = "cancelled"
                return
            _minimax_save_auth_state(auth_state)


@_oauth_poller("xai")
def _xai_device_poller(session_id: str, sess: Dict[str, Any]) -> None:
    """Background poller for xAI's OAuth device-code flow."""
    from hermes_cli.web_server_profiles import _profile_scope
    import httpx
    from hermes_cli.auth import (
        _save_xai_oauth_tokens, _xai_oauth_discovery, _xai_oauth_poll_device_token,
        mark_provider_active_if_unset, unsuppress_credential_source,
    )

    discovery = _xai_oauth_discovery(20.0)
    with httpx.Client(timeout=httpx.Timeout(20.0), headers={"Accept": "application/json"}) as client:
        token_data = _xai_oauth_poll_device_token(
            client, token_endpoint=discovery["token_endpoint"], device_code=sess["device_code"],
            expires_in=max(60, int(sess["expires_at"] - time.time())), poll_interval=int(sess["interval"]),
        )
    tokens = {
        "access_token": str(token_data.get("access_token", "") or "").strip(),
        "refresh_token": str(token_data.get("refresh_token", "") or "").strip(),
        "id_token": str(token_data.get("id_token", "") or "").strip(),
        "expires_in": token_data.get("expires_in"),
        "token_type": str(token_data.get("token_type") or "Bearer").strip() or "Bearer",
    }
    with _profile_scope(sess.get("profile")), _oauth_sessions_lock:
        # One critical section with the cancel check, as in the Nous and Codex savers.
        if sess.get("cancelled"):
            sess["status"] = "cancelled"
            return
        # set_active=False: persist without hijacking an existing active chat provider.
        _save_xai_oauth_tokens(
            tokens, discovery=discovery, auth_mode="oauth_device_code", set_active=False,
            last_refresh=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        )
        # Mirror `hermes auth add xai-oauth`: first credential may become active; never overwrite.
        mark_provider_active_if_unset("xai-oauth")
        # The singleton write is the source of truth (the pool load seeds it as the canonical
        # ``device_code`` entry). Do NOT add a parallel ``manual:dashboard_*`` pool entry — it
        # duplicates the single-use refresh token and triggers ``refresh_token_reused`` churn.
        # An interactive login is an explicit re-enable, so clear any prior suppression.
        unsuppress_credential_source("xai-oauth", "device_code")
