<?php
/**
 * OAuth 2.0 flow handler for Google Drive + OneDrive (Microsoft Graph).
 *
 *   GET  /oauth/{provider}/start?destination={id}   — returns the auth URL
 *   GET  /oauth/{provider}/callback?code=&state=    — handles browser redirect
 *
 * The /start endpoint is auth-protected (admin only). The /callback endpoint is
 * public (it's invoked by the user's browser after redirecting back from Google/MS),
 * but it validates a per-request state token that was stashed in a transient,
 * so it can't be forged.
 *
 * @package Backreload\Api
 */

namespace Backreload\Api;

use Backreload\Models\DestinationRecord;
use WP_REST_Request;

if ( ! defined( 'ABSPATH' ) ) { exit; }

class OauthController extends BaseController {

	const STATE_TTL = 600; // seconds

	private const PROVIDERS = [
		'google' => [
			'auth_url'  => 'https://accounts.google.com/o/oauth2/v2/auth',
			'token_url' => 'https://oauth2.googleapis.com/token',
			'scope'     => 'https://www.googleapis.com/auth/drive.file',
			'extra'     => [ 'access_type' => 'offline', 'prompt' => 'consent' ],
		],
		'onedrive' => [
			'auth_url'  => 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize',
			'token_url' => 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token',
			'scope'     => 'offline_access Files.ReadWrite User.Read',
			'extra'     => [],
		],
	];

	public function register( string $namespace ): void {
		register_rest_route( $namespace, '/oauth/(?P<provider>google|onedrive)/start', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'start' ],
			'permission_callback' => [ $this, 'check_perm' ],
		] );

		// Public by necessity: this is the URL the user's browser is redirected
		// to by Google / Microsoft after consent, so there is no REST nonce or
		// logged-in session to check at this point. Security is the single-use
		// `state` token generated in start(), stored in a transient, and bound
		// to the initiating user — validated inside callback(). It must also be
		// reachable for provider error redirects (e.g. access_denied) so it can
		// render a friendly result page, which a capability gate would prevent.
		register_rest_route( $namespace, '/oauth/(?P<provider>google|onedrive)/callback', [
			'methods'             => 'GET',
			'callback'            => [ $this, 'callback' ],
			'permission_callback' => '__return_true',
		] );
	}

	public function start( WP_REST_Request $req ) {
		$provider = (string) $req['provider'];
		$dest_id  = (string) $req->get_param( 'destination' );
		$dest     = DestinationRecord::find( $dest_id );
		if ( ! $dest ) {
			return $this->error( 'not_found', 'Destination not found', 404 );
		}
		if ( empty( $dest->config['client_id'] ) || empty( $dest->config['client_secret'] ) ) {
			return $this->error( 'no_credentials', 'Save your Client ID and Client Secret first.', 400 );
		}

		// Pin the OAuth flow to the WordPress user that started it. Stashing the
		// user id alongside the state means a leaked state value alone (it can
		// land in browser history, referer logs, etc.) can no longer be used by
		// another logged-in admin or by an attacker who tricks a different admin
		// into hitting the callback — the callback rejects state for a different
		// user.
		$state = wp_generate_password( 32, false, false );
		set_transient(
			'backreload_oauth_' . $state,
			[
				'dest'     => $dest_id,
				'provider' => $provider,
				'user'     => get_current_user_id(),
			],
			self::STATE_TTL
		);

		$cfg = self::PROVIDERS[ $provider ];
		$params = array_merge( [
			'client_id'     => $dest->config['client_id'],
			'redirect_uri'  => $this->callback_url( $provider ),
			'response_type' => 'code',
			'scope'         => $cfg['scope'],
			'state'         => $state,
		], $cfg['extra'] );

		$auth_url = $this->expand_url( $cfg['auth_url'], $dest->config ) . '?' . http_build_query( $params );
		return $this->ok( [ 'auth_url' => $auth_url ] );
	}

	public function callback( WP_REST_Request $req ) {
		$provider = (string) $req['provider'];
		$state    = (string) $req->get_param( 'state' );
		$code     = (string) $req->get_param( 'code' );
		$error    = (string) $req->get_param( 'error' );

		// Helper to render the final HTML to the browser. This is a standalone
		// HTML document returned as the REST response body for an OAuth popup
		// window (no theme/template, no wp_head). We still register + enqueue
		// the bundled CSS/JS through the WordPress API and let wp_print_styles()
		// / wp_print_scripts() emit the tags — the markup contains no
		// hand-written <link>/<script>. The outcome is handed to the script via
		// data-* attributes on the container element.
		wp_register_style( 'backreload-oauth', plugins_url( 'assets/oauth-callback.css', BACKRELOAD_FILE ), [], BACKRELOAD_VERSION );
		wp_enqueue_style( 'backreload-oauth' );
		wp_register_script( 'backreload-oauth', plugins_url( 'assets/oauth-callback.js', BACKRELOAD_FILE ), [], BACKRELOAD_VERSION, true );
		wp_enqueue_script( 'backreload-oauth' );

		$render = function ( bool $ok, string $message ) {
			$icon = $ok
				? '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ok"><path stroke="currentColor" d="M20 6L9 17l-5-5"/></svg>'
				: '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="err"><circle stroke="currentColor" cx="12" cy="12" r="10"/><path stroke="currentColor" d="M12 8v4M12 16h.01"/></svg>';

			ob_start();
			wp_print_styles( 'backreload-oauth' );
			$style_tags = ob_get_clean();

			ob_start();
			wp_print_scripts( 'backreload-oauth' );
			$script_tags = ob_get_clean();

			$body = '<!doctype html><html><head><meta charset="utf-8"><title>BackReload OAuth</title>'
				. $style_tags . '</head><body>'
				. '<div class="card" id="backreload-oauth" data-ok="' . ( $ok ? '1' : '0' ) . '" data-message="' . esc_attr( $message ) . '">'
				. $icon
				. '<h1>' . ( $ok ? 'Connected' : 'Connection failed' ) . '</h1>'
				. '<p>' . esc_html( $message ) . '</p>'
				. '</div>'
				. $script_tags
				. '</body></html>';

			return new \WP_REST_Response( $body, 200, [ 'Content-Type' => 'text/html; charset=utf-8' ] );
		};

		if ( $error ) {
			// Whitelist OAuth error codes so the rendered page can't be used to
			// reflect arbitrary attacker-controlled text. Unknown codes fall
			// through to a generic message.
			$known = [
				'access_denied'             => 'You declined the authorization.',
				'invalid_request'           => 'Provider rejected the request as malformed.',
				'unauthorized_client'       => 'This OAuth client is not authorized.',
				'unsupported_response_type' => 'Provider does not support this response type.',
				'invalid_scope'             => 'One or more requested scopes are invalid.',
				'server_error'              => 'Provider hit an internal error. Try again.',
				'temporarily_unavailable'   => 'Provider is temporarily unavailable. Try again later.',
			];
			$msg = $known[ $error ] ?? 'Provider returned an unknown error.';
			return $render( false, $msg );
		}

		if ( '' === $state ) {
			return $render( false, 'Invalid or expired state. Please try again.' );
		}
		$cached = get_transient( 'backreload_oauth_' . $state );
		if (
			! $cached
			|| ( $cached['provider'] ?? '' ) !== $provider
			|| (int) ( $cached['user'] ?? 0 ) !== get_current_user_id()
		) {
			// Mismatch on any of (state lookup, provider, user) → reject. The
			// user check is what stops a leaked state value from being redeemed
			// by anyone other than the admin who initiated the flow.
			return $render( false, 'Invalid or expired state. Please try again.' );
		}
		delete_transient( 'backreload_oauth_' . $state );

		if ( '' === $code ) {
			return $render( false, 'No authorization code returned by the provider.' );
		}

		$dest = DestinationRecord::find( $cached['dest'] );
		if ( ! $dest ) {
			return $render( false, 'Destination no longer exists.' );
		}

		// Exchange the code for tokens.
		$cfg = self::PROVIDERS[ $provider ];
		$res = wp_remote_post( $this->expand_url( $cfg['token_url'], $dest->config ), [
			'timeout'   => 20,
			'sslverify' => true, // explicit — the token endpoint MUST be over verified TLS.
			'body' => [
				'code'          => $code,
				'client_id'     => $dest->config['client_id'] ?? '',
				'client_secret' => $dest->config['client_secret'] ?? '',
				'redirect_uri'  => $this->callback_url( $provider ),
				'grant_type'    => 'authorization_code',
			],
		] );
		if ( is_wp_error( $res ) ) {
			return $render( false, 'Token exchange failed: ' . $res->get_error_message() );
		}
		$tokens = json_decode( wp_remote_retrieve_body( $res ), true );
		if ( wp_remote_retrieve_response_code( $res ) >= 400 || empty( $tokens['access_token'] ) ) {
			return $render( false, 'Token exchange failed: ' . ( $tokens['error_description'] ?? 'unknown error' ) );
		}

		$dest->config['access_token']  = $tokens['access_token'];
		if ( ! empty( $tokens['refresh_token'] ) ) {
			$dest->config['refresh_token'] = $tokens['refresh_token'];
		}
		$dest->config['expires_at']    = time() + (int) ( $tokens['expires_in'] ?? 3600 );
		$dest->status                  = 'connected';
		$dest->last_tested             = current_time( 'mysql', true );
		$dest->save();

		$this->plugin->get( 'logger' )->success( "OAuth connected: {$provider}" );
		return $render( true, 'You can close this window.' );
	}

	private function callback_url( string $provider ): string {
		return rest_url( "backreload/v1/oauth/{$provider}/callback" );
	}

	private function expand_url( string $url, array $config ): string {
		return str_replace( '{tenant}', $config['tenant_id'] ?? 'common', $url );
	}
}
