<?php namespace Services\OAuth2;
/**
 * Copyright 2016 OpenStack Foundation
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * http://www.apache.org/licenses/LICENSE-2.0
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 **/

use Auth\Repositories\IUserRepository;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Input;
use Illuminate\Support\Facades\Request;
use Models\OAuth2\Client;
use OAuth2\Exceptions\AbsentClientException;
use OAuth2\Exceptions\InvalidApiScope;
use OAuth2\Exceptions\InvalidClientAuthMethodException;
use OAuth2\Exceptions\InvalidClientType;
use OAuth2\Exceptions\MissingClientAuthorizationInfo;
use OAuth2\Factories\IOAuth2ClientFactory;
use OAuth2\Models\ClientAssertionAuthenticationContext;
use OAuth2\Models\ClientAuthenticationContext;
use OAuth2\Models\ClientCredentialsAuthenticationContext;
use OAuth2\Models\IClient;
use OAuth2\OAuth2Protocol;
use OAuth2\Repositories\IApiScopeRepository;
use OAuth2\Repositories\IClientRepository;
use OAuth2\Services\IApiScopeService;
use OAuth2\Services\IClientCredentialGenerator;
use OAuth2\Services\IClientService;
use Services\Exceptions\ValidationException;
use URL\Normalizer;
use Utils\Db\ITransactionService;
use Utils\Exceptions\EntityNotFoundException;
use Utils\Http\HttpUtils;
use Utils\Services\IAuthService;

/**
 * Class ClientService
 * @package Services\OAuth2
 */
class ClientService implements IClientService
{
    /**
     * @var IAuthService
     */
    private $auth_service;
    /**
     * @var IApiScopeService
     */
    private $scope_service;
    /**
     * @var IUserRepository
     */
    private $user_repository;
    /**
     * @var IClientCredentialGenerator
     */
    private $client_credential_generator;

    /**
     * @var IClientRepository
     */
    private $client_repository;

    /**
     * @var IOAuth2ClientFactory
     */
    private $client_factory;

    /**
     * @var IApiScopeRepository
     */
    private $scope_repository;

    /**
     * ClientService constructor.
     * @param IUserRepository $user_repository
     * @param IClientRepository $client_repository
     * @param IAuthService $auth_service
     * @param IApiScopeService $scope_service
     * @param IClientCredentialGenerator $client_credential_generator
     * @param IOAuth2ClientFactory $client_factory
     * @param IApiScopeRepository $scope_repository
     * @param ITransactionService $tx_service
     */
    public function __construct
    (
        IUserRepository             $user_repository,
        IClientRepository           $client_repository,
        IAuthService                $auth_service,
        IApiScopeService            $scope_service,
        IClientCredentialGenerator  $client_credential_generator,
        IOAuth2ClientFactory        $client_factory,
        IApiScopeRepository         $scope_repository,
        ITransactionService         $tx_service
    )
    {
        $this->auth_service                = $auth_service;
        $this->user_repository             = $user_repository;
        $this->scope_service               = $scope_service;
        $this->client_credential_generator = $client_credential_generator;
        $this->client_repository           = $client_repository;
        $this->scope_repository            = $scope_repository;
        $this->client_factory              = $client_factory;
        $this->tx_service                  = $tx_service;
    }


    /**
     * Clients in possession of a client password MAY use the HTTP Basic
     * authentication scheme as defined in [RFC2617] to authenticate with
     * the authorization server
     * Alternatively, the authorization server MAY support including the
     * client credentials in the request-body using the following
     * parameters:
     * implementation of @see http://tools.ietf.org/html/rfc6749#section-2.3.1
     * implementation of @see http://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication
     * @throws InvalidClientAuthMethodException
     * @throws MissingClientAuthorizationInfo
     * @return ClientAuthenticationContext
     */
    public function getCurrentClientAuthInfo()
    {

        $auth_header = Request::header('Authorization');

        if
        (
            Input::has( OAuth2Protocol::OAuth2Protocol_ClientAssertionType) &&
            Input::has( OAuth2Protocol::OAuth2Protocol_ClientAssertion)
        )
        {
            return new ClientAssertionAuthenticationContext
            (
                Input::get(OAuth2Protocol::OAuth2Protocol_ClientAssertionType, ''),
                Input::get(OAuth2Protocol::OAuth2Protocol_ClientAssertion, '')
            );
        }
        if
        (
            Input::has( OAuth2Protocol::OAuth2Protocol_ClientId) &&
            Input::has( OAuth2Protocol::OAuth2Protocol_ClientSecret)
        )
        {
            return new ClientCredentialsAuthenticationContext
            (
                Input::get(OAuth2Protocol::OAuth2Protocol_ClientId, ''),
                Input::get(OAuth2Protocol::OAuth2Protocol_ClientSecret, ''),
                OAuth2Protocol::TokenEndpoint_AuthMethod_ClientSecretPost
            );
        }
        if(!empty($auth_header))
        {
            $auth_header = trim($auth_header);
            $auth_header = explode(' ', $auth_header);

            if (!is_array($auth_header) || count($auth_header) < 2)
            {
                throw new MissingClientAuthorizationInfo('bad auth header.');
            }

            $auth_header_content = $auth_header[1];
            $auth_header_content = base64_decode($auth_header_content);
            $auth_header_content = explode(':', $auth_header_content);

            if (!is_array($auth_header_content) || count($auth_header_content) !== 2)
            {
                throw new MissingClientAuthorizationInfo('bad auth header.');
            }

            return new ClientCredentialsAuthenticationContext(
                $auth_header_content[0],
                $auth_header_content[1],
                OAuth2Protocol::TokenEndpoint_AuthMethod_ClientSecretBasic
            );
        }

        throw new InvalidClientAuthMethodException;
    }

    /**
     * @param string $application_type
     * @param string $app_name
     * @param string $app_description
     * @param null|string  $app_url
     * @param array $admin_users
     * @param string $app_logo
     * @return IClient
     * @throws ValidationException
     */
    public function register
    (
        $application_type,
        $app_name,
        $app_description,
        $app_url           = null,
        array $admin_users = [],
        $app_logo          = ''
    )
    {
        $scope_service               = $this->scope_service;
        $client_credential_generator = $this->client_credential_generator;
        $user_repository             = $this->user_repository;
        $client_repository           = $this->client_repository;
        $client_factory              = $this->client_factory;
        $current_user                = $this->auth_service->getCurrentUser();

        return $this->tx_service->transaction(function () use (
            $application_type,
            $current_user,
            $app_name,
            $app_url,
            $app_description,
            $app_logo,
            $admin_users,
            $scope_service,
            $user_repository,
            $client_repository,
            $client_factory,
            $client_credential_generator
        ) {

            if($this->client_repository->getByApplicationName($app_name) != null){
                throw new ValidationException('there is already another application with that name, please choose another one.');
            }

            $client = $client_factory->build($app_name, $current_user, $application_type);
            $client = $client_credential_generator->generate($client);

            $client->app_logo         = $app_logo;
            $client->app_description  = $app_description;
            $client->website          = $app_url;

            $client_repository->add($client);
            //add default scopes

            foreach ($this->scope_repository->getDefaults() as $default_scope) {
                if
                (
                    $default_scope->name === OAuth2Protocol::OfflineAccess_Scope &&
                    !(
                        $client->application_type == IClient::ApplicationType_Native ||
                        $client->application_type == IClient::ApplicationType_Web_App
                    )
                ) {
                    continue;
                }
                $client->addScope($default_scope);
            }

            //add admin users
            foreach($admin_users as $user_id)
            {
                $user = $user_repository->get(intval($user_id));
                if(is_null($user)) throw new EntityNotFoundException(sprintf('user %s not found.',$user_id));
                $client->addAdminUser($user);
            }

            return $client;
        });
    }


    /**
     * @param int $id
     * @param array $params
     * @throws ValidationException
     * @throws EntityNotFoundException
     * @return IClient
     */
    public function update($id, array $params)
    {
        $client_repository = $this->client_repository;
        $user_repository   = $this->user_repository;
        $editing_user      = $this->auth_service->getCurrentUser();

        return $this->tx_service->transaction(function () use ($id, $editing_user, $params, $client_repository, $user_repository) {

            $client = $client_repository->get($id);

            if (is_null($client)) {
                throw new EntityNotFoundException(sprintf('client id %s does not exists.', $id));
            }
            $app_name   = isset($params['app_name']) ? trim($params['app_name']) : null;
            if(!empty($app_name)) {
                $old_client = $client_repository->getByApplicationName($app_name);
                if(!is_null($old_client) && $old_client->id !== $client->id)
                    throw new ValidationException('there is already another application with that name, please choose another one.');
            }
            $current_app_type = $client->getApplicationType();
            if($current_app_type !== $params['application_type'])
            {
                throw new ValidationException('application type does not match.');
            }

            // validate uris
            switch($current_app_type) {
                case IClient::ApplicationType_Native: {

                    if (isset($params['redirect_uris'])) {
                        $redirect_uris = explode(',', $params['redirect_uris']);
                        //check that custom schema does not already exists for another registerd app
                        if (!empty($params['redirect_uris'])) {
                            foreach ($redirect_uris as $uri) {
                                $uri = @parse_url($uri);
                                if (!isset($uri['scheme'])) {
                                    throw new ValidationException('invalid scheme on redirect uri.');
                                }
                                if (HttpUtils::isCustomSchema($uri['scheme'])) {
                                    $already_has_schema_registered = Client::where('redirect_uris', 'like',
                                        '%' . $uri['scheme'] . '://%')->where('id', '<>', $id)->count();
                                    if ($already_has_schema_registered > 0) {
                                        throw new ValidationException(sprintf('schema %s:// already registered for another client.',
                                            $uri['scheme']));
                                    }
                                } else {
                                    if (!HttpUtils::isHttpSchema($uri['scheme'])) {
                                        throw new ValidationException(sprintf('scheme %s:// is invalid.',
                                            $uri['scheme']));
                                    }
                                }
                            }
                        }
                    }
                }
                    break;
                case IClient::ApplicationType_Web_App:
                case IClient::ApplicationType_JS_Client: {
                    if (isset($params['redirect_uris'])){
                        if (!empty($params['redirect_uris'])) {
                            $redirect_uris = explode(',', $params['redirect_uris']);
                            foreach ($redirect_uris as $uri) {
                                $uri = @parse_url($uri);
                                if (!isset($uri['scheme'])) {
                                    throw new ValidationException('invalid scheme on redirect uri.');
                                }
                                if (!HttpUtils::isHttpsSchema($uri['scheme'])) {
                                    throw new ValidationException(sprintf('scheme %s:// is invalid.', $uri['scheme']));
                                }
                            }
                        }
                    }
                    if($current_app_type === IClient::ApplicationType_JS_Client && isset($params['allowed_origins']) &&!empty($params['allowed_origins'])){
                        $allowed_origins = explode(',', $params['allowed_origins']);
                        foreach ($allowed_origins as $uri) {
                            $uri = @parse_url($uri);
                            if (!isset($uri['scheme'])) {
                                throw new ValidationException('invalid scheme on allowed origin uri.');
                            }
                            if (!HttpUtils::isHttpsSchema($uri['scheme'])) {
                                throw new ValidationException(sprintf('scheme %s:// is invalid.', $uri['scheme']));
                            }
                        }
                    }
                }
                break;
            }

            $allowed_update_params = array(
                'app_name',
                'website',
                'app_description',
                'app_logo',
                'active',
                'locked',
                'use_refresh_token',
                'rotate_refresh_token',
                'contacts',
                'logo_uri',
                'tos_uri',
                'post_logout_redirect_uris',
                'logout_uri',
                'logout_session_required',
                'logout_use_iframe',
                'policy_uri',
                'jwks_uri',
                'default_max_age',
                'logout_use_iframe',
                'require_auth_time',
                'token_endpoint_auth_method',
                'token_endpoint_auth_signing_alg',
                'subject_type',
                'userinfo_signed_response_alg',
                'userinfo_encrypted_response_alg',
                'userinfo_encrypted_response_enc',
                'id_token_signed_response_alg',
                'id_token_encrypted_response_alg',
                'id_token_encrypted_response_enc',
                'redirect_uris',
                'allowed_origins',
                'admin_users',
            );

            $fields_to_uri_normalize = array
            (
                'post_logout_redirect_uris',
                'logout_uri',
                'policy_uri',
                'jwks_uri',
                'tos_uri',
                'logo_uri',
                'redirect_uris',
                'allowed_origins'
            );

            foreach ($allowed_update_params as $param)
            {

                if (array_key_exists($param, $params))
                {
                    if($param === 'admin_users'){
                        $admin_users = trim($params['admin_users']);
                        $admin_users = empty($admin_users) ? array():explode(',',$admin_users);
                        $client->removeAllAdminUsers();
                        foreach($admin_users as $user_id)
                        {
                            $user = $user_repository->get(intval($user_id));
                            if(is_null($user)) throw new EntityNotFoundException(sprintf('user %s not found.',$user_id));
                            $client->addAdminUser($user);
                        }
                    }
                    else {
                        if (in_array($param, $fields_to_uri_normalize)) {
                            $urls = $params[$param];
                            if (!empty($urls)) {
                                $urls = explode(',', $urls);
                                $normalized_uris = '';
                                foreach ($urls as $url) {
                                    $un = new Normalizer($url);
                                    $url = $un->normalize();
                                    if (!empty($normalized_uris)) {
                                        $normalized_uris .= ',';
                                    }
                                    $normalized_uris .= $url;
                                }
                                $params[$param] = $normalized_uris;
                            }
                        }
                        $client->{$param} = trim($params[$param]);
                    }
                }

            }
            $client_repository->add($client->setEditedBy($editing_user));
            return $client;
        });
   }

    /**
     * @param int $id
     * @param int $scope_id
     * @return IClient
     * @throws EntityNotFoundException
     */
    public function addClientScope($id, $scope_id)
    {
        return $this->tx_service->transaction(function() use ($id, $scope_id){
            $client = $this->client_repository->get($id);
            if (is_null($client)) {
                throw new EntityNotFoundException(sprintf("client id %s not found!.", $id));
            }
            $scope = $this->scope_repository->get(intval($scope_id));
            if(is_null($scope)) throw new EntityNotFoundException(sprintf("scope %s not found!.", $scope_id));
            $user         = $client->user()->first();

            if($scope->isAssignableByGroups()) {

                $allowed      = false;
                foreach($user->getGroupScopes() as $group_scope)
                {
                    if(intval($group_scope->id) === intval($scope_id))
                    {
                        $allowed = true; break;
                    }
                }
                if(!$allowed) throw new InvalidApiScope(sprintf('you cant assign to this client api scope %s', $scope_id));
            }
            if($scope->isSystem() && !$user->canUseSystemScopes())
                throw new InvalidApiScope(sprintf('you cant assign to this client api scope %s', $scope_id));
            $client->scopes()->attach($scope_id);
            $client->setEditedBy($this->auth_service->getCurrentUser());

            $this->client_repository->add($client);
            return $client;
        });
    }

    /**
     * @param $id
     * @param $scope_id
     * @return IClient
     * @throws EntityNotFoundException
     */
    public function deleteClientScope($id, $scope_id)
    {
        return $this->tx_service->transaction(function() use ($id, $scope_id){
            $client = $this->client_repository->get($id);
            if (is_null($client)) {
                throw new EntityNotFoundException(sprintf("client id %s does not exists!", $id));
            }
            $client->scopes()->detach($scope_id);
            $client->setEditedBy($this->auth_service->getCurrentUser());
            $this->client_repository->add($client);
            return $client;
        });

    }

    /**
     * @param int $id
     * @return bool
     * @throws EntityNotFoundException
     */
    public function deleteClientByIdentifier($id)
    {
        return $this->tx_service->transaction(function () use ($id) {
            $client = $this->client_repository->get($id);
            if (is_null($client)) {
                throw new EntityNotFoundException(sprintf("client id %s does not exists!", $id));
            }
            $client->scopes()->detach();
            Event::fire('oauth2.client.delete', array($client->client_id));
            $this->client_repository->delete($client);
            true;
        });
    }

    /**
     * Regenerates Client Secret
     * @param $id client id
     * @return IClient
     * @throws EntityNotFoundException
     */
    public function regenerateClientSecret($id)
    {
        $client_credential_generator = $this->client_credential_generator;
        $current_user                = $this->auth_service->getCurrentUser();

        return $this->tx_service->transaction(function () use ($id, $current_user, $client_credential_generator)
        {

            $client = $this->client_repository->get($id);

            if (is_null($client))
            {
                throw new EntityNotFoundException(sprintf("client id %d does not exists!.", $id));
            }

            if ($client->client_type != IClient::ClientType_Confidential)
            {
                throw new InvalidClientType
                (
                    sprintf
                    (
                        "client id %d is not confidential type!.",
                        $id
                    )
                );
            }

            $client = $client_credential_generator->generate($client, true);
            $client->setEditedBy($current_user);
            $this->client_repository->add($client);

            Event::fire('oauth2.client.regenerate.secret', array($client->client_id));
            return $client;
        });
    }

    /**
     * @param client $client_id
     * @return mixed
     * @throws EntityNotFoundException
     */
    public function lockClient($client_id)
    {
        return $this->tx_service->transaction(function () use ($client_id) {

            $client = $this->client_repository($client_id);
            if (is_null($client)) {
                throw new EntityNotFoundException($client_id, sprintf("client id %s does not exists!", $client_id));
            }
            $client->locked = true;
            $this->client_repository->update($client);
            return true;
        });

    }

    /**
     * @param int $id
     * @return bool
     * @throws EntityNotFoundException
     */
    public function unlockClient($id)
    {
        return $this->tx_service->transaction(function () use ($id) {

            $client = $this->client_repository->getClientByIdentifier($id);
            if (is_null($client)) {
                throw new EntityNotFoundException($id, sprintf("client id %s does not exists!", $id));
            }
            $client->locked = false;
            $this->client_repository->update($client);
            return true;
        });

    }

    /**
     * @param int $id
     * @param bool $active
     * @return bool
     * @throws EntityNotFoundException
     */
    public function activateClient($id, $active)
    {

        return $this->tx_service->transaction(function () use ($id, $active) {

            $client = $this->client_repository->getClientByIdentifier($id);
            if (is_null($client)) {
                throw new EntityNotFoundException($id, sprintf("client id %s does not exists!", $id));
            }
            $client->active = $active;
            $this->client_repository->update($client);
            return true;
        });
    }

    /**
     * @param int $id
     * @param bool $use_refresh_token
     * @return bool
     * @throws EntityNotFoundException
     */
    public function setRefreshTokenUsage($id, $use_refresh_token)
    {
        return $this->tx_service->transaction(function () use ($id, $use_refresh_token) {

            $client = $this->client_repository->getClientByIdentifier($id);
            if (is_null($client)) {
                throw new EntityNotFoundException($id, sprintf("client id %s does not exists!", $id));
            }
            $client->use_refresh_token = $use_refresh_token;
            $client->setEditedBy($this->auth_service->getCurrentUser());
            $this->client_repository->update($client);
            return true;
        });
    }

    /**
     * @param int $id
     * @param bool $rotate_refresh_token
     * @return bool
     * @throws EntityNotFoundException
     */
    public function setRotateRefreshTokenPolicy($id, $rotate_refresh_token)
    {
        return $this->tx_service->transaction(function () use ($id, $rotate_refresh_token) {

            $client = $this->client_repository->getClientByIdentifier($id);
            if (is_null($client)) {
                throw new EntityNotFoundException($id, sprintf("client id %s does not exists!", $id));
            }

            $client->rotate_refresh_token = $rotate_refresh_token;
            $client->setEditedBy($this->auth_service->getCurrentUser());
            $this->client_repository->update($client);
            return true;
        });
    }
}