<?php
namespace api\controllers;

use api\filters\ActiveUserRule;
use api\components\OAuth2\Exception\AcceptRequiredException;
use api\components\OAuth2\Exception\AccessDeniedException;
use common\models\Account;
use common\models\OauthClient;
use common\models\OauthScope;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthException;
use League\OAuth2\Server\Grant\AuthCodeGrant;
use Yii;
use yii\filters\AccessControl;
use yii\helpers\ArrayHelper;

class OauthController extends Controller {

    public function behaviors() {
        return ArrayHelper::merge(parent::behaviors(), [
            'authenticator' => [
                'only' => ['complete'],
            ],
            'access' => [
                'class' => AccessControl::class,
                'only' => ['complete'],
                'rules' => [
                    [
                        'class' => ActiveUserRule::class,
                        'actions' => ['complete'],
                    ],
                ],
            ],
        ]);
    }

    public function verbs() {
        return [
            'validate' => ['GET'],
            'complete' => ['POST'],
            'token'    => ['POST'],
        ];
    }

    /**
     * Запрос, который должен проверить переданные параметры oAuth авторизации
     * и сформировать ответ для нашего приложения на фронте
     *
     * Входными данными является стандартный список GET параметров по стандарту oAuth:
     * $_GET = [
     *     client_id,
     *     redirect_uri,
     *     response_type,
     *     scope,
     *     state,
     * ]
     *
     * Кроме того можно передать значения description для переопределения описания приложения.
     *
     * @return array|\yii\web\Response
     */
    public function actionValidate() {
        try {
            $authParams = $this->getGrantType()->checkAuthorizeParams();
            /** @var \League\OAuth2\Server\Entity\ClientEntity $client */
            $client = $authParams['client'];
            /** @var \common\models\OauthClient $clientModel */
            $clientModel = OauthClient::findOne($client->getId());
            $response = $this->buildSuccessResponse(
                Yii::$app->request->getQueryParams(),
                $clientModel,
                $authParams['scopes']
            );
        } catch (OAuthException $e) {
            $response = $this->buildErrorResponse($e);
        }

        return $response;
    }

    /**
     * Метод выполняется генерацию авторизационного кода (auth_code) и формирование ссылки
     * для дальнейшнешл редиректа пользователя назад на сайт клиента
     *
     * Входными данными является всё те же параметры, что были необходимы для валидации:
     * $_GET = [
     *     client_id,
     *     redirect_uri,
     *     response_type,
     *     scope,
     *     state,
     * ];
     * А также поле accept, которое показывает, что пользователь нажал на кнопку "Принять". Если поле присутствует,
     * то оно будет интерпретироваться как любое приводимое к false значение. В ином случае, значение будет
     * интерпретировано, как положительный исход.
     *
     * @return array|\yii\web\Response
     */
    public function actionComplete() {
        $grant = $this->getGrantType();
        try {
            $authParams = $grant->checkAuthorizeParams();
            $account = Yii::$app->user->identity;
            /** @var \League\OAuth2\Server\Entity\ClientEntity $client */
            $client = $authParams['client'];
            /** @var \common\models\OauthClient $clientModel */
            $clientModel = OauthClient::findOne($client->getId());

            if (!$this->canAutoApprove($account, $clientModel, $authParams)) {
                $isAccept = Yii::$app->request->post('accept');
                if ($isAccept === null) {
                    throw new AcceptRequiredException();
                }

                if (!$isAccept) {
                    throw new AccessDeniedException($authParams['redirect_uri']);
                }
            }

            $redirectUri = $grant->newAuthorizeRequest('user', $account->id, $authParams);
            $response = [
                'success' => true,
                'redirectUri' => $redirectUri,
            ];
        } catch (OAuthException $e) {
            $response = $this->buildErrorResponse($e);
        }

        return $response;
    }

    /**
     * Метод выполняется сервером приложения, которому был выдан auth_token или refresh_token.
     *
     * Входными данными является стандартный список GET параметров по стандарту oAuth:
     * $_GET = [
     *     client_id,
     *     client_secret,
     *     redirect_uri,
     *     code,
     *     grant_type,
     * ]
     * для запроса grant_type = authentication_code.
     * $_GET = [
     *     client_id,
     *     client_secret,
     *     refresh_token,
     *     grant_type,
     * ]
     *
     * @return array
     */
    public function actionToken() {
        $this->attachRefreshTokenGrantIfNeedle();
        try {
            $response = $this->getServer()->issueAccessToken();
        } catch (OAuthException $e) {
            Yii::$app->response->statusCode = $e->httpStatusCode;
            $response = [
                'error' => $e->errorType,
                'message' => $e->getMessage(),
            ];
        }

        return $response;
    }

    /**
     * Этот метод нужен за тем, что \League\OAuth2\Server\AuthorizationServer не предоставляет
     * метода для проверки, можно ли выдавать refresh_token для пришедшего токена. Он просто
     * выдаёт refresh_token, если этот grant присутствует в конфигурации сервера. Так что чтобы
     * как-то решить эту проблему, мы не включаем RefreshTokenGrant в базовую конфигурацию сервера,
     * а подключаем его только в том случае, если у auth_token есть право на рефреш или если это
     * и есть запрос на refresh токена.
     */
    private function attachRefreshTokenGrantIfNeedle() {
        $grantType = Yii::$app->request->post('grant_type');
        if ($grantType === 'authorization_code' && Yii::$app->request->post('code')) {
            $authCode = Yii::$app->request->post('code');
            $codeModel = $this->getServer()->getAuthCodeStorage()->get($authCode);
            if ($codeModel === null) {
                return;
            }

            $scopes = $codeModel->getScopes();
            if (array_search(OauthScope::OFFLINE_ACCESS, array_keys($scopes), true) === false) {
                return;
            }
        } elseif ($grantType === 'refresh_token') {
            // Это валидный кейс
        } else {
            return;
        }

        $grantClass = Yii::$app->oauth->grantMap['refresh_token'];
        $grant = new $grantClass;

        $this->getServer()->addGrantType($grant);
    }

    /**
     * Метод проверяет, может ли текущий пользователь быть автоматически авторизован
     * для указанного клиента без запроса доступа к необходимому списку прав
     *
     * @param Account     $account
     * @param OauthClient $client
     * @param array       $oauthParams
     *
     * @return bool
     */
    private function canAutoApprove(Account $account, OauthClient $client, array $oauthParams) : bool {
        if ($client->is_trusted) {
            return true;
        }

        /** @var \League\OAuth2\Server\Entity\ScopeEntity[] $scopes */
        $scopes = $oauthParams['scopes'];

        /** @var \common\models\OauthSession|null $session */
        $session = $account->getOauthSessions()->andWhere(['client_id' => $client->id])->one();
        if ($session !== null) {
            $existScopes = $session->getScopes()->members();
            if (empty(array_diff(array_keys($scopes), $existScopes))) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array $queryParams
     * @param OauthClient $clientModel
     * @param \League\OAuth2\Server\Entity\ScopeEntity[] $scopes
     *
     * @return array
     */
    private function buildSuccessResponse(array $queryParams, OauthClient $clientModel, array $scopes) {
        return [
            'success' => true,
            // Возвращаем только те ключи, которые имеют реальное отношение к oAuth параметрам
            'oAuth' => array_intersect_key($queryParams, array_flip([
                'client_id',
                'redirect_uri',
                'response_type',
                'scope',
                'state',
            ])),
            'client' => [
                'id' => $clientModel->id,
                'name' => $clientModel->name,
                'description' => ArrayHelper::getValue($queryParams, 'description', $clientModel->description),
            ],
            'session' => [
                'scopes' => array_keys($scopes),
            ],
        ];
    }

    private function buildErrorResponse(OAuthException $e) {
        $response = [
            'success' => false,
            'error' => $e->errorType,
            'parameter' => $e->parameter,
            'statusCode' => $e->httpStatusCode,
        ];

        if ($e->shouldRedirect()) {
            $response['redirectUri'] = $e->getRedirectUri();
        }

        if ($e->httpStatusCode !== 200) {
            Yii::$app->response->setStatusCode($e->httpStatusCode);
        }

        return $response;
    }

    private function getServer(): AuthorizationServer {
        return Yii::$app->oauth->authServer;
    }

    private function getGrantType(): AuthCodeGrant {
        /** @noinspection PhpIncompatibleReturnTypeInspection */
        return $this->getServer()->getGrantType('authorization_code');
    }

}