<?php
/**
 * OAuth 2.1 token + revocation endpoints. Phase 5 of AI-9.
 *
 * POST /wp-json/awfah_oauth/v1/token   — authorization_code (PKCE) + refresh_token grants.
 * POST /wp-json/awfah_oauth/v1/revoke  — RFC 7009 token revocation.
 *
 * Issues opaque, DB-backed, revocable tokens prefixed `access_` / `refresh_`
 * (the existing JwtAuth deliberately passes `access_`-prefixed tokens through
 * to this OAuth layer).
 *
 * @package AtlasAiDev\Awfah
 * @subpackage OAuth
 */

declare(strict_types=1);

namespace AtlasAiDev\Awfah\OAuth;

defined( 'ABSPATH' ) || exit;

/**
 * Class Token_Controller
 */
class Token_Controller {

	const ACCESS_TTL = 3600;             // 1 hour.
	const REFRESH_TTL = 2592000;         // 30 days.

	/**
	 * Wire hooks.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Register token + revoke routes.
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			OAuth_Server::REST_NAMESPACE,
			'/token',
			array(
				'methods'             => 'POST',
				'permission_callback' => '__return_true',
				'callback'            => array( $this, 'handle_token' ),
			)
		);
		register_rest_route(
			OAuth_Server::REST_NAMESPACE,
			'/revoke',
			array(
				'methods'             => 'POST',
				'permission_callback' => '__return_true',
				'callback'            => array( $this, 'handle_revoke' ),
			)
		);
	}

	/**
	 * Token endpoint.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public function handle_token( \WP_REST_Request $request ) {
		if ( ! OAuth_Server::is_enabled() ) {
			return $this->error( 'invalid_request', 404 );
		}

		/**
		 * Allow Pro / third parties to reject a token request (IP allowlist, …).
		 * Return false to reject.
		 *
		 * @param bool             $allowed Whether the token request is permitted.
		 * @param \WP_REST_Request $request The token request.
		 */
		if ( ! apply_filters( 'awfah_oauth_allow_token_request', true, $request ) ) {
			return $this->error( 'invalid_request', 403 );
		}

		$p          = $this->params( $request );
		$grant_type = (string) ( $p['grant_type'] ?? '' );

		if ( 'authorization_code' === $grant_type ) {
			return $this->grant_authorization_code( $p );
		}
		if ( 'refresh_token' === $grant_type ) {
			return $this->grant_refresh_token( $p );
		}
		return $this->error( 'unsupported_grant_type', 400 );
	}

	/**
	 * authorization_code grant (with mandatory PKCE).
	 *
	 * @param array $p Params.
	 * @return \WP_REST_Response
	 */
	private function grant_authorization_code( array $p ) {
		$code          = (string) ( $p['code'] ?? '' );
		$client_id     = (string) ( $p['client_id'] ?? '' );
		$redirect_uri  = (string) ( $p['redirect_uri'] ?? '' );
		$code_verifier = (string) ( $p['code_verifier'] ?? '' );

		$client = OAuth_Repository::get_client( $client_id );
		if ( ! $client ) {
			return $this->error( 'invalid_client', 401 );
		}
		if ( ! $this->authenticate_client( $client, $p ) ) {
			return $this->error( 'invalid_client', 401 );
		}

		$row = OAuth_Repository::find_active( $code, 'code' );
		if ( ! $row || $row['client_id'] !== $client_id || ! hash_equals( (string) $row['redirect_uri'], $redirect_uri ) ) {
			return $this->error( 'invalid_grant', 400 );
		}

		// PKCE S256 verification.
		if ( '' === $code_verifier || ! $this->verify_pkce( $code_verifier, (string) $row['code_challenge'] ) ) {
			OAuth_Repository::revoke( (int) $row['id'] ); // burn the code on PKCE failure.
			return $this->error( 'invalid_grant', 400 );
		}

		// Single-use: burn the code.
		OAuth_Repository::revoke( (int) $row['id'] );

		return $this->issue_tokens( $client_id, (int) $row['user_id'], (string) $row['scope'] );
	}

	/**
	 * refresh_token grant (with rotation).
	 *
	 * @param array $p Params.
	 * @return \WP_REST_Response
	 */
	private function grant_refresh_token( array $p ) {
		$refresh   = (string) ( $p['refresh_token'] ?? '' );
		$client_id = (string) ( $p['client_id'] ?? '' );

		$client = OAuth_Repository::get_client( $client_id );
		if ( ! $client || ! $this->authenticate_client( $client, $p ) ) {
			return $this->error( 'invalid_client', 401 );
		}

		$row = OAuth_Repository::find_active( $refresh, 'refresh' );
		if ( ! $row || $row['client_id'] !== $client_id ) {
			return $this->error( 'invalid_grant', 400 );
		}

		// Rotate: revoke the presented refresh token.
		OAuth_Repository::revoke( (int) $row['id'] );

		return $this->issue_tokens( $client_id, (int) $row['user_id'], (string) $row['scope'] );
	}

	/**
	 * Issue a fresh access + refresh token pair.
	 *
	 * @param string $client_id Client id.
	 * @param int    $user_id   WP user id.
	 * @param string $scope     Granted scope.
	 * @return \WP_REST_Response
	 */
	private function issue_tokens( string $client_id, int $user_id, string $scope ) {
		$access  = OAuth_Server::random_token( 'access_' );
		$refresh = OAuth_Server::random_token( 'refresh_' );

		/**
		 * Filter access/refresh token lifetimes in seconds (Pro: admin-configurable TTLs).
		 *
		 * @param int    $ttl       Default lifetime in seconds.
		 * @param string $client_id Client id.
		 * @param int    $user_id   WP user id.
		 */
		$access_ttl  = (int) apply_filters( 'awfah_oauth_access_ttl', self::ACCESS_TTL, $client_id, $user_id );
		$refresh_ttl = (int) apply_filters( 'awfah_oauth_refresh_ttl', self::REFRESH_TTL, $client_id, $user_id );

		OAuth_Repository::insert_token(
			array(
				'token'     => $access,
				'type'      => 'access',
				'client_id' => $client_id,
				'user_id'   => $user_id,
				'scope'     => $scope,
				'expires'   => gmdate( 'Y-m-d H:i:s', time() + $access_ttl ),
			)
		);
		OAuth_Repository::insert_token(
			array(
				'token'     => $refresh,
				'type'      => 'refresh',
				'client_id' => $client_id,
				'user_id'   => $user_id,
				'scope'     => $scope,
				'expires'   => gmdate( 'Y-m-d H:i:s', time() + $refresh_ttl ),
			)
		);

		/**
		 * Fires after an access+refresh token pair is issued (audit/notify).
		 *
		 * @param int    $user_id   WP user id.
		 * @param string $client_id Client id.
		 * @param string $scope     Granted scope.
		 */
		do_action( 'awfah_oauth_token_issued', $user_id, $client_id, $scope );

		$response = new \WP_REST_Response(
			array(
				'access_token'  => $access,
				'token_type'    => 'Bearer',
				'expires_in'    => $access_ttl,
				'refresh_token' => $refresh,
				'scope'         => $scope,
			),
			200
		);
		$response->header( 'Cache-Control', 'no-store' );
		$response->header( 'Pragma', 'no-cache' );
		return $response;
	}

	/**
	 * RFC 7009 revocation. Always returns 200 per spec.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response
	 */
	public function handle_revoke( \WP_REST_Request $request ) {
		if ( ! OAuth_Server::is_enabled() ) {
			return $this->error( 'invalid_request', 404 );
		}
		$p     = $this->params( $request );
		$token = (string) ( $p['token'] ?? '' );
		if ( '' !== $token ) {
			OAuth_Repository::revoke_by_value( $token );
		}
		return new \WP_REST_Response( array(), 200 );
	}

	/* --------------------------------------------------------------------- *
	 * Helpers
	 * --------------------------------------------------------------------- */

	/**
	 * Verify a PKCE S256 challenge.
	 *
	 * @param string $verifier  code_verifier from the client.
	 * @param string $challenge Stored code_challenge.
	 * @return bool
	 */
	private function verify_pkce( string $verifier, string $challenge ): bool {
		$computed = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );
		return hash_equals( $challenge, $computed );
	}

	/**
	 * Authenticate the client. Public clients (auth method "none") pass on
	 * client_id alone (PKCE is the protection); confidential clients must
	 * present a valid client_secret.
	 *
	 * @param array $client Client row.
	 * @param array $p      Request params (may include client_secret).
	 * @return bool
	 */
	private function authenticate_client( array $client, array $p ): bool {
		if ( 'none' === ( $client['token_endpoint_auth_method'] ?? 'none' ) ) {
			return true;
		}
		$secret = (string) ( $p['client_secret'] ?? '' );
		if ( '' === $secret ) {
			return false;
		}
		return hash_equals( (string) $client['client_secret_hash'], OAuth_Repository::hash( $secret ) );
	}

	/**
	 * Collect params from JSON body, form body, or Basic auth (client creds).
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return array
	 */
	private function params( \WP_REST_Request $request ): array {
		$p = $request->get_json_params();
		if ( ! is_array( $p ) || empty( $p ) ) {
			$p = $request->get_body_params();
		}
		if ( ! is_array( $p ) ) {
			$p = array();
		}
		// HTTP Basic client authentication (client_secret_basic).
		$auth = OAuth_Server::get_auth_header();
		if ( 0 === stripos( $auth, 'basic ' ) ) {
			$decoded = base64_decode( trim( substr( $auth, 6 ) ), true );
			if ( $decoded && false !== strpos( $decoded, ':' ) ) {
				list( $cid, $csecret ) = explode( ':', $decoded, 2 );
				$p['client_id']     = $p['client_id'] ?? $cid;
				$p['client_secret'] = $p['client_secret'] ?? $csecret;
			}
		}
		return $p;
	}

	/**
	 * OAuth error response.
	 *
	 * @param string $code   OAuth error code.
	 * @param int    $status HTTP status.
	 * @return \WP_REST_Response
	 */
	private function error( string $code, int $status ) {
		$response = new \WP_REST_Response( array( 'error' => $code ), $status );
		$response->header( 'Cache-Control', 'no-store' );
		return $response;
	}
}
