<?php

namespace apexl\Io\modules\user\controllers;

use apexl\hashing\Hash;
use apexl\Io\exceptions\RecordNotFoundException;
use apexl\Io\includes\Controller;
use apexl\Io\includes\Hook;
use apexl\Io\includes\Routes;
use apexl\Io\includes\System;
use apexl\Io\modules\email\services\templateService;
use apexl\Io\modules\user\entities\refreshTokenEntity;
use apexl\Io\modules\user\entities\sessionEntity;
use apexl\Io\modules\user\entities\userEntity;
use apexl\Io\modules\user\enums\permissions\Role;
use apexl\Io\modules\user\enums\permissions\User;
use apexl\Io\modules\user\exceptions\ClientCredentialsValidationException;
use apexl\Io\modules\user\exceptions\RefreshTokenExpiredException;
use apexl\Io\modules\user\exceptions\RefreshTokenUserMismatchException;
use apexl\Io\modules\user\services\currentUser;
use apexl\Io\modules\user\services\RefreshTokenSessionIdFinder;
use apexl\Io\modules\user\services\userTools;
use DateTimeImmutable;
use Exception;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;

class userController extends Controller
{
    protected userEntity $currentUser;

    public function __construct(
        currentUser $currentUser,
        protected userTools $userTools,
        readonly private RefreshTokenSessionIdFinder $sessionIdFinder
    ) {
        parent::__construct();
        $this->currentUser = $currentUser::getCurrentUser();
    }

    /**
     * @throws Exception
     */
    public function login(Request $request, Response $response): Response
    {
        $body = (object) $request->getParsedBody();
        if (isset($body->grant_type) && $body->grant_type === 'refresh_token') {
            return $this->refresh(
                $request,
                $response,
            );
        }

        //basic validation
        if (empty($body) || !isset($body->username) || !isset($body->password)) {
            $this->output::addMessage(
                'user.login.validation',
                'error',
                "Please provide a username and a password."
            );
            $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

            return System::asJson($response, [], 400);
        }

        //first, grab the user data.
        $user = new userEntity();
        $user->getUserByEmail($body->username);
        //if we have user data, grab the salt and password and check the provided one matches.
        //we also need the user to be active, so check that at the same time.
        if ($user->email !== null && $user->active == 1) {
            $hash = new Hash();
            $match = $hash->matchHash($body->password, $user->password, $user->salt);
            //if we match, complete the login process

            $match = Hook::processHook('userLogin', $match, $user, $request);
            if ($match) {
                [$authToken, $refreshToken] = $this->userTools->startLoggedInSession($user);
                //allow others to act on this response. Hook is keyed by route name.
                $this->output::addResponse(
                    $request,
                    [
                        'access_token' => $authToken,
                        'refresh_token' => $refreshToken,
                        'expires_in' => currentUser::getTokenExpiryFromNow($authToken),
                    ]
                );

                if ($redirect = ((object) $request->getParsedBody())->redirect ?? null) {
                    $this->output::addRedirect('user.login.redirect', $redirect);
                }


                return System::asJson($response);
            }
        }
        //if we end up here, then the login attempt failed, so return a 400
        $this->output::addMessage('user.login.validation', 'error', 'The email address and password don\'t match');
        $this->output::addResponse($request, [], false); // added so we can hook into this elsewhere.

        return System::asJson($response, [], 400);
    }

    public function refresh(
        Request $request,
        Response $response,
    ): Response {
        $body = (object) $request->getParsedBody();

        try {
            if (isset($body->refresh_token)) {
                $refreshTokenEntity = new refreshTokenEntity();
                $refreshTokenEntity->loadByToken(hash('sha256', (string) $body->refresh_token));
                if ($refreshTokenEntity->isExpired()) {
                    throw new RefreshTokenExpiredException('Token expired');
                }

                if ($sessionId = $this->sessionIdFinder->find($request, $refreshTokenEntity)) {
                    // Refresh token still valid?
                    $user = new userEntity();
                    $user->load($refreshTokenEntity->user_id);
                    $authToken = currentUser::createJWT(
                        $user,
                        $sessionId,
                        config('auth.jwt.secret_key'),
                        config('auth.jwt.algorithm'),
                        config('auth.jwt.lifetime')
                    );
                    $refreshTokenEntity->expiry = refreshTokenEntity::newExpiryFrom();
                    $refreshTokenEntity->store();

                    $this->output::addResponse($request, ['access_token' => $authToken]);

                    return System::asJson($response);
                }
            }
        } catch (
        ClientCredentialsValidationException|
        RefreshTokenUserMismatchException|
        RefreshTokenExpiredException
        $exception
        ) {
            return System::asJson(
                $response,
                ['error' => $exception->getMessage()],
                401,
            );
        } catch (RecordNotFoundException $exception) {
            return System::asJson(
                $response,
                ['error' => $exception->getMessage()],
                404,
            );
        } catch (Exception $exception) {
            return System::asJson(
                $response,
                ['error' => $exception->getMessage()],
                500,
            );
        }

        return System::asJson($response, [], 403);
    }

    /**
     * @throws Exception
     */
    public function logout(Request $request, Response $response): Response
    {
        [$token] = currentUser::authenticateJWT(
            $request,
            config('auth.jwt.secret_key'),
            config('auth.jwt.algorithm'),
        );
        if ($token) {
            $session = new sessionEntity();
            $session->load($token->sessionId);
            if (property_exists($session, 'sessionId') && $session->sessionId !== null) {
                $session->active = false;
                $session->ended = new DateTimeImmutable();
                $session->store();
            }

            $this->output::addResponse($request); //added so we can hook into this elsewhere.

            return System::asJson($response);
        }

        $this->output::addMessage('user.logout.validation', 'error', 'The user is not logged in');
        $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

        return System::asJson($response, [], 403);
    }

    /**
     * @throws Exception
     */
    public function register(Request $request, Response $response, array $args = []): Response
    {
        $body = $request->getParsedBody();
        $user = new userEntity();
        if ($id = $args['id'] ?? false) {
            //Permissions check
            if ($this->currentUser->id !== $id && !$this->currentUser->isAllowed(User::UPDATE)) {
                $this->output::addMessage(
                    $this->path->getRouteName($request) . '.validation',
                    'error',
                    'The UpdateUsers permission is required to do this.'
                );
                $this->output::addResponse($request, [], false);

                //added so we can hook into this elsewhere.
                return System::asJson($response, [], 403);
            }
            if ($this->currentUser->id === $id && (!$this->currentUser->isAllowed(User::UPDATE) &&
                    !$this->currentUser->isAllowed(User::UPDATE_SELF))) {
                $this->output::addMessage(
                    $this->path->getRouteName($request) . '.validation',
                    'error',
                    'The UpdateSelf permission is required to do this.'
                );
                $this->output::addResponse($request, [], false);

                //added so we can hook into this elsewhere.
                return System::asJson($response, [], 403);
            }
            $user->load($id);
            //check we have all the data we need, make sure password is not required as this is an update.
            $user->setRequiredData(['email', 'first_name', 'last_name']);
        } else {
            if (!$this->currentUser->isAllowed(User::CAN_REGISTER) && !$this->currentUser->isAllowed(User::CREATE)) {
                $this->output::addMessage(
                    $this->path->getRouteName($request) . '.validation',
                    'error',
                    'The CreateUsers permission is required to do this.'
                );
                $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

                return System::asJson($response, [], 403);
            }
            //does this user already exist?
            $userExisting = new userEntity();
            $userExisting->getUserByEmail($body['email']);
            if (isset($userExisting->id) && $userExisting->id) {
                $user = $userExisting;
            }

            if (!empty($user->email)) {
                $this->output::addMessage(
                    $this->path->getRouteName($request) . '.validation',
                    'error',
                    'A user with that email address already exists.'
                );
                $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

                return System::asJson($response, [], 400);
            }
        }

        //check if passwords match, if we're providing them
        if ($body['password'] ?? false) {
            if ($body['password'] != $body['confirm_password']) {
                $this->output::addMessage(
                    $this->path->getRouteName($request) . '.validation',
                    'error',
                    'Passwords don\'t match'
                );
                $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

                return System::asJson($response, [], 400);
            }
            $passwordHash = '';
            $passwordSalt = '';
            if (isset($body['password']) && !empty($body['password'])) {
                $hash = new Hash();
                $hashData = $hash->hashString($body['password']);
                $passwordHash = $hashData->hash;
                $passwordSalt = $hashData->salt;
                unset($body['password']);
            }
            $user->password = $passwordHash;
            $user->salt = $passwordSalt;
        }
        //unset password
        if (isset($body['password'])) {
            unset($body['password']);
        }

        if (isset($body['confirm_password'])) {
            //unset confirm_password so we dont try to store it.
            unset($body['confirm_password']);
        }

        if (isset($body['active']) && $this->currentUser->isAllowed(User::CHANGE_ACTIVE_STATE)) {
            $user->active = $body['active'];
        }

        foreach (['first_name', 'last_name', 'email'] as $field) {
            if (isset($body[$field])) $user->$field = $body[$field];
        }

        $hasRolesInBody = count(array_filter(array_keys($body), fn($key) => str_starts_with($key, 'role_')));

        //we need to unset all roles if we're setting any at all, so check we can do this on first pass.
        if ($hasRolesInBody && $this->currentUser->isAllowed(Role::SET)) {
            $user->removeRoles();
        }

        $user->roles = serialize([]);

        //Hook user pre save - allow other modules to act on the user save process, before storage.
        $user = Hook::processHook('userPreSave', $user, $request);

        if ($user->isValid()) {

            $user->store();

            //Hook user post save - allow other modules to act on the user save process, after storage.
            $user = Hook::processHook('userPostSave', $user, $request);

            foreach ($body as $name => $value) {
                if ($this->currentUser->isAllowed(Role::SET)) {
                    if (str_starts_with((string) $name, 'role_')) {
                        //Check we have permission to set user roles.
                        if ($value === 'true' || (int) $value !== 0) { //Ignore any FALSE/0/blank string values
                            $user->addRole(str_replace('role_', '', (string) $name));
                        }
                    } else {
                        $user->$name = $value;
                    }
                } elseif (strpos((string) $name, 'role_')) {
                    //dont allow users without permission to set roles. Just ignore them and move on.
                    continue;
                } else {
                    $user->$name = $value;
                }
            }

            $this->output::addMetadata($this->path->getRouteName($request) . '.complete', 'entityId', $user->id);
            $this->output::addMessage(
                $this->path->getRouteName($request) . '.complete',
                'success',
                $user->getNiceName() . ' ' . ($id ? 'updated' : 'created')
            );
            $this->output::addResponse($request, $user->getData()); //added so we can hook into this elsewhere.

            return System::asJson($response);
        }
        $this->output::addMessage(
            $this->path->getRouteName($request) . '.validation',
            'error',
            'Not all of the data provided is valid'
        );
        $this->output::addResponse(
            $request,
            ['missingFields' => $user->getMissingData()],
            false
        ); //added so we can hook into this elsewhere.

        return System::asJson($response, [], 400);
    }

    public function forgotPassword(Request $request, Response $response): Response
    {
        $body = (object) $request->getParsedBody();
        //first, grab the user data.
        $user = new userEntity();
        $user->getUserByEmail($body->email);

        //got a match? we need to generate a new random hash, then email it to the user.
        if ($user->email !== null) {
            $hash = new Hash();
            //we do this so the has can be passed back as a url param rather than a query string.
            $randomHash = str_replace('/', '@', $hash->generateRandomHash());
            //write the hash to the database
            $user->newForgottenPasswordLink($randomHash);
            //now we send an email to the user.
            $this->forgottenPasswordEmail($user->first_name, $user->email, $randomHash);
            $this->output->addMetadata(
                'user.forgot-password.redirect',
                'redirect',
                Routes::getRoutePattern('user.display.forgot-password.check-email')
            );
        } else {
            $this->output::addMessage('user.forgot-password', 'error', 'User account does not exist');
        }
        $this->output::addResponse($request); //added so we can hook into this elsewhere.

        return System::asJson($response);
    }

    protected function forgottenPasswordEmail($firstName, $email, $string): void
    {
        /**
         * @TODO Allow for database config here.
         * @TODO Move all this to the central mailer system, make an easy to use mailer function
         */
        $from = config('app.site.email_address') ?? 'no-reply@localhost.com';
        $fromName = config('app.site.name') ?? 'localhost';
        $frontEndDomain = config('app.site.frontend_domain') ?? 'localhost';

        /** @TODO Move to config. * */
        //$link = rtrim((string) $frontEndDomain, '/').'/api/v1/user/data/forgot-password/h/'.$string;
        $link = rtrim($frontEndDomain, '/') . '/' . ltrim(
                '/' . Routes::getRoutePattern('user.display.reset-password', ['hash' => $string]),
                '/'
            );

        try {
            $this->mailer->setFrom($from, $fromName);
            $this->mailer->addAddress($email);     // Add a recipient

            $this->mailer->Subject = 'Password Reset Request';
            $this->mailer->Body = templateService::fetch("password_reset", [
                'from_name' => $fromName,
                'reset_link' => $link,
                'name' => $firstName,
            ]);
            $this->mailer->IsHTML();
            $this->mailer->send();
        } catch (\PHPMailer\PHPMailer\Exception $e) {
            user_error(
                sprintf('Unable to send forgot password email: %s', $e->getMessage()),
                E_USER_WARNING
            );
        }
    }

    public function resetPassword(Request $request, Response $response, $args)
    {
        $body = (object) $request->getParsedBody();
        if ($body->password != $body->confirm_password) {
            $this->output::addMessage(
                $this->path->getRouteName($request) . '.validation',
                'error',
                'Passwords don\'t match'
            );
            $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.
            return System::asJson($response, [], 400);
        }
        $isReset = false;

        $user = new userEntity();
        $match = $user->getForgottenPasswordLink($body->hash);
        if (!empty($match) && ((time() - $match['created']) <= 86400 && $match['used'] == 0)) {
            $user->markForgottenPasswordLinkUsed($body->hash);
            $user->getUserByEmail($match['email']);
            if (isset($user->id) && $user->id > 0 && isset($body->password) && !empty($body->password)) {

                $passwordHash = '';
                $passwordSalt = '';
                $hash = new Hash();
                $hashData = $hash->hashString($body->password);
                $passwordHash = $hashData->hash;
                $passwordSalt = $hashData->salt;
                unset($body->password);

                $user->password = $passwordHash;
                $user->salt = $passwordSalt;
                $user->store();

                $this->output->addMetadata(
                    'user.login.validation.redirect',
                    'redirect',
                    Routes::getRoutePattern('user.display.reset-password-success')
                );
                return System::asJson($response, ['success' => true]);
            }
        }

        $this->output::addMessage(
            $this->path->getRouteName($request) . '.validation',
            'error',
            'We were unable to reset your password'
        );
        return System::asJson($response, [], 400);

    }

    public function forgotPasswordWithLink(Request $request, Response $response, array $args = []): Response
    {
        $hash = $args['hash'] ?? false;
        if ($hash) {
            $user = new userEntity();
            $match = $user->getForgottenPasswordLink($hash);
            if (!empty($match) && ((time() - $match['created']) <= 86400 && $match['used'] == 0)) {
                //log the user in and send them to their settings page to update the password. flag the hash as used.
                $user->markForgottenPasswordLinkUsed($hash);
                $user->getUserByEmail($match['email']);
                if ($user->id ?? false) {
                    //log the user in.
                    [$authToken] = $this->userTools->startLoggedInSession($user);
                    $this->output::addResponse($request, ['access_token' => $authToken]
                    ); //added so we can hook into this elsewhere.

                    return System::asJson($response);
                }
            }
        }
        $this->output::addMessage(
            $this->path->getRouteName($request) . '.validation',
            'error',
            "Invalid, unknown or expired hash"
        );
        $this->output::addResponse($request, [], false); //added so we can hook into this elsewhere.

        return System::asJson($response, [], 400);
    }
}
