<?php

namespace Kirby\Cms;

use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Request\Auth\BasicAuth;
use Kirby\Toolkit\F;
use Throwable;

/**
 * Authentication layer
 *
 * @package   Kirby Cms
 * @author    Bastian Allgeier <bastian@getkirby.com>
 * @link      https://getkirby.com
 * @copyright Bastian Allgeier GmbH
 * @license   https://getkirby.com/license
 */
class Auth
{
    protected $impersonate;
    protected $kirby;
    protected $user = false;
    protected $userException;

    /**
     * @param \Kirby\Cms\App $kirby
     * @codeCoverageIgnore
     */
    public function __construct(App $kirby)
    {
        $this->kirby = $kirby;
    }

    /**
     * Returns the csrf token if it exists and if it is valid
     *
     * @return string|false
     */
    public function csrf()
    {
        // get the csrf from the header
        $fromHeader = $this->kirby->request()->csrf();

        // check for a predefined csrf or use the one from session
        $fromSession = $this->kirby->option('api.csrf', csrf());

        // compare both tokens
        if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) {
            return false;
        }

        return $fromSession;
    }

    /**
     * Returns the logged in user by checking
     * for a basic authentication header with
     * valid credentials
     *
     * @param \Kirby\Http\Request\Auth\BasicAuth|null $auth
     * @return \Kirby\Cms\User|null
     */
    public function currentUserFromBasicAuth(BasicAuth $auth = null)
    {
        if ($this->kirby->option('api.basicAuth', false) !== true) {
            throw new PermissionException('Basic authentication is not activated');
        }

        $request = $this->kirby->request();
        $auth    = $auth ?? $request->auth();

        if (!$auth || $auth->type() !== 'basic') {
            throw new InvalidArgumentException('Invalid authorization header');
        }

        // only allow basic auth when https is enabled or insecure requests permitted
        if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) {
            throw new PermissionException('Basic authentication is only allowed over HTTPS');
        }

        return $this->validatePassword($auth->username(), $auth->password());
    }

    /**
     * Returns the logged in user by checking
     * the current session and finding a valid
     * valid user id in there
     *
     * @param \Kirby\Session\Session|array|null $session
     * @return \Kirby\Cms\User|null
     */
    public function currentUserFromSession($session = null)
    {
        // use passed session options or session object if set
        if (is_array($session) === true) {
            $session = $this->kirby->session($session);
        }

        // try session in header or cookie
        if (is_a($session, 'Kirby\Session\Session') === false) {
            $session = $this->kirby->session(['detect' => true]);
        }

        $id = $session->data()->get('user.id');

        if (is_string($id) !== true) {
            return null;
        }

        if ($user = $this->kirby->users()->find($id)) {
            // in case the session needs to be updated, do it now
            // for better performance
            $session->commit();
            return $user;
        }

        return null;
    }

    /**
     * Become any existing user
     *
     * @param string|null $who
     * @return \Kirby\Cms\User|null
     */
    public function impersonate(string $who = null)
    {
        switch ($who) {
            case null:
                return $this->impersonate = null;
            case 'kirby':
                return $this->impersonate = new User([
                    'email' => 'kirby@getkirby.com',
                    'id'    => 'kirby',
                    'role'  => 'admin',
                ]);
            default:
                if ($user = $this->kirby->users()->find($who)) {
                    return $this->impersonate = $user;
                }

                throw new NotFoundException('The user "' . $who . '" cannot be found');
        }
    }

    /**
     * Returns the hashed ip of the visitor
     * which is used to track invalid logins
     *
     * @return string
     */
    public function ipHash(): string
    {
        $hash = hash('sha256', $this->kirby->visitor()->ip());

        // only use the first 50 chars to ensure privacy
        return substr($hash, 0, 50);
    }

    /**
     * Check if logins are blocked for the current ip or email
     *
     * @param string $email
     * @return bool
     */
    public function isBlocked(string $email): bool
    {
        $ip     = $this->ipHash();
        $log    = $this->log();
        $trials = $this->kirby->option('auth.trials', 10);

        if ($entry = ($log['by-ip'][$ip] ?? null)) {
            if ($entry['trials'] >= $trials) {
                return true;
            }
        }

        if ($this->kirby->users()->find($email)) {
            if ($entry = ($log['by-email'][$email] ?? null)) {
                if ($entry['trials'] >= $trials) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Login a user by email and password
     *
     * @param string $email
     * @param string $password
     * @param bool $long
     * @return \Kirby\Cms\User
     *
     * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
     * @throws \Kirby\Exception\NotFoundException If the email was invalid
     * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
     */
    public function login(string $email, string $password, bool $long = false)
    {
        // session options
        $options = [
            'createMode' => 'cookie',
            'long'       => $long === true
        ];

        // validate the user and log in to the session
        $user = $this->validatePassword($email, $password);
        $user->loginPasswordless($options);

        return $user;
    }

    /**
     * Sets a user object as the current user in the cache
     * @internal
     *
     * @param \Kirby\Cms\User $user
     * @return void
     */
    public function setUser(User $user): void
    {
        // stop impersonating
        $this->impersonate = null;

        $this->user = $user;
    }

    /**
     * Validates the user credentials and returns the user object on success;
     * otherwise logs the failed attempt
     *
     * @param string $email
     * @param string $password
     * @return \Kirby\Cms\User
     *
     * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
     * @throws \Kirby\Exception\NotFoundException If the email was invalid
     * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
     */
    public function validatePassword(string $email, string $password)
    {
        // check for blocked ips
        if ($this->isBlocked($email) === true) {
            if ($this->kirby->option('debug') === true) {
                $message = 'Rate limit exceeded';
            } else {
                // avoid leaking security-relevant information
                $message = 'Invalid email or password';
            }

            throw new PermissionException($message);
        }

        // validate the user
        try {
            if ($user = $this->kirby->users()->find($email)) {
                if ($user->validatePassword($password) === true) {
                    return $user;
                }
            }

            throw new NotFoundException([
                'key'  => 'user.notFound',
                'data' => [
                    'name' => $email
                ]
            ]);
        } catch (Throwable $e) {
            // log invalid login trial
            $this->track($email);

            // sleep for a random amount of milliseconds
            // to make automated attacks harder
            usleep(random_int(1000, 2000000));

            // keep throwing the original error in debug mode,
            // otherwise hide it to avoid leaking security-relevant information
            if ($this->kirby->option('debug') === true) {
                throw $e;
            } else {
                throw new PermissionException('Invalid email or password');
            }
        }
    }

    /**
     * Returns the absolute path to the logins log
     *
     * @return string
     */
    public function logfile(): string
    {
        return $this->kirby->root('accounts') . '/.logins';
    }

    /**
     * Read all tracked logins
     *
     * @return array
     */
    public function log(): array
    {
        try {
            $log  = Data::read($this->logfile(), 'json');
            $read = true;
        } catch (Throwable $e) {
            $log  = [];
            $read = false;
        }

        // ensure that the category arrays are defined
        $log['by-ip']    = $log['by-ip'] ?? [];
        $log['by-email'] = $log['by-email'] ?? [];

        // remove all elements on the top level with different keys (old structure)
        $log = array_intersect_key($log, array_flip(['by-ip', 'by-email']));

        // remove entries that are no longer needed
        $originalLog = $log;
        $time = time() - $this->kirby->option('auth.timeout', 3600);
        foreach ($log as $category => $entries) {
            $log[$category] = array_filter($entries, function ($entry) use ($time) {
                return $entry['time'] > $time;
            });
        }

        // write new log to the file system if it changed
        if ($read === false || $log !== $originalLog) {
            if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) {
                F::remove($this->logfile());
            } else {
                Data::write($this->logfile(), $log, 'json');
            }
        }

        return $log;
    }

    /**
     * Logout the current user
     *
     * @return void
     */
    public function logout(): void
    {
        // stop impersonating;
        // ensures that we log out the actually logged in user
        $this->impersonate = null;

        // logout the current user if it exists
        if ($user = $this->user()) {
            $user->logout();
        }
    }

    /**
     * Clears the cached user data after logout
     * @internal
     *
     * @return void
     */
    public function flush(): void
    {
        $this->impersonate = null;
        $this->user = null;
    }

    /**
     * Tracks a login
     *
     * @param string $email
     * @return bool
     */
    public function track(string $email): bool
    {
        $ip   = $this->ipHash();
        $log  = $this->log();
        $time = time();

        if (isset($log['by-ip'][$ip]) === true) {
            $log['by-ip'][$ip] = [
                'time'   => $time,
                'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1
            ];
        } else {
            $log['by-ip'][$ip] = [
                'time'   => $time,
                'trials' => 1
            ];
        }

        if ($this->kirby->users()->find($email)) {
            if (isset($log['by-email'][$email]) === true) {
                $log['by-email'][$email] = [
                    'time'   => $time,
                    'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1
                ];
            } else {
                $log['by-email'][$email] = [
                    'time'   => $time,
                    'trials' => 1
                ];
            }
        }

        return Data::write($this->logfile(), $log, 'json');
    }

    /**
     * Returns the current authentication type
     *
     * @return string
     */
    public function type(): string
    {
        $basicAuth = $this->kirby->option('api.basicAuth', false);
        $auth      = $this->kirby->request()->auth();

        if ($basicAuth === true && $auth && $auth->type() === 'basic') {
            return 'basic';
        } elseif ($this->impersonate !== null) {
            return 'impersonate';
        } else {
            return 'session';
        }
    }

    /**
     * Validates the currently logged in user
     *
     * @param \Kirby\Session\Session|array|null $session
     * @return \Kirby\Cms\User
     *
     * @throws \Throwable If an authentication error occured
     */
    public function user($session = null)
    {
        if ($this->impersonate !== null) {
            return $this->impersonate;
        }

        // return from cache
        if ($this->user === null) {
            // throw the same Exception again if one was captured before
            if ($this->userException !== null) {
                throw $this->userException;
            }

            return null;
        } elseif ($this->user !== false) {
            return $this->user;
        }

        try {
            if ($this->type() === 'basic') {
                return $this->user = $this->currentUserFromBasicAuth();
            } else {
                return $this->user = $this->currentUserFromSession($session);
            }
        } catch (Throwable $e) {
            $this->user = null;

            // capture the Exception for future calls
            $this->userException = $e;

            throw $e;
        }
    }
}
