<?php

namespace Mtc\Modules\Members\Classes;

use App\Src\Encryption;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Mtc\Modules\Members\Models\Member;

/**
 * Authenticator class.
 * Performs member authentication. This is mostly authentication methods moved from the former Member class.
 *
 * @author mtc.
 * @author Aleksey Lavrinenko
 * @version 06.09.2016.
 */
class Authenticator
{
    /**
     * Request-scoped cache for the member-exists check performed by isLoggedIn().
     *
     * isLoggedIn() is called many times per request (Twig globals, basket, checkout),
     * and the Auth facade is bound with App::bind (a fresh Authenticator per call),
     * so this is static rather than an instance property. Keyed on member_id so it
     * invalidates automatically when the session points at a different member.
     *
     * @var array<int|string, bool>
     */
    protected static array $memberExistsCache = [];

    /**
     * Validates credentials, performs bruteforce checks and logs in a member. If a member object is passed, this will
     * force login of the member without any checks. Otherwise all checks will be performed against the email and
     * password provided.
     *
     * @param Member|string $member A member object or email to identify the account
     * @param null|string $password
     * @return bool
     * @throws AuthenticationException
     */
    public function login($member, $password = null)
    {
        if ($member instanceof Member) {
            if (!$member->exists) {
                throw new \RuntimeException("Member does not exist");
            }

            // Clear any previous member's basket from the session.
            //
            // Note: object logins (Clinic magic-link, Wisebee, admin login-as)
            // deliberately do NOT merge the session basket into the member. On
            // those paths the session belongs to the acting user, not the
            // member being logged in, so folding it in would contaminate one
            // patient's basket with another person's items. Only the
            // email/password branch below, where the session provably belongs
            // to the person authenticating, merges the guest basket.
            unset($_SESSION['basket_id'], $_SESSION['basket']);

            $_SESSION['member_id'] = $member->id;
            $_SESSION['logged_in'] = true;
            // We just confirmed this member exists, so prime the cache to avoid a
            // redundant existence query on the isLoggedIn() calls later this request.
            static::$memberExistsCache[$member->id] = true;
        } else {
            // expecting en email in this case
            $email = $member;

            if (empty($password)) {
                throw AuthenticationException::wrongUsernameOrPassword();
            }

            try {
                $member = Member::where('email_hash', Encryption::makeHash($email))
                    ->firstOrFail();
            } catch (ModelNotFoundException $e) {
                // email not found
                throw AuthenticationException::wrongUsernameOrPassword();
            }

            if (empty($member->first_login)) {
                self::sendNewWebsiteEmail($member);
                throw new AuthenticationException('Your password has expired. Please check your mailbox for password change instructions.');
            }

            if ($member->failed_login_count >= config('auth.bruteforce_failed_login_threshold') && time() - strtotime($member->last_failed_login) <= config('auth.bruteforce_cooldown_time')){
                $this->saveLoginFailure($email);
                throw new AuthenticationException('You cannot login just now, please try again in ' . config('auth.bruteforce_cooldown_time') . ' seconds');
            }

            if (!$this->matchesPassword($password, $member->password)) {
                if ($member->failed_login_count == (config('auth.bruteforce_failed_login_threshold') - 2)) {
                    $this->saveLoginFailure($email);
                    throw new AuthenticationException('If you enter your details incorrectly again, your account will be suspended from logins for ' . config('auth.bruteforce_cooldown_time') . ' seconds');
                }

                // Bad password
                $this->saveLoginFailure($email);
                throw AuthenticationException::wrongUsernameOrPassword();
            }

            // all checks passed, go on log in

            $member->last_login = date('Y-m-d H:i:s');
            $member->save();

            // Mark the session authenticated first, so a failure in the
            // best-effort basket/assessment reconciliation below can never leave
            // a member with valid credentials un-logged-in.
            $_SESSION['member_id'] = $member->id;
            $_SESSION['logged_in'] = true;
            static::$memberExistsCache[$member->id] = true;

            // Link the session's completed assessments to the member, then merge
            // the guest (pre-login) session basket into the member basket
            // instead of letting one silently win. Both run only here, on the
            // email/password path, where the session provably belongs to the
            // person who just authenticated. Each is a convenience, never a
            // reason to fail an otherwise valid login, and each is guarded
            // separately so a basket-merge failure cannot suppress assessment
            // linking (an unclaimed assessment orphans the prescription at
            // checkout). Linking runs first: the merge's prescription-safety
            // guard accepts a line either from this session's assessments or
            // from assessments the member already owns, so claiming them first
            // does not weaken the guard.
            try {
                $member->linkAssessmentsFromSession();
            } catch (\Throwable $e) {
                report($e);
            }

            try {
                $guestBasketId = !empty($_SESSION['basket_id']) ? (int) $_SESSION['basket_id'] : 0;
                $member->reconcileBasketOnLogin($guestBasketId);
            } catch (\Throwable $e) {
                report($e);
            }

            if ($member->failed_login_count > 0) {
                $this->resetLoginFailures($member);
            }

            return true;
        }
    }

    /**
     * Logs out current member and cleans up session and some special cookies
     */
    public function logout(): void
    {
        if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
        // Drop any cached existence result for the member being logged out.
        if (!empty($_SESSION['member_id'])) {
            unset(static::$memberExistsCache[$_SESSION['member_id']]);
        }
        // Clear member-specific session state. Non-member state (currency, csrf,
        // viewed products) is intentionally left so the guest session stays usable.
        // The member's basket is preserved in members.basket_id and restored on next login.
        unset(
            $_SESSION['member_id'],
            $_SESSION['logged_in'],
            $_SESSION['basket_id'],
            $_SESSION['basket'],
            $_SESSION['order_id'],
        );
        session_regenerate_id(true);
    }

    /**
     * Return currently logged in member if a member is logged in, an empty Member otherwise.
     *
     * @return Member
     */
    public function getLoggedInMember()
    {
        if (!$this->isLoggedIn()) {
            // The session points at a member that no longer exists (or is not logged
            // in). Clear the stale member session here — this is a context where a
            // side effect is expected — rather than from the read-only isLoggedIn().
            if (!empty($_SESSION['member_id'])) {
                $this->logout();
            }
            return new Member();
        }

        $member = Member::findOrNew($_SESSION['member_id']);

        // Trigger event only if member has actually logged in
        if ($member->exists) {
            Event::dispatch(new Events\MemberLoggedInEvent($member));
        }

        return $member;
    }

    /**
     * Returns true if there is a logged in member in the session.
     *
     * This is a pure read: it has no side effects (it never mutates the session),
     * so it is safe to call from read-only contexts such as Twig helpers and the
     * basket constructor. Clearing a stale member session is handled by
     * getLoggedInMember(), which is where a mutation is expected.
     *
     * @return bool
     */
    public function isLoggedIn()
    {
        if (empty($_SESSION['logged_in']) || empty($_SESSION['member_id'])) {
            return false;
        }

        return $this->memberExists($_SESSION['member_id']);
    }

    /**
     * Whether a member row exists for the given id, memoised for the current request.
     *
     * isLoggedIn() runs on many code paths per request; without memoisation this
     * would issue the same indexed lookup repeatedly (the sort of redundancy the
     * hot-path query cleanup deliberately removed).
     *
     * @param int|string $member_id
     * @return bool
     */
    protected function memberExists($member_id): bool
    {
        if (!array_key_exists($member_id, static::$memberExistsCache)) {
            static::$memberExistsCache[$member_id] = Member::where('id', $member_id)->exists();
        }

        return static::$memberExistsCache[$member_id];
    }

    /**
     * Returns an hash for a password.
     *
     * @param $password
     * @return bool|string
     */
    public function hashPassword($password)
    {
        return password_hash($password, PASSWORD_DEFAULT);
    }

    /**
     * Returns true if the password matches the hash, false otherwise.
     *
     * @param $password
     * @param $hash
     * @return bool
     */
    public function matchesPassword($password, $hash)
    {
        return password_verify($password, $hash);
    }

    /**
     * Save to database datetime of last login failure
     * and increment failed login count
     *
     * @param string $email
     * @return bool
     */
    public function saveLoginFailure($email = '')
    {
        if (!$email) {
            return false;
        }

        try {
            $member = Member::withEmail($email)->firstOrFail();
        } catch (ModelNotFoundException $e) {
            // member not found
            return false;
        }
        /* @var $member Member */

        if ((time() - strtotime($member->last_failed_login) <= BRUTEFORCE_FAILED_LOGIN_MAXTIME
                && $member->failed_login_count < config('auth.bruteforce_failed_login_threshold')
            ) || $member->failed_login_count == 0
        ) {
            // increment failed login count and update date
            $member->last_failed_login = Carbon::now();
            $member->failed_login_count += 1;
        } else {
            $member->last_failed_login = Carbon::now();
        }

        return $member->save();
    }

    /**
     * Set failed login count to 0 after successful login
     *
     * @param Member $member
     */
    protected function resetLoginFailures(Member $member)
    {
        $member->failed_login_count = 0;
        $member->save();
    }

    /**
     * Generate and store new password reset hash for a member
     *
     * @param Member $member
     * @return string
     */
    public function initiatePasswordReset(Member $member)
    {
        $member->hash = $this->generateRandomHash();
        $member->save();

        return $member->hash;
    }

    /**
     * Generate a random hash string
     * @return string
     */
    public function generateRandomHash()
    {
        return sha1(microtime());
    }

    /**
     * Matches members by email and reset password hash and returns found member or null
     *
     * @param $email
     * @param $hash
     * @return Member|null
     */
    public function matchMemberByHash($email, $hash, $check_update_time = true)
    {
        $member = Member::withEmail($email)
            ->where('hash', '=', $hash)
            ->first();
        /* @var $member Member */

        if ($member === null) {
            return $member;
        }


        // concider hash created at the updated_at time
        if ($check_update_time && $member->updated_at->diffInMinutes() > 60 && $member->first_login != null) {
            return null;
        }

        return $member;
    }

    /**
     * Sends an email about the new website with password change link
     *
     * @param $member
     * @param null $twig Twig. Pass this if there's bulk email send, so it's not created each tim
     * @return bool
     */
    public static function sendNewWebsiteEmail(Member $member, $twig = null) : bool
    {

        if (empty($twig)) {
            $twig = App::make('twig');
        }

        if (!$member instanceof Member) {
            return false;
        }

        if (empty($member->hash)) {
            $hash = Auth::initiatePasswordReset($member);
        } else {
            $hash = $member->hash;
        }

        $params = [
            'reset_email' => $member->email,
            'reset_code'  => $hash,
        ];

        $content = $twig->render('emails/new_website_email.twig', [
            'member' => $member,
            'url' => route('members-reset-password', $params),
        ]);

        $to = $member->email;
        $subject = config('app.name') . ' Password update';

        email($to, $subject, $content);
        return true;
    }
}
