<?php
namespace WeDevs\AppBridge\Core\Auth;

use Exception;
use WeDevs\AppBridge\ThirdParty\Packages\Firebase\JWT\JWT;
use WeDevs\AppBridge\ThirdParty\Packages\Firebase\JWT\Key;
use WP_Error;
use WP_REST_Request;
use WP_User;

/**
 * The public-facing functionality of the plugin.
 *
 * Defines the plugin name, version, and two examples hooks for how to
 * enqueue the admin-specific stylesheet and JavaScript.
 *
 * @author     Enrique Chavez <noone@tmeister.net>
 * @since      1.0.0
 */
class JwtAuth {

    /**
     * `wp_option` key holding the shared JWT auth secret. Single source for all
     * integrations (Pocket Store, Vendor App, ...).
     */
    public const SECRET_KEY_OPTION = 'app_bridge_jwt_auth_secret_key';

    private $issued_at;

    private const TTL = 7 * DAY_IN_SECONDS;

	/**
	 * Store errors to display if the JWT is wrong
	 *
	 * @var WP_Error|null
	 */
	private ?WP_Error $jwt_error = null;

	/**
	 * Supported algorithms to sign the token.
	 *
	 * @var array|string[]
	 * @since 1.3.1
	 * @see https://www.rfc-editor.org/rfc/rfc7518#section-3
	 */
	private array $supported_algorithms = [
		'HS256',
		'HS384',
		'HS512',
		'RS256',
		'RS384',
		'RS512',
		'ES256',
		'ES384',
		'ES512',
		'PS256',
		'PS384',
		'PS512',
	];

	/**
	 * Add CORs support to the request.
	 */
	public function add_cors_support() {
		$enable_cors = defined( 'JWT_AUTH_CORS_ENABLE' ) && JWT_AUTH_CORS_ENABLE;
		if ( $enable_cors ) {
			$headers = apply_filters(
                'jwt_auth_cors_allow_headers',
                'Access-Control-Allow-Headers, Content-Type, Authorization'
            );
			header( sprintf( 'Access-Control-Allow-Headers: %s', $headers ) );
		}
	}

    protected function get_secret_key() {
        $auth_secret_key = defined( 'JWT_AUTH_SECRET_KEY' ) ? JWT_AUTH_SECRET_KEY : false;

        if ( ! $auth_secret_key ) {
            $auth_secret_key = get_option( self::SECRET_KEY_OPTION );
        }

        return $auth_secret_key ?? false;
    }

    public function get_issued_at() {
        if ( ! $this->issued_at ) {
            $this->issued_at = time();
        }

        return apply_filters( 'jwt_auth_issued_at', $this->issued_at );
    }

    public function get_not_before_at() {
        return apply_filters( 'jwt_auth_not_before', $this->get_issued_at(), $this->get_issued_at() );
    }

    public function get_expire_at() {
        return apply_filters( 'jwt_auth_expire', $this->get_issued_at() + $this->get_ttl(), $this->get_issued_at() );
    }

    public function get_ttl() {
        return apply_filters( 'jwt_auth_ttl', self::TTL );
    }

    public function generate_token_for_request( WP_REST_Request $request ) {
        return $this->generate_token( $request );
    }

    public function generate_token( WP_REST_Request $request ) {
        $username = $request->get_param( 'username' );
        $password = $request->get_param( 'password' );

        if ( ! $username || ! $password ) {
            return new WP_Error(
                'jwt_auth_invalid_credentials',
                __( 'Invalid username or password', 'app-bridge' ),
                [
                    'status' => 403,
                ]
            );
        }

        $user = wp_authenticate( $username, $password );

        return $this->generate_token_for_user( $user );
    }

	/**
	 * Get the user and password in the request body and generate a JWT
	 *
	 * @param WP_User $user
	 *
	 * @return mixed|WP_Error|null
	 */
	public function generate_token_for_user( $user ) {
		$secret_key = $this->get_secret_key();

		/** First thing, check the secret key if not exist return an error*/
		if ( ! $secret_key ) {
			return new WP_Error(
				'jwt_auth_bad_config',
				__( 'JWT is not configured properly, please contact the admin', 'app-bridge' ),
				[
					'status' => 403,
				]
			);
		}

		/** If the authentication fails return an error*/
		if ( is_wp_error( $user ) ) {
			$error_code = $user->get_error_code();

			return new WP_Error(
				'[jwt_auth] ' . $error_code,
				$user->get_error_message( $error_code ),
				[
					'status' => 403,
				]
			);
		}

    	/** Let the user modify the token data before the sign. */
		$algorithm = $this->get_algorithm();

		if ( $algorithm === false ) {
			return new WP_Error(
				'jwt_auth_unsupported_algorithm',
				__(
                    'Algorithm not supported, see https://www.rfc-editor.org/rfc/rfc7518#section-3',
                    'app-bridge'
                ),
				[
					'status' => 403,
				]
			);
		}

        $payload = [
			'data' => [
				'user' => [
					'id' => $user->data->ID,
				],
			],
		];

		$token = $this->encode(
			$payload,
			$secret_key,
			$algorithm
		);

		/** The token is signed, now create the object with no sensible user data to the client*/
		$data = [
			'token'             => $token,
			'user_email'        => $user->data->user_email,
			'user_nicename'     => $user->data->user_nicename,
			'user_display_name' => $user->data->display_name,
            'expires_at'        => $this->get_expire_at(),
		];

		do_action( 'jwt_auth_token_generated', $data, $user );

		/** Let the user modify the data before send it back */
		return apply_filters( 'jwt_auth_token_before_dispatch', $data, $user );
	}

    /**
     * Encode the token with the secret key and the algorithm.
     * @param array $payload
     * @param string|null $secret_key
     * @param string|null $algorithm
     *
     * @since SINCE_TAG
     *
     * @return WP_Error|string The JWT token or WP_Error if something goes wrong
     */
    public function encode( $payload, $secret_key = null, $algorithm = null ) {
        $secret_key = $secret_key ? $secret_key : $this->get_secret_key();

        if ( ! $secret_key ) {
            return new WP_Error(
                'jwt_auth_bad_config',
                __( 'JWT is not configured properly, please contact the admin', 'app-bridge' ),
                [
                    'status' => 403,
                ]
            );
        }

        $algorithm = $algorithm ? $algorithm : $this->get_algorithm();

        if ( $algorithm === false ) {
            return new WP_Error(
                'jwt_auth_unsupported_algorithm',
                __(
                    'Algorithm not supported, see https://www.rfc-editor.org/rfc/rfc7518#section-3',
                    'app-bridge'
                ),
                [
                    'status' => 403,
                ]
            );
        }

        $payload = wp_parse_args(
            $payload, [
				'iss'  => get_bloginfo( 'url' ),
				'iat'  => $this->get_issued_at(),
				'nbf'  => $this->get_not_before_at(),
				'exp'  => $this->get_expire_at(),
			]
        );

        $payload = apply_filters( 'jwt_auth_payload_before_encode', $payload, $secret_key, $algorithm );

        return JWT::encode( $payload, $secret_key, $algorithm );
    }

    /**
     * Decode the token with the secret key and the algorithm.
     *
     * @since SINCE_TAG
     *
     * @param array $payload
     * @param string|null $secret_key
     * @param string|null $algorithm
     *
     * @return WP_Error|\stdClass The JWT's payload as a PHP object
     */
    public function decode( $token, $secret_key = null, $algorithm = null ) {
        $secret_key = $secret_key ? $secret_key : $this->get_secret_key();

        if ( ! $secret_key ) {
            return new WP_Error(
                'jwt_auth_bad_config',
                __( 'JWT is not configured properly, please contact the admin', 'app-bridge' ),
                [
                    'status' => 403,
                ]
            );
        }

        $algorithm = $algorithm ? $algorithm : $this->get_algorithm();

        if ( $algorithm === false ) {
            return new WP_Error(
                'jwt_auth_unsupported_algorithm',
                __(
                    'Algorithm not supported, see https://www.rfc-editor.org/rfc/rfc7518#section-3',
                    'app-bridge'
                ),
                [
                    'status' => 403,
                ]
            );
        }

        try {
            $token = str_replace( 'Bearer ', '', $token );

            return JWT::decode( $token, new Key( $secret_key, $algorithm ) );
        } catch ( Exception $e ) {
            return new WP_Error(
                'jwt_auth_invalid_token',
                $e->getMessage(),
                [
                    'status' => 403,
                ]
            );
        }
    }

	/**
	 * This is our Middleware to try to authenticate the user according to the
	 * token send.
	 *
	 * @param (int|bool) $user Logged User ID
	 *
	 * @return (int|bool)
	 */
	public function determine_current_user( $user ) {
		/**
		 * This hook only should run on the REST API requests to determine
		 * if the user in the Token (if any) is valid, for any other
		 * normal call ex. wp-admin/.* return the user.
		 *
		 * @since 1.2.3
		 **/
		$rest_api_slug = rest_get_url_prefix();
		$requested_url = sanitize_url( $_SERVER['REQUEST_URI'] ); // phpcs:ignore
		// if we already have a valid user, or we have an invalid url, don't attempt to validate token
		$is_rest_request_constant_defined = defined( 'REST_REQUEST' ) && REST_REQUEST;
		$is_rest_request                  = $is_rest_request_constant_defined || strpos(
            $requested_url,
            $rest_api_slug
        );
		if ( $is_rest_request && $user ) {
			return $user;
		}

		/*
		 * if the request URI is for validate the token don't do anything,
		 * this avoids double calls.
		 */
		$validate_uri = strpos( $requested_url, 'token/validate' );
		if ( $validate_uri > 0 ) {
			return $user;
		}

		/**
		 * We still need to get the Authorization header and check for the token.
		 */
		$auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ? sanitize_text_field( $_SERVER['HTTP_AUTHORIZATION'] ) : false; // phpcs:ignore
		/* Double check for different auth header string (server dependent) */
		if ( ! $auth_header ) {
			$auth_header = ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ? sanitize_text_field( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) : false; // phpcs:ignore
		}

		if ( ! $auth_header ) {
			return $user;
		}

		/**
		 * Check if the auth header is not bearer, if so, return the user
		 */
		if ( strpos( $auth_header, 'Bearer' ) !== 0 ) {
			return $user;
		}

		/*
		 * Check the token from the headers.
		 */
		$token = $this->validate_token_for_request( new WP_REST_Request(), $auth_header );

		if ( is_wp_error( $token ) ) {
			if ( $token->get_error_code() !== 'jwt_auth_no_auth_header' ) {
				/** If there is a
                 * n error, store it to show it after see rest_pre_dispatch */
				$this->jwt_error = $token;
			}

			return $user;
		}

		/** Everything is ok, return the user ID stored in the token*/
		return $token->data->user->id;
	}

    public function get_token_from_request( ?WP_REST_Request $request ) {
        if ( $request ) {
            $auth_header = $request->get_header( 'Authorization' );
        } else {
            $headers = getallheaders();
            $auth_header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
        }

		if ( ! $auth_header ) {
			return new WP_Error(
				'jwt_auth_no_auth_header',
				'Authorization header not found.',
				[
					'status' => 403,
				]
			);
		}

		/*
		 * Extract the authorization header
		 */
		[ $token ] = sscanf( $auth_header, 'Bearer %s' );

        return $token;
    }

    public function validate_token_for_request( ?WP_REST_Request $request, $custom_token = false ) {
        /*
		 * Looking for the Authorization header
		 *
		 * There is two ways to get the authorization token
		 *  1. via WP_REST_Request
		 *  2. via custom_token, we get this for all the other API requests
		 *
		 * The get_header( 'Authorization' ) checks for the header in the following order:
		 * 1. HTTP_AUTHORIZATION
		 * 2. REDIRECT_HTTP_AUTHORIZATION
		 *
		 * @see https://core.trac.wordpress.org/ticket/47077
		 */

        $token = $custom_token ? $custom_token : $this->get_token_from_request( $request );

        $token = $this->validate_token( $token );

        if ( is_wp_error( $token ) ) {
            return $token;
        }

        /** Everything looks good return the decoded token if we are using the custom_token */
        if ( $custom_token ) {
            return $token;
        }

        /** This is for the /toke/validate endpoint*/
        return [
            'code' => 'jwt_auth_valid_token',
            'data' => [
                'status' => 200,
            ],
        ];
    }

	/**
	 * Main validation function
	 *
	 * This function is used by the /token/validate endpoint and
	 * by our middleware.
	 *
	 * The function take the token and try to decode it and validated it.
	 *
	 * @param string $token
	 *
	 * @return WP_Error |\stdClass The JWT's payload as a PHP object
	 */
	public function validate_token( $token ) {

		/** Surface upstream resolution errors (e.g. missing Authorization header). */
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		/**
		 * If the format is not valid return an error.
		 */
		if ( ! is_string( $token ) || '' === $token ) {
			return new WP_Error(
				'jwt_auth_bad_auth_header',
				'Authorization header malformed.',
				[
					'status' => 403,
				]
			);
		}

		/** Get the Secret Key */
		$secret_key = $this->get_secret_key();

		if ( ! $secret_key ) {
			return new WP_Error(
				'jwt_auth_bad_config',
				'JWT is not configured properly, please contact the admin',
				[
					'status' => 403,
				]
			);
		}

		/** Try to decode the token */
		try {
			$algorithm = $this->get_algorithm();
			if ( $algorithm === false ) {
				return new WP_Error(
					'jwt_auth_unsupported_algorithm',
					__(
                        'Algorithm not supported, see https://www.rfc-editor.org/rfc/rfc7518#section-3',
                        'app-bridge'
                    ),
					[
						'status' => 403,
					]
				);
			}

			$token = $this->decode( $token, $secret_key, $algorithm );

            if ( is_wp_error( $token ) ) {
                return $token;
            }

			/** The Token is decoded now validate the iss */
			if ( $token->iss !== get_bloginfo( 'url' ) ) {
				/** The iss do not match, return error */
				return new WP_Error(
					'jwt_auth_bad_iss',
					// translators: 1. The iss in the token, 2. The current site url.
					sprintf( __( 'The iss %1$s do not match with this server %2$s', 'app-bridge' ), $token->iss, get_bloginfo( 'url' ) ),
					[
						'status' => 403,
					]
				);
			}

			/** So far so good, validate the user id in the token */
			if ( ! isset( $token->data->user->id ) ) {
				/** No user id in the token, abort!! */
				return new WP_Error(
					'jwt_auth_bad_request',
					'User ID not found in the token',
					[
						'status' => 403,
					]
				);
			}

            return $token;
		} catch ( Exception $e ) {
			/** Something were wrong trying to decode the token, send back the error */
			return new WP_Error(
				'jwt_auth_invalid_token',
				$e->getMessage(),
				[
					'status' => 403,
				]
			);
		}
	}

	/**
	 * Filter to hook the rest_pre_dispatch, if the is an error in the request
	 * send it, if there is no error just continue with the current request.
	 *
	 * @param $request
	 *
	 * @return mixed|WP_Error|null
	 */
	public function rest_pre_dispatch( $request ) {
		if ( is_wp_error( $this->jwt_error ) ) {
			return $this->jwt_error;
		}

		return $request;
	}

	/**
	 * Get the algorithm used to sign the token via the filter jwt_auth_algorithm.
	 * and validate that the algorithm is in the supported list.
	 *
	 * @return false|mixed|null
	 */
	private function get_algorithm() {
		$algorithm = apply_filters( 'jwt_auth_algorithm', 'HS256' );

		if ( ! in_array( $algorithm, $this->supported_algorithms, true ) ) {
			return false;
		}

		return $algorithm;
	}
}
