<?php

declare(strict_types=1);

namespace OCA\Astrolabe\Controller;

use OCA\Astrolabe\Service\McpServerClient;
use OCA\Astrolabe\Service\McpTokenStorage;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/**
 * OAuth controller for MCP Server UI.
 *
 * Implements OAuth 2.0 Authorization Code flow with PKCE (RFC 9207).
 * PKCE is always used for all clients (public and confidential) as recommended
 * by modern OAuth 2.0 best practices.
 *
 * - Public clients: PKCE only
 * - Confidential clients: PKCE + client_secret (defense in depth)
 */
class OAuthController extends Controller {
	private $config;
	private $session;
	private $userSession;
	private $urlGenerator;
	private $tokenStorage;
	private $logger;
	private $l;
	private $httpClient;
	private $client;

	public function __construct(
		string $appName,
		IRequest $request,
		IConfig $config,
		ISession $session,
		IUserSession $userSession,
		IURLGenerator $urlGenerator,
		McpTokenStorage $tokenStorage,
		LoggerInterface $logger,
		IL10N $l,
		IClientService $clientService,
		McpServerClient $client,
	) {
		parent::__construct($appName, $request);
		$this->config = $config;
		$this->session = $session;
		$this->userSession = $userSession;
		$this->urlGenerator = $urlGenerator;
		$this->tokenStorage = $tokenStorage;
		$this->logger = $logger;
		$this->l = $l;
		$this->httpClient = $clientService->newClient();
		$this->client = $client;
	}

	/**
	 * Initiate OAuth authorization flow.
	 *
	 * Always generates PKCE code verifier and challenge (RFC 9207).
	 * Stores state and code verifier in session, then redirects user to IdP authorization endpoint.
	 *
	 * @return RedirectResponse|TemplateResponse
	 */
	#[NoAdminRequired]
	#[NoCSRFRequired]
	public function initiateOAuth() {
		$this->logger->info('initiateOAuth called');

		$user = $this->userSession->getUser();
		if (!$user) {
			$this->logger->error('initiateOAuth: User not authenticated');
			return new TemplateResponse(
				'astrolabe',
				'settings/error',
				['error' => $this->l->t('User not authenticated')]
			);
		}

		$this->logger->info('initiateOAuth: User authenticated: ' . $user->getUID());

		try {
			// Get MCP server configuration
			$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
			if (empty($mcpServerUrl)) {
				throw new \Exception('MCP server URL not configured');
			}

			// Always generate PKCE values (RFC 9207: PKCE recommended for all clients)
			$codeVerifier = bin2hex(random_bytes(32));
			$codeChallenge = $this->base64UrlEncode(hash('sha256', $codeVerifier, true));

			// Check if confidential client secret is also configured
			$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
			$isConfidentialClient = !empty($clientSecret);

			if ($isConfidentialClient) {
				$this->logger->info('Using confidential client mode with PKCE and client secret');
			} else {
				$this->logger->info('Using public client mode with PKCE only');
			}

			// Generate state for CSRF protection
			$state = bin2hex(random_bytes(16));

			// Store values in session
			$this->session->set('mcp_oauth_code_verifier', $codeVerifier);
			$this->session->set('mcp_oauth_state', $state);
			$this->session->set('mcp_oauth_user_id', $user->getUID());

			// Build OAuth authorization URL
			$authUrl = $this->buildAuthorizationUrl(
				$mcpServerUrl,
				$state,
				$codeChallenge
			);

			$this->logger->info('Initiating OAuth flow for user: ' . $user->getUID());

			return new RedirectResponse($authUrl);
		} catch (\Exception $e) {
			$this->logger->error('Failed to initiate OAuth flow', [
				'error' => $e->getMessage()
			]);

			return new TemplateResponse(
				'astrolabe',
				'settings/error',
				['error' => $this->l->t('Failed to initiate OAuth: %s', [$e->getMessage()])]
			);
		}
	}

	/**
	 * Handle OAuth callback after user authorization.
	 *
	 * Validates state, exchanges authorization code for access token using PKCE,
	 * and stores tokens for the user.
	 *
	 * @param string $code Authorization code
	 * @param string $state State parameter for CSRF protection
	 * @param string|null $error Error from IdP
	 * @param string|null $error_description Error description from IdP
	 * @return RedirectResponse
	 */
	#[NoAdminRequired]
	#[NoCSRFRequired]
	public function oauthCallback(
		string $code = '',
		string $state = '',
		?string $error = null,
		?string $error_description = null,
	): RedirectResponse {
		try {
			// Check for errors from IdP
			if ($error) {
				throw new \Exception("OAuth error: $error - " . ($error_description ?? ''));
			}

			// Validate state to prevent CSRF
			$storedState = $this->session->get('mcp_oauth_state');
			if (empty($storedState) || $state !== $storedState) {
				throw new \Exception('Invalid state parameter (CSRF protection)');
			}

			// Get stored PKCE verifier (always required)
			$codeVerifier = $this->session->get('mcp_oauth_code_verifier');
			if (empty($codeVerifier)) {
				throw new \Exception('PKCE code verifier not found in session');
			}

			// Get user ID from session
			$userId = $this->session->get('mcp_oauth_user_id');
			if (empty($userId)) {
				throw new \Exception('User ID not found in session');
			}

			// Get MCP server configuration
			$mcpServerUrl = $this->config->getSystemValue('mcp_server_url', '');
			if (empty($mcpServerUrl)) {
				throw new \Exception('MCP server URL not configured');
			}

			// Exchange authorization code for tokens
			$tokenData = $this->exchangeCodeForToken(
				$mcpServerUrl,
				$code,
				$codeVerifier
			);

			// Store tokens for user
			$this->tokenStorage->storeUserToken(
				$userId,
				$tokenData['access_token'],
				$tokenData['refresh_token'] ?? '',
				time() + ($tokenData['expires_in'] ?? 3600)
			);

			// Clean up session
			$this->session->remove('mcp_oauth_code_verifier');
			$this->session->remove('mcp_oauth_state');
			$this->session->remove('mcp_oauth_user_id');

			$this->logger->info("OAuth flow completed successfully for user: $userId");

			// Redirect back to personal settings
			return new RedirectResponse(
				$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
			);
		} catch (\Exception $e) {
			$this->logger->error('OAuth callback failed', [
				'error' => $e->getMessage()
			]);

			// Clean up session
			$this->session->remove('mcp_oauth_code_verifier');
			$this->session->remove('mcp_oauth_state');
			$this->session->remove('mcp_oauth_user_id');

			// Redirect to settings with error
			return new RedirectResponse(
				$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
					'section' => 'astrolabe',
					'error' => urlencode($e->getMessage())
				])
			);
		}
	}

	/**
	 * Disconnect user's MCP OAuth tokens.
	 *
	 * Deletes stored tokens from Nextcloud. Note: Does not revoke tokens on IdP side.
	 *
	 * @return RedirectResponse
	 */
	#[NoAdminRequired]
	public function disconnect(): RedirectResponse {
		$user = $this->userSession->getUser();
		if (!$user) {
			return new RedirectResponse(
				$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
			);
		}

		$userId = $user->getUID();

		try {
			$this->tokenStorage->deleteUserToken($userId);
			$this->logger->info("Disconnected MCP OAuth for user: $userId");
		} catch (\Exception $e) {
			$this->logger->error("Failed to disconnect MCP OAuth for user $userId", [
				'error' => $e->getMessage()
			]);
		}

		return new RedirectResponse(
			$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astrolabe'])
		);
	}

	/**
	 * Build OAuth authorization URL.
	 *
	 * Queries MCP server for IdP configuration, then performs OIDC discovery
	 * to find the authorization endpoint. Supports both Nextcloud OIDC and
	 * external IdPs like Keycloak.
	 *
	 * Always uses PKCE (RFC 9207 recommends PKCE for all clients).
	 *
	 * @param string $mcpServerUrl Base URL of MCP server
	 * @param string $state CSRF state parameter
	 * @param string $codeChallenge PKCE code challenge
	 * @return string Authorization URL
	 * @throws \Exception if OIDC discovery fails
	 */
	private function buildAuthorizationUrl(
		string $mcpServerUrl,
		string $state,
		string $codeChallenge,
	): string {
		// First, query MCP server to discover which IdP it's configured to use
		$this->logger->info('buildAuthorizationUrl: Starting', [
			'mcp_server_url' => $mcpServerUrl,
		]);

		try {
			$statusUrl = $mcpServerUrl . '/api/v1/status';
			$this->logger->info('buildAuthorizationUrl: Fetching MCP server status', [
				'url' => $statusUrl,
			]);

			$statusResponse = $this->httpClient->get($statusUrl);
			$statusData = json_decode($statusResponse->getBody(), true);

			if (json_last_error() !== JSON_ERROR_NONE) {
				throw new \RuntimeException('Invalid JSON in status response: ' . json_last_error_msg());
			}

			$this->logger->info('buildAuthorizationUrl: MCP server status received', [
				'auth_mode' => $statusData['auth_mode'] ?? 'unknown',
				'has_oidc' => isset($statusData['oidc']),
				'oidc_discovery_url' => $statusData['oidc']['discovery_url'] ?? 'not_set',
			]);

		} catch (\Exception $e) {
			$this->logger->error('buildAuthorizationUrl: Failed to fetch MCP server status', [
				'url' => $mcpServerUrl . '/api/v1/status',
				'error' => $e->getMessage(),
				'trace' => $e->getTraceAsString(),
			]);
			throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
		}

		// Determine OIDC discovery URL
		// Priority: 1) MCP server's configured discovery URL, 2) Nextcloud OIDC app
		if (isset($statusData['oidc']['discovery_url'])) {
			// MCP server has external IdP configured (e.g., Keycloak)
			$discoveryUrl = $statusData['oidc']['discovery_url'];
			$this->logger->info('Using IdP from MCP server configuration', [
				'discovery_url' => $discoveryUrl,
			]);
		} else {
			// Fall back to Nextcloud's OIDC app
			// Use internal localhost URL for HTTP request (accessible from inside container)
			// We'll transform the returned URLs to external format after discovery
			$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
			$internalBaseUrl = 'http://localhost';

			$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
				'discovery_url' => $discoveryUrl,
			]);
		}

		// Perform OIDC discovery
		$this->logger->info('buildAuthorizationUrl: Starting OIDC discovery', [
			'discovery_url' => $discoveryUrl,
		]);

		try {
			$response = $this->httpClient->get($discoveryUrl);
			$responseBody = $response->getBody();
			$this->logger->info('buildAuthorizationUrl: Got OIDC discovery response', [
				'status_code' => $response->getStatusCode(),
				'body_length' => strlen($responseBody),
			]);

			$discovery = json_decode($responseBody, true);

			if (json_last_error() !== JSON_ERROR_NONE) {
				throw new \RuntimeException('Invalid JSON in OIDC discovery: ' . json_last_error_msg());
			}

			if (!isset($discovery['authorization_endpoint'])) {
				throw new \RuntimeException('Missing authorization_endpoint in OIDC discovery');
			}

			$authEndpoint = $discovery['authorization_endpoint'];

			// Transform internal URL to external URL if using Nextcloud OIDC app
			// The discovery was done via internal http://localhost but browsers need
			// the external URL (e.g., http://localhost:8080)
			if (isset($internalBaseUrl)) {
				$externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/');
				$externalBaseUrl = rtrim($externalBaseUrl, '/');
				$authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint);
			}

			$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
				'auth_endpoint' => $authEndpoint,
				'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
			]);

		} catch (\Exception $e) {
			$this->logger->error('buildAuthorizationUrl: OIDC discovery failed', [
				'discovery_url' => $discoveryUrl,
				'error' => $e->getMessage(),
				'trace' => $e->getTraceAsString(),
			]);
			throw new \Exception('Failed to discover OAuth endpoints: ' . $e->getMessage());
		}

		// Build callback URL
		$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
			'astrolabe.oauth.oauthCallback'
		);

		// Get public MCP server URL for token audience (RFC 8707 Resource Indicator)
		// Use public URL that clients/browsers see, not internal Docker URL
		$mcpServerPublicUrl = $this->config->getSystemValue('mcp_server_public_url', $mcpServerUrl);

		// Build authorization URL parameters
		$params = [
			'client_id' => $this->client->getClientId(),
			'redirect_uri' => $redirectUri,
			'response_type' => 'code',
			'scope' => 'openid profile email offline_access',  // Request MCP scopes
			'state' => $state,
			'resource' => $mcpServerPublicUrl,  // RFC 8707 Resource Indicator - request token with MCP server audience
		];

		// Add PKCE parameters (always required per RFC 9207)
		$params['code_challenge'] = $codeChallenge;
		$params['code_challenge_method'] = 'S256';

		return $authEndpoint . '?' . http_build_query($params);
	}

	/**
	 * Exchange authorization code for access token.
	 *
	 * Always uses PKCE code_verifier (RFC 9207).
	 * For confidential clients: Also includes client_secret for additional security.
	 * For public clients: Uses PKCE code_verifier only.
	 *
	 * Queries MCP server for IdP configuration, then performs OIDC discovery
	 * to find the token endpoint. Supports both Nextcloud OIDC and external IdPs.
	 *
	 * @param string $mcpServerUrl Base URL of MCP server
	 * @param string $code Authorization code
	 * @param string $codeVerifier PKCE code verifier
	 * @return array Token data containing access_token, refresh_token, expires_in
	 * @throws \Exception on HTTP or token error
	 */
	private function exchangeCodeForToken(
		string $mcpServerUrl,
		string $code,
		string $codeVerifier,
	): array {
		// Query MCP server to discover which IdP it's configured to use
		try {
			$statusResponse = $this->httpClient->get($mcpServerUrl . '/api/v1/status');
			$statusData = json_decode($statusResponse->getBody(), true);

			if (json_last_error() !== JSON_ERROR_NONE) {
				throw new \RuntimeException('Invalid status response from MCP server');
			}

		} catch (\Exception $e) {
			$this->logger->error('Failed to fetch MCP server status during token exchange', [
				'error' => $e->getMessage(),
			]);
			throw new \Exception('Cannot connect to MCP server: ' . $e->getMessage());
		}

		// Determine OIDC discovery URL and token endpoint
		$useInternalNextcloud = !isset($statusData['oidc']['discovery_url']);

		if (!$useInternalNextcloud) {
			// External IdP configured - use discovery
			$discoveryUrl = $statusData['oidc']['discovery_url'];

			try {
				$response = $this->httpClient->get($discoveryUrl);
				$discovery = json_decode($response->getBody(), true);

				if (json_last_error() !== JSON_ERROR_NONE || !isset($discovery['token_endpoint'])) {
					throw new \RuntimeException('Invalid OIDC discovery response');
				}

				$tokenEndpoint = $discovery['token_endpoint'];

			} catch (\Exception $e) {
				$this->logger->error('OIDC discovery failed during token exchange', [
					'discovery_url' => $discoveryUrl,
					'error' => $e->getMessage(),
				]);
				throw new \Exception('Failed to discover token endpoint: ' . $e->getMessage());
			}
		} else {
			// Nextcloud's OIDC app - use internal URL directly (no HTTP request needed)
			// This avoids network issues when overwritehost includes external port
			$tokenEndpoint = 'http://localhost/apps/oidc/token';
		}

		$redirectUri = $this->urlGenerator->linkToRouteAbsolute(
			'astrolabe.oauth.oauthCallback'
		);

		// Build token request parameters
		$postData = [
			'grant_type' => 'authorization_code',
			'code' => $code,
			'redirect_uri' => $redirectUri,
			'client_id' => $this->client->getClientId(),
		];

		// Always include PKCE code verifier (RFC 9207)
		$postData['code_verifier'] = $codeVerifier;

		// Also include client secret if configured (defense in depth for confidential clients)
		$clientSecret = $this->config->getSystemValue('astrolabe_client_secret', '');
		if (!empty($clientSecret)) {
			$postData['client_secret'] = $clientSecret;
			$this->logger->info('Using PKCE with client secret for token exchange');
		} else {
			$this->logger->info('Using PKCE only for token exchange');
		}

		// Use Nextcloud's HTTP client for token request
		try {
			$response = $this->httpClient->post($tokenEndpoint, [
				'body' => http_build_query($postData),
				'headers' => [
					'Content-Type' => 'application/x-www-form-urlencoded',
					'Accept' => 'application/json',
				],
			]);

			$tokenData = json_decode($response->getBody(), true);

			if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
				throw new \RuntimeException('Invalid token response from server');
			}

			return $tokenData;

		} catch (\Exception $e) {
			$this->logger->error('Token exchange failed', [
				'error' => $e->getMessage(),
				'token_endpoint' => $tokenEndpoint,
			]);
			throw new \Exception('Token exchange failed: ' . $e->getMessage());
		}
	}

	/**
	 * Base64 URL-safe encoding (for PKCE).
	 *
	 * @param string $data Data to encode
	 * @return string Base64 URL-encoded string
	 */
	private function base64UrlEncode(string $data): string {
		return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
	}
}
