"""
OAuth2 authorization-code flow (Google / GitHub) for browser login.

After the IdP redirects back, PyGitWeb issues the same opaque in-process session id used
for local login (cookie / Bearer), not an IdP access token. FastAPI's OAuth2* security
classes only extract credentials from requests; they do not implement this redirect flow.
"""

from __future__ import annotations

import base64
import fnmatch
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass
from typing import Annotated, Any
from urllib.parse import urlencode

import httpx
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import RedirectResponse

from pygitweb.auth_config import AuthConfig, OAuthUsernameFrom, auth_config, is_oauth_auth_available
from pygitweb.config import settings
from pygitweb.sessions import create_session

OAUTH_STATE_COOKIE_NAME = "pygitweb_oauth_state"
OAUTH_STATE_MAX_AGE = 600
ACCESS_TOKEN_COOKIE_NAME = "pygitweb_access_token"
LOGIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7

TAG_AUTH_OAUTH = "auth - OAuth"

oauth_router = APIRouter()


def _safe_next_url(next_raw: str | None) -> str:
	n = (next_raw or "/").strip()
	if not n.startswith("/") or n.startswith("//"):
		return "/"
	return n


@dataclass(frozen=True)
class _OAuthProvider:
	authorize_url: str
	token_url: str
	scopes: str
	userinfo_url: str | None


_PROVIDERS: dict[str, _OAuthProvider] = {
	"google": _OAuthProvider(
		authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
		token_url="https://oauth2.googleapis.com/token",
		scopes="openid email profile",
		userinfo_url="https://openidconnect.googleapis.com/v1/userinfo",
	),
	"github": _OAuthProvider(
		authorize_url="https://github.com/login/oauth/authorize",
		token_url="https://github.com/login/oauth/access_token",
		scopes="read:user user:email",
		userinfo_url=None,
	),
}


def _provider(config: AuthConfig) -> _OAuthProvider:
	name = str(config.oauth_provider).strip()
	prov = _PROVIDERS.get(name)
	if prov is None:
		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")
	return prov


def _seal_oauth_state(payload: dict[str, Any], secret: str) -> str:
	body = {**payload, "exp": time.time() + OAUTH_STATE_MAX_AGE}
	data = json.dumps(body, separators=(",", ":")).encode()
	sig = hmac.new(secret.encode(), data, hashlib.sha256).digest()
	return base64.urlsafe_b64encode(data + b"." + base64.urlsafe_b64encode(sig)).decode()


def _unseal_oauth_state(token: str, secret: str) -> dict[str, Any]:
	try:
		raw = base64.urlsafe_b64decode(token.encode())
		data, sig_b64 = raw.rsplit(b".", 1)
		sig = base64.urlsafe_b64decode(sig_b64)
		expected = hmac.new(secret.encode(), data, hashlib.sha256).digest()
		if not hmac.compare_digest(sig, expected):
			raise ValueError("invalid signature")
		payload: dict[str, Any] = json.loads(data)
		if float(payload.get("exp", 0)) < time.time():
			raise ValueError("expired")
		return payload
	except (ValueError, json.JSONDecodeError, KeyError) as exc:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state") from exc


def _pkce_pair() -> tuple[str, str]:
	verifier = secrets.token_urlsafe(48)
	digest = hashlib.sha256(verifier.encode()).digest()
	challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
	return verifier, challenge


def _claim(profile: dict[str, Any], claim: OAuthUsernameFrom) -> str | None:
	if claim == "sub":
		raw = profile.get("sub") or profile.get("id")
	elif claim == "email":
		raw = profile.get("email")
	elif claim == "preffered_username":
		raw = profile.get("preferred_username") or profile.get("preffered_username")
	elif claim == "login":
		raw = profile.get("login")
	elif claim == "name":
		raw = profile.get("name")
	else:
		raw = None
	if raw is None:
		return None
	return str(raw)


def oauth_username_from_profile(config: AuthConfig, profile: dict[str, Any]) -> str:
	username = _claim(profile, config.oauth_username_from)
	if username:
		return username
	for fallback in ("sub", "email", "login", "id"):
		val = profile.get(fallback)
		if val is not None:
			return str(val)
	return "unknown"


def is_oauth_email_allowed(config: AuthConfig, email: str | None) -> bool:
	if not config.oauth_allowed_emails:
		return True
	if not email:
		return False
	normalized = email.strip().lower()
	patterns = [p.strip().lower() for p in config.oauth_allowed_emails if p.strip()]
	return any(fnmatch.fnmatchcase(normalized, pattern) for pattern in patterns)


async def _exchange_code(
	config: AuthConfig,
	*,
	code: str,
	code_verifier: str,
) -> dict[str, Any]:
	prov = _provider(config)
	data = {
		"grant_type": "authorization_code",
		"code": code,
		"redirect_uri": config.oauth_redirect_uri,
		"client_id": config.oauth_client_id,
		"client_secret": config.oauth_client_secret,
		"code_verifier": code_verifier,
	}
	headers = {"Accept": "application/json"}
	async with httpx.AsyncClient(timeout=30.0) as client:
		resp = await client.post(prov.token_url, data=data, headers=headers)
	resp.raise_for_status()
	out = resp.json()
	if not isinstance(out, dict):
		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid token response from provider")
	return out


async def _fetch_google_profile(access_token: str) -> dict[str, Any]:
	prov = _provider(auth_config)
	assert prov.userinfo_url is not None
	headers = {"Authorization": f"Bearer {access_token}"}
	async with httpx.AsyncClient(timeout=30.0) as client:
		resp = await client.get(prov.userinfo_url, headers=headers)
	resp.raise_for_status()
	profile = resp.json()
	if not isinstance(profile, dict):
		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid userinfo response")
	return profile


async def _fetch_github_profile(access_token: str) -> dict[str, Any]:
	headers = {
		"Authorization": f"Bearer {access_token}",
		"Accept": "application/vnd.github+json",
	}
	async with httpx.AsyncClient(timeout=30.0) as client:
		user_resp = await client.get("https://api.github.com/user", headers=headers)
		user_resp.raise_for_status()
		profile = user_resp.json()
		if not isinstance(profile, dict):
			raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid GitHub user response")
		if profile.get("email"):
			return profile
		email_resp = await client.get("https://api.github.com/user/emails", headers=headers)
		if email_resp.is_success:
			emails = email_resp.json()
			if isinstance(emails, list):
				for entry in emails:
					if isinstance(entry, dict) and entry.get("primary") and entry.get("verified"):
						profile = {**profile, "email": entry.get("email")}
						break
	return profile


async def _fetch_profile(config: AuthConfig, token_payload: dict[str, Any]) -> dict[str, Any]:
	access_token = token_payload.get("access_token")
	if not isinstance(access_token, str) or not access_token:
		raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Provider did not return an access token")
	provider = str(config.oauth_provider).strip()
	if provider == "google":
		return await _fetch_google_profile(access_token)
	if provider == "github":
		return await _fetch_github_profile(access_token)
	raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth provider is not configured")


def _profile_subject(profile: dict[str, Any]) -> str | None:
	for key in ("sub", "id"):
		val = profile.get(key)
		if val is not None:
			return str(val)
	return None


def _profile_email(profile: dict[str, Any]) -> str | None:
	email = profile.get("email")
	return str(email) if email else None


@oauth_router.get(
	"/auth/oauth/start",
	tags=[TAG_AUTH_OAUTH],
	summary="Start external OAuth sign-in",
	description=(
		"Browser-only. Redirects to the configured provider (Google or GitHub). "
		"On success, /auth/oauth/callback sets the same pygitweb_access_token session cookie as local login."
	),
	responses={303: {"description": "Redirect to identity provider"}},
)
async def oauth_start(
	next: Annotated[str, Query()] = "/",
) -> RedirectResponse:
	if not settings.AUTH:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
	if not is_oauth_auth_available(auth_config):
		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
	prov = _provider(auth_config)
	verifier, challenge = _pkce_pair()
	idp_state = secrets.token_urlsafe(32)
	sealed = _seal_oauth_state(
		{
			"state": idp_state,
			"next": _safe_next_url(next),
			"code_verifier": verifier,
		},
		auth_config.oauth_client_secret,
	)
	params = {
		"response_type": "code",
		"client_id": auth_config.oauth_client_id,
		"redirect_uri": auth_config.oauth_redirect_uri,
		"scope": prov.scopes,
		"state": idp_state,
		"code_challenge": challenge,
		"code_challenge_method": "S256",
	}
	url = f"{prov.authorize_url}?{urlencode(params)}"
	resp = RedirectResponse(url=url, status_code=303)
	resp.set_cookie(
		key=OAUTH_STATE_COOKIE_NAME,
		value=sealed,
		httponly=True,
		samesite="lax",
		max_age=OAUTH_STATE_MAX_AGE,
		path="/",
	)
	return resp


@oauth_router.get(
	"/auth/oauth/callback",
	tags=[TAG_AUTH_OAUTH],
	summary="OAuth provider callback",
	description=(
		"Browser-only. Called by the identity provider after sign-in. "
		"Validates state, exchanges the code, and sets the session cookie. Not intended for Try it out."
	),
	responses={303: {"description": "Redirect to original next URL with session cookie set"}},
)
async def oauth_callback(
	request: Request,
	code: Annotated[str | None, Query()] = None,
	state: Annotated[str | None, Query()] = None,
	error: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
	if not settings.AUTH:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is disabled")
	if not is_oauth_auth_available(auth_config):
		raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="OAuth is not configured")
	if error:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"OAuth provider error: {error}")
	if not code or not state:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth code or state")
	sealed = request.cookies.get(OAUTH_STATE_COOKIE_NAME)
	if not sealed:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing OAuth state cookie")
	payload = _unseal_oauth_state(sealed, auth_config.oauth_client_secret)
	if not hmac.compare_digest(str(payload.get("state", "")), state):
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth state mismatch")
	code_verifier = payload.get("code_verifier")
	if not isinstance(code_verifier, str) or not code_verifier:
		raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OAuth state payload")
	next_url = _safe_next_url(str(payload.get("next") or "/"))

	token_payload = await _exchange_code(auth_config, code=code, code_verifier=code_verifier)
	profile = await _fetch_profile(auth_config, token_payload)
	email = _profile_email(profile)
	if not is_oauth_email_allowed(auth_config, email):
		raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
	username = oauth_username_from_profile(auth_config, profile)
	subject = _profile_subject(profile)
	sid = create_session(
		username,
		subject=subject,
		email=email,
		auth_method="oauth",
	)
	resp = RedirectResponse(url=next_url, status_code=303)
	resp.set_cookie(
		key=ACCESS_TOKEN_COOKIE_NAME,
		value=sid,
		httponly=True,
		samesite="lax",
		max_age=LOGIN_COOKIE_MAX_AGE,
		path="/",
	)
	resp.delete_cookie(key=OAUTH_STATE_COOKIE_NAME, path="/")
	return resp