"""xAI subscription OAuth (Authorization Code + PKCE) support.

This integration follows the public OAuth client contract used by Grok Build.
Credentials are stored separately from Grok Build so rotating refresh tokens are
never shared between the two applications.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import json
import os
import queue
import secrets
import threading
import time
import webbrowser
from collections.abc import Callable
from contextlib import suppress
from dataclasses import asdict, dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, cast
from urllib.parse import parse_qs, urlencode, urlsplit

import httpx
from filelock import FileLock
from loguru import logger

from nanobot.config.paths import get_data_dir
from nanobot.utils.helpers import _write_text_atomic  # pyright: ignore[reportPrivateUsage]

XAI_OAUTH_ISSUER = "https://auth.x.ai"
XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
XAI_CLIENT_VERSION = "0.2.109"
XAI_ALLOWED_CALLBACK_ORIGIN = "https://accounts.x.ai"
XAI_OAUTH_SCOPES = (
    "openid",
    "profile",
    "email",
    "offline_access",
    "grok-cli:access",
    "api:access",
    "conversations:read",
    "conversations:write",
    "workspaces:read",
    "workspaces:write",
)

_DISCOVERY_URL = f"{XAI_OAUTH_ISSUER}/.well-known/openid-configuration"
_TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000
_DEFAULT_TOKEN_TTL_S = 60 * 60
_HTTP_TIMEOUT_S = 15.0


class XAIOAuthError(RuntimeError):
    """An actionable xAI OAuth failure with no credential material."""


@dataclass(frozen=True)
class XAIToken:
    """Persisted xAI OAuth token material."""

    access: str
    refresh: str | None
    expires: int
    account_id: str | None = None

    @classmethod
    def from_dict(cls, value: Any) -> XAIToken | None:
        if not isinstance(value, dict):
            return None
        token_data = cast(dict[str, Any], value)
        access = token_data.get("access")
        if not isinstance(access, str) or not access:
            return None
        refresh = token_data.get("refresh")
        if not isinstance(refresh, str) or not refresh:
            refresh = None
        try:
            expires = int(token_data.get("expires") or 0)
        except (TypeError, ValueError):
            expires = 0
        account_id = token_data.get("account_id")
        if not isinstance(account_id, str) or not account_id:
            account_id = None
        return cls(access=access, refresh=refresh, expires=expires, account_id=account_id)


@dataclass(frozen=True)
class _Discovery:
    authorization_endpoint: str
    token_endpoint: str
    userinfo_endpoint: str | None


@dataclass(frozen=True)
class _CallbackResult:
    code: str | None = None
    state: str | None = None
    error: str | None = None


class XAIOAuthLoginFlow:
    """Pending xAI OAuth login that can finish through loopback or an authorization code."""

    def __init__(
        self,
        *,
        authorization_url: str,
        redirect_uri: str,
        verifier: str,
        state: str,
        discovery: _Discovery,
        proxy: str | None,
        result_queue: queue.Queue[_CallbackResult],
        server: ThreadingHTTPServer,
        timeout_s: float,
    ) -> None:
        self.authorization_url = authorization_url
        self.redirect_uri = redirect_uri
        self._verifier = verifier
        self._state = state
        self._discovery = discovery
        self._proxy = proxy
        self._result_queue = result_queue
        self._server = server
        self._expires_at = time.monotonic() + timeout_s
        self._lock = threading.Lock()
        self._stop_event = threading.Event()
        self._token: XAIToken | None = None
        self._error: Exception | None = None
        self._closed = False
        self._server_thread = threading.Thread(
            target=_serve_callback_server,
            args=(server, self._stop_event),
            name="nanobot-xai-grok-oauth-callback",
            daemon=True,
        )
        self._server_thread.start()
        self._timeout_timer = threading.Timer(timeout_s, self._expire)
        self._timeout_timer.daemon = True
        self._timeout_timer.start()

    @property
    def expired(self) -> bool:
        return time.monotonic() >= self._expires_at

    @property
    def remaining_seconds(self) -> int:
        return max(0, int(self._expires_at - time.monotonic()))

    def complete(self, authorization_code: str | None = None) -> XAIToken | None:
        """Complete this flow, or return ``None`` while loopback is still pending."""
        with self._lock:
            if self._token is not None:
                return self._token
            self._raise_if_finished()

        callback: _CallbackResult | None
        if authorization_code is not None:
            callback = _CallbackResult(code=authorization_code.strip())
        else:
            try:
                callback = self._result_queue.get_nowait()
            except queue.Empty:
                callback = None
        if callback is None:
            with self._lock:
                if self._token is not None:
                    return self._token
                self._raise_if_finished()
                if self.expired:
                    self._expire_locked()
                    self._raise_if_finished()
            return None
        return self._finish(callback)

    def wait(self, timeout_s: float) -> XAIToken:
        """Wait for the loopback callback and complete this flow."""
        with self._lock:
            if self._token is not None:
                return self._token
            self._raise_if_finished()
        try:
            callback = self._result_queue.get(timeout=timeout_s)
        except queue.Empty as exc:
            with self._lock:
                if self._error is not None:
                    raise self._error
            raise XAIOAuthError(
                "Timed out waiting for xAI sign-in. Run "
                "`nanobot provider login xai-grok` to try again."
            ) from exc
        return self._finish(callback)

    def cancel(self) -> None:
        """Stop the callback listener for an abandoned flow."""
        with self._lock:
            if self._token is None and self._error is None:
                self._error = XAIOAuthError("xAI sign-in was cancelled.")
            self._close_locked()

    def _finish(self, callback: _CallbackResult) -> XAIToken:
        with self._lock:
            if self._token is not None:
                return self._token
            self._raise_if_finished()
            self._close_locked()
            try:
                token = _exchange_callback(
                    callback,
                    expected_state=self._state,
                    discovery=self._discovery,
                    verifier=self._verifier,
                    redirect_uri=self.redirect_uri,
                    proxy=self._proxy,
                )
                with _token_lock():
                    _write_token(token)
            except Exception as exc:
                self._error = exc
                raise
            self._token = token
            return token

    def _expire(self) -> None:
        with self._lock:
            if self._token is not None or self._error is not None:
                return
            self._expire_locked()

    def _expire_locked(self) -> None:
        self._error = XAIOAuthError("xAI sign-in expired. Start a new sign-in flow.")
        self._close_locked()

    def _raise_if_finished(self) -> None:
        if self._token is not None:
            return
        if self._error is not None:
            raise self._error

    def _close_locked(self) -> None:
        if self._closed:
            return
        self._closed = True
        self._timeout_timer.cancel()
        self._stop_event.set()
        if threading.current_thread() is not self._server_thread:
            self._server_thread.join(timeout=2)


def get_xai_oauth_storage_path() -> Path:
    """Return the instance-scoped xAI OAuth credential path."""
    return get_data_dir() / "auth" / "xai.json"


def get_xai_oauth_login_status() -> XAIToken | None:
    """Return locally stored login state without making a network request."""
    return _load_token()


def logout_xai_oauth() -> bool:
    """Remove this instance's credentials while excluding token refreshes."""
    path = get_xai_oauth_storage_path()
    with _token_lock():
        try:
            path.unlink()
        except FileNotFoundError:
            return False
    return True


def login_xai_oauth(
    *,
    print_fn: Callable[[str], None] = print,
    prompt_fn: Callable[[str], str] | None = None,
    proxy: str | None = None,
    callback_timeout_s: float = 600,
    browser_opener: Callable[[str], bool] = webbrowser.open,
) -> XAIToken:
    """Run xAI's browser-based OAuth flow and persist the resulting token.

    ``prompt_fn`` is used only when no local browser could be opened. It lets a
    headless user paste either the final callback URL or its authorization code.
    """
    flow = start_xai_oauth_login(proxy=proxy, timeout_s=callback_timeout_s)

    try:
        print_fn("Opening xAI sign-in in your browser...")
        print_fn(f"If it does not open automatically, visit:\n{flow.authorization_url}")
        opened = False
        with suppress(Exception):
            opened = bool(browser_opener(flow.authorization_url))

        if not opened and prompt_fn is not None:
            pasted = prompt_fn("Paste the final callback URL (or authorization code)")
            token = flow.complete(pasted)
            if token is None:  # pragma: no cover - pasted input always resolves a callback
                raise XAIOAuthError("xAI sign-in returned no authorization code.")
            return token
        return flow.wait(callback_timeout_s)
    finally:
        flow.cancel()


def start_xai_oauth_login(
    *,
    proxy: str | None = None,
    timeout_s: float = 600,
) -> XAIOAuthLoginFlow:
    """Create a non-blocking OAuth flow for browser or pasted-callback completion."""
    discovery = _discover(proxy)
    verifier, challenge = _generate_pkce()
    state = secrets.token_urlsafe(32)
    nonce = secrets.token_urlsafe(32)
    result_queue: queue.Queue[_CallbackResult] = queue.Queue(maxsize=1)
    server = _make_callback_server(state, result_queue)
    redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
    authorization_url = _build_authorize_url(
        discovery.authorization_endpoint,
        redirect_uri=redirect_uri,
        challenge=challenge,
        state=state,
        nonce=nonce,
    )
    return XAIOAuthLoginFlow(
        authorization_url=authorization_url,
        redirect_uri=redirect_uri,
        verifier=verifier,
        state=state,
        discovery=discovery,
        proxy=proxy,
        result_queue=result_queue,
        server=server,
        timeout_s=timeout_s,
    )


def complete_xai_oauth_login(
    flow: XAIOAuthLoginFlow,
    authorization_code: str | None = None,
) -> XAIToken | None:
    """Complete a pending login from loopback state or a pasted authorization code."""
    return flow.complete(authorization_code)


def get_xai_oauth_token(
    *,
    proxy: str | None = None,
    min_ttl_ms: int = _TOKEN_REFRESH_MARGIN_MS,
    force_refresh: bool = False,
) -> XAIToken:
    """Load a usable token, refreshing it under an inter-process lock when needed."""
    token = _load_token()
    if token is None:
        raise XAIOAuthError(
            "xAI is not signed in. Run `nanobot provider login xai-grok` first."
        )
    if not force_refresh and _token_is_fresh(token, min_ttl_ms):
        return token
    if not token.refresh:
        if not force_refresh and token.expires > _now_ms():
            return token
        raise XAIOAuthError(
            "The xAI login has expired and cannot be refreshed. "
            "Run `nanobot provider login xai-grok` again."
        )

    with _token_lock():
        latest = _load_token()
        if latest is None:
            raise XAIOAuthError(
                "xAI is not signed in. Run `nanobot provider login xai-grok` first."
            )
        if not force_refresh and _token_is_fresh(latest, min_ttl_ms):
            return latest
        if not latest.refresh:
            raise XAIOAuthError(
                "The xAI login has expired and cannot be refreshed. "
                "Run `nanobot provider login xai-grok` again."
            )
        refreshed = _refresh_token(latest, proxy)
        _write_token(refreshed)
        return refreshed


def _discover(proxy: str | None) -> _Discovery:
    try:
        with _http_client(proxy) as client:
            response = client.get(_DISCOVERY_URL)
    except httpx.HTTPError as exc:
        raise XAIOAuthError(f"Could not reach xAI sign-in: {type(exc).__name__}.") from exc
    if response.status_code != HTTPStatus.OK:
        raise _oauth_http_error(response, "discovery")
    try:
        payload = response.json()
    except ValueError as exc:
        raise XAIOAuthError("xAI sign-in discovery returned invalid JSON.") from exc
    if payload.get("issuer", "").rstrip("/") != XAI_OAUTH_ISSUER:
        raise XAIOAuthError("xAI sign-in discovery returned an unexpected issuer.")
    authorization_endpoint = _validate_xai_endpoint(
        payload.get("authorization_endpoint"), "authorization"
    )
    token_endpoint = _validate_xai_endpoint(payload.get("token_endpoint"), "token")
    userinfo_raw = payload.get("userinfo_endpoint")
    userinfo_endpoint = (
        _validate_xai_endpoint(userinfo_raw, "userinfo") if userinfo_raw else None
    )
    return _Discovery(authorization_endpoint, token_endpoint, userinfo_endpoint)


def _validate_xai_endpoint(value: Any, label: str) -> str:
    if not isinstance(value, str):
        raise XAIOAuthError(f"xAI sign-in discovery omitted the {label} endpoint.")
    parsed = urlsplit(value)
    try:
        port = parsed.port
    except ValueError as exc:
        raise XAIOAuthError(
            f"xAI sign-in discovery returned an unsafe {label} endpoint."
        ) from exc
    if (
        parsed.scheme != "https"
        or parsed.hostname != "auth.x.ai"
        or parsed.username is not None
        or parsed.password is not None
        or port not in (None, 443)
    ):
        raise XAIOAuthError(f"xAI sign-in discovery returned an unsafe {label} endpoint.")
    return value


def _generate_pkce() -> tuple[str, str]:
    verifier = _base64url(secrets.token_bytes(32))
    challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
    return verifier, challenge


def _base64url(value: bytes) -> str:
    return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")


def _build_authorize_url(
    endpoint: str,
    *,
    redirect_uri: str,
    challenge: str,
    state: str,
    nonce: str,
) -> str:
    params = {
        "response_type": "code",
        "client_id": XAI_CLIENT_ID,
        "redirect_uri": redirect_uri,
        "scope": " ".join(XAI_OAUTH_SCOPES),
        "code_challenge": challenge,
        "code_challenge_method": "S256",
        "state": state,
        "nonce": nonce,
        "referrer": "nanobot",
    }
    return f"{endpoint}?{urlencode(params)}"


def _make_callback_server(
    expected_state: str,
    result_queue: queue.Queue[_CallbackResult],
) -> ThreadingHTTPServer:
    class CallbackHandler(BaseHTTPRequestHandler):
        def do_OPTIONS(self) -> None:  # noqa: N802
            if self.path.split("?", 1)[0] != "/callback":
                self.send_error(HTTPStatus.NOT_FOUND)
                return
            self.send_response(HTTPStatus.NO_CONTENT)
            self._send_cors_headers()
            self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
            self.send_header("Access-Control-Allow-Headers", "Content-Type")
            self.send_header("Access-Control-Allow-Private-Network", "true")
            self.end_headers()

        def do_GET(self) -> None:  # noqa: N802
            parsed = urlsplit(self.path)
            if parsed.path != "/callback":
                self.send_error(HTTPStatus.NOT_FOUND)
                return
            params = parse_qs(parsed.query)
            code = _first(params, "code")
            received_state = _first(params, "state")
            error = _first(params, "error_description") or _first(params, "error")
            if code and received_state and hmac.compare_digest(received_state, expected_state):
                result = _CallbackResult(code=code, state=received_state)
                title = "Signed in to xAI"
                message = "You can close this tab and return to nanobot."
            elif code:
                result = _CallbackResult(error="OAuth state mismatch")
                title = "Sign-in failed"
                message = "The sign-in response could not be verified. Return to nanobot and retry."
            else:
                result = _CallbackResult(error=error or "access denied")
                title = "Access denied"
                message = "Return to nanobot and try signing in again."
            with suppress(queue.Full):
                result_queue.put_nowait(result)
            body = _callback_page(title, message)
            encoded = body.encode("utf-8")
            self.send_response(HTTPStatus.OK)
            self._send_cors_headers()
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(encoded)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(encoded)

        def _send_cors_headers(self) -> None:
            origin = self.headers.get("Origin")
            if origin == XAI_ALLOWED_CALLBACK_ORIGIN:
                self.send_header("Access-Control-Allow-Origin", origin)
                self.send_header("Vary", "Origin")
            self.send_header("Access-Control-Allow-Private-Network", "true")

        def log_message(self, format: str, *_args: Any) -> None:  # noqa: A002
            # Callback query strings contain an authorization code.
            return

    return ThreadingHTTPServer(("127.0.0.1", 0), CallbackHandler)


def _serve_callback_server(
    server: ThreadingHTTPServer,
    stop_event: threading.Event,
) -> None:
    server.timeout = 0.2
    try:
        while not stop_event.is_set():
            server.handle_request()
    finally:
        server.server_close()


def _first(params: dict[str, list[str]], key: str) -> str | None:
    values = params.get(key)
    return values[0] if values else None


def _callback_page(title: str, message: str) -> str:
    return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>{title}</title><style>
body{{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f7fb;color:#172033;
font:16px/1.5 system-ui,sans-serif}}main{{max-width:30rem;margin:1.5rem;padding:2rem;border:1px solid #dfe4ee;
border-radius:18px;background:white;box-shadow:0 16px 50px #17203318}}h1{{margin:0 0 .65rem;font-size:1.5rem}}
p{{margin:0;color:#526078}}</style></head><body><main><h1>{title}</h1><p>{message}</p></main></body></html>"""


def _exchange_callback(
    callback: _CallbackResult,
    *,
    expected_state: str,
    discovery: _Discovery,
    verifier: str,
    redirect_uri: str,
    proxy: str | None,
) -> XAIToken:
    if callback.error:
        raise XAIOAuthError(f"xAI sign-in was not completed: {callback.error}")
    if not callback.code:
        raise XAIOAuthError("xAI sign-in returned no authorization code.")
    if callback.state and not hmac.compare_digest(callback.state, expected_state):
        raise XAIOAuthError("xAI sign-in failed because the OAuth state did not match.")

    payload = _exchange_code(
        discovery.token_endpoint,
        code=callback.code,
        verifier=verifier,
        redirect_uri=redirect_uri,
        proxy=proxy,
    )
    account_id = _fetch_account(discovery.userinfo_endpoint, payload["access_token"], proxy)
    return _token_from_response(payload, account_id=account_id)


def _exchange_code(
    token_endpoint: str,
    *,
    code: str,
    verifier: str,
    redirect_uri: str,
    proxy: str | None,
) -> dict[str, Any]:
    data = {
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": redirect_uri,
        "client_id": XAI_CLIENT_ID,
        "code_verifier": verifier,
    }
    try:
        with _http_client(proxy) as client:
            response = client.post(
                token_endpoint,
                data=data,
                headers={"x-grok-client-version": XAI_CLIENT_VERSION},
            )
    except httpx.HTTPError as exc:
        raise XAIOAuthError(f"Could not exchange the xAI sign-in code: {type(exc).__name__}.") from exc
    if not response.is_success:
        raise _oauth_http_error(response, "token exchange")
    return _token_payload(response)


def _refresh_token(token: XAIToken, proxy: str | None) -> XAIToken:
    data = {
        "grant_type": "refresh_token",
        "refresh_token": token.refresh or "",
        "client_id": XAI_CLIENT_ID,
    }
    try:
        with _http_client(proxy) as client:
            response = client.post(
                f"{XAI_OAUTH_ISSUER}/oauth2/token",
                data=data,
                headers={"x-grok-client-version": XAI_CLIENT_VERSION},
            )
    except httpx.HTTPError as exc:
        raise XAIOAuthError(f"Could not refresh the xAI login: {type(exc).__name__}.") from exc
    if not response.is_success:
        raise _oauth_http_error(response, "token refresh")
    payload = _token_payload(response)
    return _token_from_response(
        payload,
        account_id=token.account_id,
        previous_refresh=token.refresh,
    )


def _token_payload(response: httpx.Response) -> dict[str, Any]:
    try:
        payload = response.json()
    except ValueError as exc:
        raise XAIOAuthError("xAI sign-in returned an invalid token response.") from exc
    if not isinstance(payload, dict):
        raise XAIOAuthError("xAI sign-in returned no access token.")
    token_payload = cast(dict[str, Any], payload)
    if not isinstance(token_payload.get("access_token"), str):
        raise XAIOAuthError("xAI sign-in returned no access token.")
    return token_payload


def _token_from_response(
    payload: dict[str, Any],
    *,
    account_id: str | None,
    previous_refresh: str | None = None,
) -> XAIToken:
    try:
        expires_in = max(1, int(payload.get("expires_in") or _DEFAULT_TOKEN_TTL_S))
    except (TypeError, ValueError):
        expires_in = _DEFAULT_TOKEN_TTL_S
    refresh = payload.get("refresh_token")
    if not isinstance(refresh, str) or not refresh:
        refresh = previous_refresh
    return XAIToken(
        access=payload["access_token"],
        refresh=refresh,
        expires=_now_ms() + expires_in * 1000,
        account_id=account_id,
    )


def _fetch_account(endpoint: str | None, access_token: str, proxy: str | None) -> str | None:
    if not endpoint:
        return None
    try:
        with _http_client(proxy) as client:
            response = client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
        if not response.is_success:
            return None
        payload = response.json()
    except (httpx.HTTPError, ValueError):
        return None
    if not isinstance(payload, dict):
        return None
    account_payload = cast(dict[str, Any], payload)
    for key in ("email", "preferred_username", "name", "sub"):
        value = account_payload.get(key)
        if isinstance(value, str) and value:
            return value
    return None


def _oauth_http_error(response: httpx.Response, action: str) -> XAIOAuthError:
    code: str | None = None
    description: str | None = None
    with suppress(ValueError):
        payload = response.json()
        if isinstance(payload, dict):
            error_payload = cast(dict[str, Any], payload)
            raw_code = error_payload.get("error")
            raw_description = error_payload.get("error_description") or error_payload.get("message")
            code = raw_code[:80] if isinstance(raw_code, str) else None
            description = raw_description[:200] if isinstance(raw_description, str) else None
    detail = ": ".join(value for value in (code, description) if value)
    suffix = f" ({detail})" if detail else ""
    return XAIOAuthError(f"xAI OAuth {action} failed with HTTP {response.status_code}{suffix}.")


def _http_client(proxy: str | None) -> httpx.Client:
    kwargs: dict[str, Any] = {"timeout": _HTTP_TIMEOUT_S}
    if proxy:
        kwargs.update(proxy=proxy, trust_env=False)
    return httpx.Client(**kwargs)


def _token_lock() -> FileLock:
    path = get_xai_oauth_storage_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    return FileLock(str(path.with_suffix(".lock")), timeout=15)


def _load_token() -> XAIToken | None:
    path = get_xai_oauth_storage_path()
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return None
    except (OSError, ValueError, TypeError) as exc:
        logger.warning("Could not read xAI OAuth credentials: {}", type(exc).__name__)
        return None
    return XAIToken.from_dict(payload)


def _write_token(token: XAIToken) -> None:
    path = get_xai_oauth_storage_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    with suppress(OSError):
        os.chmod(path.parent, 0o700)
    _write_text_atomic(path, json.dumps(asdict(token), indent=2, ensure_ascii=False))
    with suppress(OSError):
        os.chmod(path, 0o600)


def _token_is_fresh(token: XAIToken, min_ttl_ms: int) -> bool:
    return bool(token.access and token.expires > _now_ms() + max(0, min_ttl_ms))


def _now_ms() -> int:
    return int(time.time() * 1000)
