<?php
namespace App\Model\Behavior;

use ArrayObject;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\Behavior;
use Cake\ORM\Entity;
use Cake\ORM\Query;
use Cake\Utility\Text;
use Cake\Utility\Security;
use Cake\Utility\Hash;
use \Cake\Http\Session;
use Cake\Core\Configure;
use Cake\Http\Client;
use Cake\Http\Client\FormData;
use Cake\Http\Exception\NotFoundException;
use Cake\Utility\Inflector;

class AuthKeycloakBehavior extends Behavior
{

    public function getMappedFieldList(): array
    {
        $mappers = Configure::read('keycloak.user_meta_mapping');
        if (empty($mappers)) {
            return [];
        }
        return explode(',', $mappers);
    }

    public function getUser(EntityInterface $profile, Session $session)
    {
        $userId = $session->read('Auth.User.id');
        if ($userId) {
            return $this->_table->get($userId);
        }

        $raw_profile_payload = $profile->access_token->getJwt()->getPayload();
        $user = $this->extractProfileData($raw_profile_payload);
        if (!$user) {
            throw new \RuntimeException('Unable to authenticate user. The KeyCloak and Cerebrate states of the user differ. This could be due to a missing synchronisation of the data.');
        }

        return $user;
    }

    private function extractProfileData($profile_payload)
    {
        $mapping = Configure::read('keycloak.mapping');
        $fields = [
            'username' => 'preferred_username',
            'email' => 'email',
            'first_name' => 'given_name',
            'last_name' => 'family_name'
        ];
        foreach ($fields as $field => $default) {
            if (!empty($mapping[$field])) {
                $fields[$field] = $mapping[$field];
            }
        }
        $existingUser = $this->_table->find()
            ->where(['username' => $profile_payload[$fields['username']]])
            ->contain('Individuals')
            ->first();
        if (mb_strtolower($existingUser['individual']['email']) !== mb_strtolower($profile_payload[$fields['email']])) {
            return false;
        }
        return $existingUser;
    }

    /*
     * Run a rest query against keycloak
     * Auto sets the headers and uses a sprintf string to build the URL, injecting the baseurl + realm into the $pathString
     */
    private function restApiRequest(string $pathString, array $payload, string $postRequestType = 'post'): Object
    {
        $token = $this->getAdminAccessToken();
        $keycloakConfig = Configure::read('keycloak');
        $http = new Client();
        $url = sprintf(
            $pathString,
            $keycloakConfig['provider']['baseUrl'],
            $keycloakConfig['provider']['realm']
        );
        return $http->$postRequestType(
            $url,
            json_encode($payload),
            [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . $token
                ]
            ]
        );
    }

    public function getUserIdByUsername(string $username)
    {
        $response = $this->restApiRequest(
            '%s/admin/realms/%s/users/?username=' . $this->urlencodeEscapeForSprintf($username),
            [],
            'GET'
        );
        if (!$response->isOk()) {
            $responseBody = json_decode($response->getStringBody(), true);
            $this->_table->auditLogs()->insert([
                'request_action' => 'keycloakGetUser',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('Failed to fetch user ({0}) from keycloak', $username),
                'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']]
            ]);
        }
        $responseBody = json_decode($response->getStringBody(), true);
        if (empty($responseBody[0]['id'])) {
            return false;
        }
        return $responseBody[0]['id'];
    }

    public function deleteUser($data): bool
    {
        $userId = $this->getUserIdByUsername($data['username']);
        if ($userId === false) {
            $this->_table->auditLogs()->insert([
                'request_action' => 'keycloakUserDeletion',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('User {0} not found in keycloak, deleting the user locally.', $data['username']),
                'changed' => []
            ]);
            return true;
        }
        $response = $this->restApiRequest(
            '%s/admin/realms/%s/users/' . urlencode($userId),
            [],
            'delete'
        );
        if (!$response->isOk()) {
            $responseBody = json_decode($response->getStringBody(), true);
            $this->_table->auditLogs()->insert([
                'request_action' => 'keycloakUserDeletion',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('Failed to delete user {0} ({1}) in keycloak', $data['username'], $userId),
                'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']]
            ]);
            return false;
        }
        return true;
    }

    public function enrollUser($data): bool
    {
        $roleConditions = [
            'id' => $data['role_id']
        ];
        $user = [
            'username' => $data['username'],
            'disabled' => false,
            'individual' => $this->_table->Individuals->find()->where(
                [
                    'id' => $data['individual_id']
                ]
            )->first(),
            'role' => $this->_table->Roles->find()->where($roleConditions)->first(),
            'organisation' => $this->_table->Organisations->find()->where(
                [
                    'id' => $data['organisation_id']
                ]
            )->first()
        ];
        $clientId = $this->getClientId();
        $newUserId = $this->createUser($user, $clientId);
        if (!$newUserId) {
            $logChange = [
                'username' => $user['username'],
                'individual_id' => $user['individual']['id'],
                'role_id' => $user['role']['id']
            ];
            $this->_table->auditLogs()->insert([
                'request_action' => 'enrollUser',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('Failed Keycloak enrollment for user {0}', $user['username']),
                'changed' => $logChange
            ]);
        } else {
            $logChange = [
                'username' => $user['username'],
                'individual_id' => $user['individual']['id'],
                'role_id' => $user['role']['id']
            ];
            $this->_table->auditLogs()->insert([
                'request_action' => 'enrollUser',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('Successful Keycloak enrollment for user {0}', $user['username']),
                'changed' => $logChange
            ]);
            $saved_user = $this->getCerebrateUsers($user['id']);
            $clientId = $this->getClientId();
            $this->syncUsers($saved_user, $clientId);
            $response = $this->restApiRequest(
                '%s/admin/realms/%s/users/' . urlencode($newUserId) . '/execute-actions-email',
                ['UPDATE_PASSWORD'],
                'put'
            );
            if (!$response->isOk()) {
                $responseBody = json_decode($response->getStringBody(), true);
                $this->_table->auditLogs()->insert([
                    'request_action' => 'keycloakWelcomeEmail',
                    'model' => 'User',
                    'model_id' => 0,
                    'model_title' => __('Failed to send welcome mail to user ({0}) in keycloak', $user['username']),
                    'changed' => ['error' => empty($responseBody['errorMessage']) ? 'Unknown error.' : $responseBody['errorMessage']]
                ]);
            }
        }
        return true;
    }

    /**
     * handleUserUpdate
     *
     * @param \App\Model\Entity\User $user
     * @return array Containing changes if successful
     */
    public function handleUserUpdate(\App\Model\Entity\User $user): array
    {
        $user['individual'] = $this->_table->Individuals->find()->where([
            'id' => $user['individual_id']
        ])->first();
        $user['role'] = $this->_table->Roles->find()->where([
             'id' => $user['role_id']
        ])->first();
        $user['organisation'] = $this->_table->Organisations->find()->where([
            'id' => $user['organisation_id']
        ])->first();

        $users = [$user->toArray()];
        $clientId = $this->getClientId();
        $changes = $this->syncUsers($users, $clientId);
        return $changes;
    }

    public function keyCloaklogout(): string
    {
        $keycloakConfig = Configure::read('keycloak');
        $logoutUrl = sprintf(
            '%s/realms/%s/protocol/openid-connect/logout?redirect_uri=%s',
            $keycloakConfig['provider']['baseUrl'],
            $keycloakConfig['provider']['realm'],
            urlencode(Configure::read('App.fullBaseUrl'))
        );
        return $logoutUrl;
    }

    private function getAdminAccessToken()
    {
        $keycloakConfig = Configure::read('keycloak');
        $http = new Client();
        $tokenUrl = sprintf(
            '%s/realms/%s/protocol/openid-connect/token',
            $keycloakConfig['provider']['baseUrl'],
            $keycloakConfig['provider']['realm']
        );
        $response = $http->post(
            $tokenUrl,
            sprintf(
                'grant_type=client_credentials&client_id=%s&client_secret=%s',
                urlencode(Configure::read('keycloak.provider.applicationId')),
                urlencode(Configure::read('keycloak.provider.applicationSecret'))
            ),
            [
                'headers' => [
                    'Content-Type' => 'application/x-www-form-urlencoded'
                ]
            ]
        );
        $parsedResponse = json_decode($response->getStringBody(), true);
        return $parsedResponse['access_token'];
    }

    private function getClientId(): string
    {
        $response = $this->restApiRequest('%s/admin/realms/%s/clients?clientId=' . Configure::read('keycloak.provider.applicationId'), [], 'get');
        $clientId = json_decode($response->getStringBody(), true);
        if (!empty($clientId[0]['id'])) {
            return $clientId[0]['id'];
        } else {
            throw new NotFoundException(__('Keycloak client ID not found or service account doesn\'t have the "view-clients" privilege.'));
        }
    }

    public function syncWithKeycloak(): array
    {
        $this->updateMappers();
        $results = [];
        $data['Users'] = $this->getCerebrateUsers();
        $clientId = $this->getClientId();
        return $this->syncUsers($data['Users'], $clientId);
    }

    private function syncUsers(array $users, $clientId): array
    {
        $keycloakUsersParsed = $this->getParsedKeycloakUser();
        $changes = [
            'created' => [],
            'modified' => [],
        ];
        foreach ($users as &$user) {
            try {
                if (empty($keycloakUsersParsed[$user['username']])) {
                    if ($this->createUser($user, $clientId)) {
                        $changes['created'][] = $user['username'];
                    }
                } else {
                    if ($this->checkAndUpdateUser($keycloakUsersParsed[$user['username']], $user)) {
                        $changes['modified'][] = $user['username'];
                    }
                }
            } catch (\Exception $e) {
                $this->_table->auditLogs()->insert([
                    'request_action' => 'syncUsers',
                    'model' => 'User',
                    'model_id' => 0,
                    'model_title' => __('Failed to create or modify user ({0}) in keycloak', $user['username']),
                    'changed' => [
                        'message' => $e->getMessage(),
                    ]
                ]);
            }
        }
        return $changes;
    }

    public function getParsedKeycloakUser(): array
    {
        $response = $this->restApiRequest('%s/admin/realms/%s/users/?max=999999', [], 'get');
        $keycloakUsers = json_decode($response->getStringBody(), true);
        $keycloakUsersParsed = [];
        $mappers = array_merge(['role_name', 'role_uuid', 'org_uuid', 'org_name'], $this->getMappedFieldList());
        foreach ($keycloakUsers as $u) {
            $attributes = [];
            $keycloakUsersParsed[$u['username']] = [
                'id' => $u['id'],
                'username' => $u['username'],
                'enabled' => $u['enabled'],
                'firstName' => $u['firstName'],
                'lastName' => $u['lastName'],
                'email' => $u['email'],
                'attributes' => []
            ];
            foreach ($mappers as $mapper) {
                $keycloakUsersParsed[$u['username']]['attributes'][$mapper] = $u['attributes'][$mapper][0] ?? '';
            }
        }
        return $keycloakUsersParsed;
    }

    private function getCerebrateUsers($id = null): array
    {
        $metaFieldsSelector = ['fields' => ['MetaFields.field', 'MetaFields.parent_id', 'MetaFields.value']];
        $query = $this->_table->find()->contain(['Individuals', 'Organisations', 'Roles', 'MetaFields' => $metaFieldsSelector])->select([
            'id',
            'uuid',
            'username',
            'disabled',
            'Individuals.email',
            'Individuals.first_name',
            'Individuals.last_name',
            'Individuals.uuid',
            'Roles.name',
            'Roles.uuid',
            'Organisations.name',
            'Organisations.uuid'
        ]);
        if ($id) {
            $query->where(['User.id' => $id]);
        }
        $results = $query->disableHydration()->toArray();
        foreach ($results as &$result) {
            if (!empty($result['meta_fields'])) {
                $temp = [];
                foreach ($result['meta_fields'] as $meta_field) {
                    $temp[$meta_field['field']] = $meta_field['value'];
                }
                $result['meta_fields'] = $temp;
            }
        }
        return $results;
    }

    private function checkAndUpdateUser(array $keycloakUser, array $user): bool
    {
        if (!empty($user['meta_fields'][0])) {
            $temp = [];
            foreach ($user['meta_fields'] as $meta_field) {
                $temp[$meta_field['field']] = $meta_field['value'];
            }
            $user['meta_fields'] = $temp;
        }
        if ($this->checkKeycloakUserRequiresUpdate($keycloakUser, $user)) {
            $default_mappers = [
                'role' => [
                    'uuid',
                    'name'
                ],
                'organisation' => [
                    'uuid',
                    'name'
                ]
            ];
            $custom_mappers = $this->getMappedFieldList();
            $change = [
                'enabled' => !$user['disabled'],
                'firstName' => $user['individual']['first_name'],
                'lastName' => $user['individual']['last_name'],
                'email' => $user['individual']['email'],
                'attributes' => []
            ];
            foreach ($default_mappers as $object_type => $object_data) {
                foreach ($object_data as $mapper) {
                    $object_type_kc = $object_type === 'organisation' ? 'org' : $object_type;
                    $change['attributes'][$object_type_kc . '_' . $mapper] = $user[$object_type][$mapper];
                }
            }
            foreach ($custom_mappers as $mapper) {
                $change['attributes'][$mapper] = $user['meta_fields'][$mapper] ?? '';
            }
            $response = $this->restApiRequest('%s/admin/realms/%s/users/' . $keycloakUser['id'], $change, 'put');
            if (!$response->isOk()) {
                $this->_table->auditLogs()->insert([
                    'request_action' => 'keycloakUpdateUser',
                    'model' => 'User',
                    'model_id' => 0,
                    'model_title' => __('Failed to update user ({0}) in keycloak', $user['username']),
                    'changed' => [
                        'code' => $response->getStatusCode(),
                        'error_body' => $response->getStringBody()
                    ]
                ]);
            } else {
                return true;
            }
        }
        return false;
    }

    public function checkKeycloakStatus(array $users, array $keycloakUsers): array
    {
        $users = Hash::combine($users, '{n}.username', '{n}');
        $keycloakUsersParsed = Hash::combine($keycloakUsers, '{n}.username', '{n}');
        $status = [];
        foreach ($users as $username => $user) {
            $temp = [];
            foreach ($user['meta_fields'] as $meta_field) {
                $temp[$meta_field['field']] = $meta_field['value'];
            }
            $user['meta_fields'] = $temp;
            $differences = [];
            $keycloakUser = $keycloakUsersParsed[$username] ?? [];
            if (empty($keycloakUser)) {
                $requireUpdate = true;
                $differences = [
                    'user' => [
                        'keycloak' => __('ERROR or USER NOT FOUND'),
                        'cerebrate' => $user['username']
                    ]
                ];
            } else {
                $requireUpdate = $this->checkKeycloakUserRequiresUpdate($keycloakUser, $user, $differences);
            }
            $status[$user['id']] = [
                'require_update' => $requireUpdate,
                'differences' => $differences,
            ];
        }
        return $status;
    }

    private function checkKeycloakUserRequiresUpdate(array $keycloakUser, array $user, array &$differences = []): bool
    {   
        $mappedFields = $this->getMappedFieldList();
        $condEnabled = $keycloakUser['enabled'] == $user['disabled'];
        $condFirstname = mb_strtolower($keycloakUser['firstName']) !== mb_strtolower($user['individual']['first_name']);
        $condLastname = mb_strtolower($keycloakUser['lastName']) !== mb_strtolower($user['individual']['last_name']);
        $condEmail = mb_strtolower($keycloakUser['email']) !== mb_strtolower($user['individual']['email']);
        $condRolename = (empty($keycloakUser['attributes']['role_name']) || mb_strtolower($keycloakUser['attributes']['role_name']) !== mb_strtolower($user['role']['name']));
        $condRoleuuid = (empty($keycloakUser['attributes']['role_uuid']) || mb_strtolower($keycloakUser['attributes']['role_uuid']) !== mb_strtolower($user['role']['uuid']));
        $condOrgname = (empty($keycloakUser['attributes']['org_name']) || mb_strtolower($keycloakUser['attributes']['org_name']) !== mb_strtolower($user['organisation']['name']));
        $condOrguuid = (empty($keycloakUser['attributes']['org_uuid']) || mb_strtolower($keycloakUser['attributes']['org_uuid']) !== mb_strtolower($user['organisation']['uuid']));
        $condMapped = false;
        if (!empty($user['meta_fields']) && isset($user['meta_fields'][0])) {
            $temp = [];
            foreach ($user['meta_fields'] as $meta_field) {
                $temp[$meta_field['field']] = $meta_field['value'];
            }
            $user['meta_fields'] = $temp;
        }
        foreach ($mappedFields as $mappedField) {
            if (($keycloakUser['attributes'][$mappedField] ?? '') != ($user['meta_fields'][$mappedField] ?? '')) {
                $condMapped = true;
                $differences[$mappedField] = [
                    'keycloak' => $keycloakUser['attributes'][$mappedField] ?? '',
                    'cerebrate' => $user['meta_fields'][$mappedField] ?? ''
                ];
            }
        }
        if ($condEnabled || $condFirstname || $condLastname || $condEmail || $condRolename || $condRoleuuid || $condOrgname || $condOrguuid || $condMapped) {
            if ($condEnabled) {
                $differences['enabled'] = ['keycloak' => $keycloakUser['enabled'], 'cerebrate' => $user['disabled']];
            }
            if ($condFirstname) {
                $differences['first_name'] = ['keycloak' => $keycloakUser['firstName'], 'cerebrate' => $user['individual']['first_name']];
            }
            if ($condLastname) {
                $differences['last_name'] = ['keycloak' => $keycloakUser['lastName'], 'cerebrate' => $user['individual']['last_name']];
            }
            if ($condEmail) {
                $differences['email'] = ['keycloak' => $keycloakUser['email'], 'cerebrate' => $user['individual']['email']];
            }
            if ($condRolename) {
                $differences['role_name'] = ['keycloak' => $keycloakUser['attributes']['role_name'], 'cerebrate' => $user['role']['name']];
            }
            if ($condRoleuuid) {
                $differences['role_uuid'] = ['keycloak' => $keycloakUser['attributes']['role_uuid'], 'cerebrate' => $user['role']['uuid']];
            }
            if ($condOrgname) {
                $differences['org_name'] = ['keycloak' => $keycloakUser['attributes']['org_name'], 'cerebrate' => $user['organisation']['name']];
            }
            if ($condOrguuid) {
                $differences['org_uuid'] = ['keycloak' => $keycloakUser['attributes']['org_uuid'], 'cerebrate' => $user['organisation']['uuid']];
            }
            return true;
        }
        return false;
    }

    private function createUser(array $user, string $clientId)
    {
        $newUser = [
            'username' => $user['username'],
            'enabled' => !$user['disabled'],
            'firstName' => $user['individual']['first_name'],
            'lastName' => $user['individual']['last_name'],
            'email' => $user['individual']['email'],
            'attributes' => [
                'role_name' => $user['role']['name'],
                'role_uuid' => $user['role']['uuid'],
                'org_name' => $user['organisation']['name'],
                'org_uuid' => $user['organisation']['uuid']
            ]
        ];
        $response = $this->restApiRequest('%s/admin/realms/%s/users', $newUser, 'post');
        if (!$response->isOk()) {
            $this->_table->auditLogs()->insert([
                'request_action' => 'createUser',
                'model' => 'User',
                'model_id' => 0,
                'model_title' => __('Failed to create user ({0}) in keycloak {0}', $user['username']),
                'changed' => [
                    'code' => $response->getStatusCode(),
                    'error_body' => $response->getStringBody()
                ]
            ]);
        }
        $newUser = $this->restApiRequest(
            '%s/admin/realms/%s/users?username=' . $this->urlencodeEscapeForSprintf(urlencode($user['username'])),
            [],
            'get'
        );
        $users = json_decode($newUser->getStringBody(), true);
        if (empty($users[0]['id'])) {
            return false;
        }
        if (is_array($users[0]['id'])) {
            $users[0]['id'] = $users[0]['id'][0];
        }
        $user['id'] = $users[0]['id'];
        return $user['id'];
    }

    private function urlencodeEscapeForSprintf(string $input): string
    {
        return str_replace('%', '%%', $input);
    }

    public function updateMappers(): bool
    {
        $clientId = $this->getClientId();
        $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/models?protocolMapper=oidc-usermodel-attribute-mapper', [], 'get');
        if ($response->isOk()) {
            $mappers = json_decode($response->getStringBody(), true);
        } else {
            return false;
        }
        $enabledMappers = [];
        $defaultMappers = [
            'org_name' => 0,
            'org_uuid' => 0,
            'role_name' => 0,
            'role_uuid' => 0
        ];
        $mappersToEnable = explode(',', Configure::read('keycloak.user_meta_mapping'));
        foreach ($mappers as $mapper) {
            if ($mapper['protocolMapper'] !== 'oidc-usermodel-attribute-mapper') {
                continue;
            }
            if (in_array($mapper['name'], array_keys($defaultMappers))) {
                $defaultMappers[$mapper['name']] = 1;
                continue;
            }
            $enabledMappers[$mapper['name']] = $mapper;
        }
        $payload = [];
        foreach ($mappersToEnable as $mapperToEnable) {
            $payload[] = [
                'protocol' => 'openid-connect',
                'name' => $mapperToEnable,
                'protocolMapper' => 'oidc-usermodel-attribute-mapper',
                'config' => [
                    'id.token.claim' => true,
                    'access.token.claim' => true,
                    'userinfo.token.claim' => true,
                    'user.attribute' => $mapperToEnable,
                    'claim.name' => $mapperToEnable
                ]
            ];
        }
        foreach ($defaultMappers as $defaultMapper => $enabled) {
            if (!$enabled) {
                $payload[] = [
                    'protocol' => 'openid-connect',
                    'name' => $defaultMapper,
                    'protocolMapper' => 'oidc-usermodel-attribute-mapper',
                    'config' => [
                        'id.token.claim' => true,
                        'access.token.claim' => true,
                        'userinfo.token.claim' => true,
                        'user.attribute' => $defaultMapper,
                        'claim.name' => $defaultMapper
                    ]
                ];
            }
        }
        if (!empty($payload)) {
            $response = $this->restApiRequest('%s/admin/realms/%s/clients/' . $clientId . '/protocol-mappers/add-models', $payload, 'post');
            if (!$response->isOk()) {
                return false;
            }
        }
        return true;
    }
}
