"""None-mode auto-approve OAuth authorization server (issue #1969).

In ``none`` webhook auth mode the secret webhook URL *is* the credential, so no
bearer is required and the forwarder always returns 200. But claude.ai's
connector onboarding intermittently front-loads OAuth discovery, and because the
component registers no ``/.well-known`` views in none mode, claude.ai falls
through to Home Assistant *core*'s own origin-root
``/.well-known/oauth-authorization-server`` — which advertises
``client_id_metadata_document_supported`` but omits
``token_endpoint_auth_methods_supported: ["none"]`` and has no
``registration_endpoint``. claude.ai then can neither use CIMD nor do dynamic
client registration and shows "Automatic client registration isn't supported…".

This module is the none-mode fix's authorization-server half: a pair of
path-scoped ``OAUTH_BASE`` endpoints that complete OAuth *invisibly* — no login,
no consent — so a connector that does run discovery resolves against our own
corrected documents (served by :mod:`mcp_webhook`) instead of HA core's broken
root doc, and connects with zero HA login:

* ``GET  {OAUTH_BASE}/authorize`` issues a PKCE-bound one-time code and
  immediately 302-redirects back to the client with ``?code=…&state=…`` — no
  page is rendered.
* ``POST {OAUTH_BASE}/token`` exchanges that code (public client, PKCE S256, no
  ``client_secret``) for an opaque access token. The token is *cosmetic* — none
  mode ignores bearers entirely — but is a real random string so a spec-strict
  client is satisfied.

Both views are gated per request off ``hass.data`` (they 404 unless none mode is
the live webhook auth mode), mirroring the discovery views, so a
``none``\\ ↔\\ ``ha_auth`` switch needs no restart. The PKCE code store and the
redirect-URI floor are reused from :mod:`oauth_legacy` rather than copied.

**Open-redirect defence.** ``/authorize`` 302-redirects to a caller-supplied
``redirect_uri`` on the Home Assistant origin, so an unvalidated target would be
an open redirector. On top of :func:`oauth_legacy._is_valid_redirect_uri`'s
scheme/host/port floor, the redirect must EXACTLY match a known MCP callback
(:data:`_AUTOAPPROVE_REDIRECT_ALLOWLIST`). Anything else is a hard 400 (no
redirect). A "same origin as the client_id" rule was deliberately NOT used: the
client_id is fully attacker-controlled, so ``client_id == redirect_uri origin``
still lets an attacker bounce a victim to any site of their choosing (a real
open redirect on a public HA origin). Properly honouring an arbitrary CIMD
client would require fetching the attacker-supplied client_id URL — an SSRF
vector — so the allowlist is both the safe and the simple choice. Add a client's
callback here to support it.
"""

from __future__ import annotations

import secrets
from typing import TYPE_CHECKING, Any

from aiohttp import web
from homeassistant.components.http import HomeAssistantView

from .const import DATA_WEBHOOK, DOMAIN, OAUTH_BASE
from .oauth_legacy import (
    _PKCE_CHALLENGE_RE,
    _TOKEN_RESPONSE_HEADERS,
    ACCESS_TOKEN_TTL,
    PKCECodeStore,
    _is_valid_redirect_uri,
    _issuer_for,
)

if TYPE_CHECKING:
    from homeassistant.core import HomeAssistant


# cfg (hass.data[DOMAIN][DATA_WEBHOOK]) key holding the live AutoApproveProvider.
# Present ONLY in none mode with the remote endpoint enabled; its presence is
# how :func:`mcp_webhook.active_auth_mode` recognises the none-autoapprove live
# mode (mirrors the "resource_server"/"oauth_provider" presence keys).
CFG_AUTOAPPROVE_PROVIDER = "autoapprove_provider"

# TOP-LEVEL hass.data flag recording that the two auto-approve views are bound
# for this HA session. Not under DOMAIN so it survives async_unload_entry's
# teardown — aiohttp cannot unregister a bound view until HA restarts, so the
# views (and this ownership flag) must outlive the config entry (mirrors
# mcp_webhook._OAUTH_VIEWS_REGISTERED_KEY).
_AUTOAPPROVE_VIEWS_REGISTERED_KEY = "ha_mcp_tools_oauth_autoapprove_views_registered"

# Known MCP OAuth callback URLs always accepted as a redirect target even when
# the client_id is not a same-origin URL — claude.ai's connector onboarding
# posts its authorization code here. Exact-match only (never a prefix test, so
# ``https://claude.ai/api/mcp/auth_callback.evil.example`` cannot slip through).
_AUTOAPPROVE_REDIRECT_ALLOWLIST = frozenset(
    {
        "https://claude.ai/api/mcp/auth_callback",
    }
)


def _json_not_found() -> web.Response:
    """404 JSON body used when none-autoapprove is not the live mode."""
    return web.json_response({"error": "not_found"}, status=404)


def _json_error(
    error: str, status: int, description: str | None = None
) -> web.Response:
    """OAuth-style JSON error (RFC 6749 §5.2 shape) with no-store headers."""
    body: dict[str, str] = {"error": error}
    if description is not None:
        body["error_description"] = description
    return web.json_response(body, status=status, headers=_TOKEN_RESPONSE_HEADERS)


def _is_valid_autoapprove_redirect(redirect_uri: str) -> bool:
    """Open-redirect gate for the auto-approve ``/authorize`` view.

    Exact-match allowlist only, on top of
    :func:`oauth_legacy._is_valid_redirect_uri`'s scheme/host/port floor. The
    ``client_id`` is NOT consulted: it is attacker-controlled, so validating the
    redirect against it (even "same origin") does not constrain the redirect
    target to a trusted host. See the module docstring.
    """
    return (
        _is_valid_redirect_uri(redirect_uri)
        and redirect_uri in _AUTOAPPROVE_REDIRECT_ALLOWLIST
    )


def _redirect_with(redirect_uri: str, **params: str) -> web.Response:
    """302 to ``redirect_uri`` with ``params`` merged into its query string."""
    # yarl ships with aiohttp and handles existing-query merging + encoding
    # correctly — safer than hand-rolling (matches oauth_legacy.AuthorizeView).
    import yarl

    url = yarl.URL(redirect_uri).update_query(params)
    return web.Response(status=302, headers={"Location": str(url)})


class AutoApproveProvider:
    """None-mode auto-approve authorization-server state.

    Holds only the PKCE code store shared with :mod:`oauth_legacy`; it owns no
    signing key and no client credentials (the token it issues is cosmetic).
    Constructed per registration and stored in ``cfg`` — the views resolve it
    from ``hass.data`` per request, so a reload minting a fresh provider is
    transparent (no bound view captures the old one, unlike legacy mode).
    """

    def __init__(self) -> None:
        self._code_store = PKCECodeStore()

    def issue_code(self, redirect_uri: str, code_challenge: str) -> str | None:
        """Issue a one-shot PKCE-bound authorization code (see PKCECodeStore)."""
        return self._code_store.issue_code(redirect_uri, code_challenge)

    def consume_code(self, code: str, redirect_uri: str, code_verifier: str) -> bool:
        """Verify PKCE S256 + one-shot consume a code (see PKCECodeStore)."""
        return self._code_store.consume_code(code, redirect_uri, code_verifier)

    @staticmethod
    def issue_access_token() -> str:
        """Mint an opaque access token.

        None mode ignores bearers (the secret webhook URL is the credential),
        so this token grants nothing — but it is a real random string, so a
        spec-strict client that stores/echoes it is satisfied.
        """
        return secrets.token_urlsafe(32)


def _active_autoapprove_provider(hass: HomeAssistant) -> AutoApproveProvider | None:
    """The live none-mode auto-approve provider, or None when it is not live.

    Read live from ``hass.data`` (not captured at view construction) so the
    bound views serve only while none-autoapprove is the active mode and 404
    otherwise — mirrors ``mcp_webhook._active_webhook_id``'s per-request gating.
    """
    domain_data = hass.data.get(DOMAIN)
    if not isinstance(domain_data, dict):
        return None
    cfg = domain_data.get(DATA_WEBHOOK)
    if not isinstance(cfg, dict):
        return None
    provider = cfg.get(CFG_AUTOAPPROVE_PROVIDER)
    return provider if isinstance(provider, AutoApproveProvider) else None


class AutoApproveAuthorizeView(HomeAssistantView):
    """None-mode auto-approve ``/authorize`` — issues a code, 302s, no UI.

    Validates ``response_type=code``, PKCE S256, and the redirect_uri
    open-redirect gate, then issues a PKCE-bound one-time code and redirects
    straight back to the client. No login page and no consent screen render, so
    claude.ai's OAuth flow completes invisibly (issue #1969).

    ACCEPTED RISK (issue #1978): this endpoint is anonymous by design — none
    mode requires zero HA login — so it consults neither the webhook id nor a
    client identity. Anyone who knows the HA origin can therefore fill the
    shared pending-code store (``MAX_PENDING_CODES``) with S256 challenges bound
    to the public claude.ai callback, at which point a *brand-new* connector's
    handshake gets ``temporarily_unavailable`` until those codes expire
    (``AUTH_CODE_TTL``, 5 min). Accepted because it is self-healing, exposes no
    data, and grants no access: completing the flow needs the PKCE verifier the
    attacker never has, and the issued token is cosmetic (none mode ignores
    bearers). The webhook URL itself keeps forwarding throughout — only the rare
    OAuth-discovery fallback for a *first* connect is briefly delayed.
    """

    requires_auth = False
    cors_allowed = True
    url = f"{OAUTH_BASE}/authorize"
    name = "ha_mcp_tools:oauth:autoapprove-authorize"

    def __init__(self, hass: HomeAssistant) -> None:
        """Bind the view to the HA instance; liveness is resolved per request."""
        self._hass = hass

    async def get(self, request: web.Request) -> web.Response:
        """Auto-approve the authorization request or reject with a 400/404."""
        provider = _active_autoapprove_provider(self._hass)
        if provider is None:
            return _json_not_found()

        params = request.query
        response_type = params.get("response_type", "")
        redirect_uri = params.get("redirect_uri", "")
        state = params.get("state", "")
        code_challenge = params.get("code_challenge", "")
        code_challenge_method = params.get("code_challenge_method", "")

        if response_type != "code":
            return _json_error("unsupported_response_type", 400)
        if code_challenge_method != "S256":
            return _json_error(
                "invalid_request", 400, "code_challenge_method must be S256"
            )
        if not _PKCE_CHALLENGE_RE.match(code_challenge):
            return _json_error(
                "invalid_request", 400, "invalid code_challenge (43-char base64url)"
            )
        # SECURITY: an unvalidated redirect_uri would be an open redirector on
        # the HA origin. Reject in-place (never redirect) unless it exactly
        # matches a known MCP callback (client_id is attacker-controlled and is
        # deliberately not consulted — see module docstring).
        if not _is_valid_autoapprove_redirect(redirect_uri):
            return _json_error("invalid_request", 400, "invalid redirect_uri")

        # RFC 9207: every authorization response — success or error — names the
        # issuer that produced it, so a client registered with several
        # authorization servers cannot be fed a response minted by another one.
        iss = _issuer_for(request)

        code = provider.issue_code(redirect_uri, code_challenge)
        if code is None:
            # Pending-code store at capacity (abuse guard) — surface per
            # RFC 6749 §4.1.2.1 instead of a silent failure.
            return _redirect_with(
                redirect_uri, error="temporarily_unavailable", state=state, iss=iss
            )
        redirect_params = {"code": code, "iss": iss}
        if state:
            redirect_params["state"] = state
        return _redirect_with(redirect_uri, **redirect_params)


class AutoApproveTokenView(HomeAssistantView):
    """None-mode auto-approve ``/token`` — PKCE code → opaque access token.

    Public client (no ``client_secret``): the PKCE code_verifier is the only
    proof required. The returned access token is cosmetic (none mode ignores
    bearers), but real and opaque. Only the ``authorization_code`` grant is
    supported — none mode has no refresh cycle.
    """

    requires_auth = False
    cors_allowed = True
    url = f"{OAUTH_BASE}/token"
    name = "ha_mcp_tools:oauth:autoapprove-token"

    def __init__(self, hass: HomeAssistant) -> None:
        """Bind the view to the HA instance; liveness is resolved per request."""
        self._hass = hass

    async def post(self, request: web.Request) -> web.Response:
        """Exchange a PKCE authorization code for an opaque access token."""
        provider = _active_autoapprove_provider(self._hass)
        if provider is None:
            return _json_not_found()

        form: dict[str, Any] = dict(await request.post())
        if form.get("grant_type", "") != "authorization_code":
            return _json_error("unsupported_grant_type", 400)

        code = str(form.get("code", ""))
        redirect_uri = str(form.get("redirect_uri", ""))
        code_verifier = str(form.get("code_verifier", ""))
        if not (code and redirect_uri and code_verifier):
            return _json_error("invalid_request", 400)
        if not provider.consume_code(code, redirect_uri, code_verifier):
            return _json_error("invalid_grant", 400)

        return web.json_response(
            {
                "access_token": provider.issue_access_token(),
                "token_type": "Bearer",
                "expires_in": ACCESS_TOKEN_TTL,
            },
            headers=_TOKEN_RESPONSE_HEADERS,
        )


def bind_autoapprove_views(hass: HomeAssistant) -> None:
    """Bind the two auto-approve views at most once per HA session.

    aiohttp cannot unregister a bound view, so a reload / re-enable / mode
    switch must reuse the already-bound views — they resolve the active
    provider from ``hass.data`` per request (see
    :func:`_active_autoapprove_provider`), so they serve only while
    none-autoapprove is live and 404 otherwise. The guard flag lives at a
    top-level ``hass.data`` key that survives config-entry teardown (mirrors
    :func:`mcp_webhook._register_metadata_views`).
    """
    if hass.data.get(_AUTOAPPROVE_VIEWS_REGISTERED_KEY):
        return
    # Set the flag only AFTER both views register (issue #1978): see
    # mcp_webhook._register_metadata_views. Marking the bundle bound before
    # /token registers would let a later none-mode setup assign the provider and
    # advertise OAuth with an unbound /token — a 404 on the token exchange. The
    # flag must mean the full bundle succeeded; a partial bind leaves it unset.
    hass.http.register_view(AutoApproveAuthorizeView(hass))
    hass.http.register_view(AutoApproveTokenView(hass))
    hass.data[_AUTOAPPROVE_VIEWS_REGISTERED_KEY] = True
