<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare(strict_types=1);

namespace Magento\Captcha\Model;

use Magento\Authorization\Model\UserContextInterface;
use Magento\Captcha\Helper\Data;
use Magento\Framework\App\ObjectManager;
use Magento\Framework\Math\Random;

/**
 * Implementation of \Laminas\Captcha\Image
 *
 * @SuppressWarnings(PHPMD.CookieAndSessionMisuse)
 *
 * @api
 * @since 100.0.2
 */
class DefaultModel extends \Laminas\Captcha\Image implements \Magento\Captcha\Model\CaptchaInterface
{
    /**
     * Key in session for captcha code
     */
    const SESSION_WORD = 'word';

    /**
     * Min captcha lengths default value
     */
    const DEFAULT_WORD_LENGTH_FROM = 3;

    /**
     * Max captcha lengths default value
     */
    const DEFAULT_WORD_LENGTH_TO = 5;

    /**
     * @var Data
     * @since 100.2.0
     */
    protected $captchaData;

    /**
     * Captcha expire time
     * @var int
     * @since 100.2.0
     */
    protected $expiration;

    /**
     * Override default value to prevent a captcha cut off
     * @var int
     * @see \Laminas\Captcha\Image::$fsize
     * @since 100.2.0
     */
    protected $fsize = 22;

    /**
     * Captcha form id
     * @var string
     * @since 100.2.0
     */
    protected $formId;

    /**
     * @var \Magento\Captcha\Model\ResourceModel\LogFactory
     * @since 100.2.0
     */
    protected $resLogFactory;

    /**
     * Overrides parent parameter as session comes in constructor.
     *
     * @var bool
     * @since 100.2.0
     */
    protected $keepSession = true;

    /**
     * @var \Magento\Framework\Session\SessionManagerInterface
     * @since 100.2.0
     */
    protected $session;

    /**
     * @var string
     */
    private $words;

    /**
     * @var Random
     */
    private $randomMath;

    /**
     * @var UserContextInterface
     */
    private $userContext;

    /**
     * @param \Magento\Framework\Session\SessionManagerInterface $session
     * @param \Magento\Captcha\Helper\Data $captchaData
     * @param ResourceModel\LogFactory $resLogFactory
     * @param string $formId
     * @param Random $randomMath
     * @param UserContextInterface|null $userContext
     * @throws \Laminas\Captcha\Exception\ExtensionNotLoadedException
     */
    public function __construct(
        \Magento\Framework\Session\SessionManagerInterface $session,
        \Magento\Captcha\Helper\Data $captchaData,
        \Magento\Captcha\Model\ResourceModel\LogFactory $resLogFactory,
        $formId,
        Random $randomMath = null,
        ?UserContextInterface $userContext = null
    ) {
        parent::__construct();
        $this->session = $session;
        $this->captchaData = $captchaData;
        $this->resLogFactory = $resLogFactory;
        $this->formId = $formId;
        $this->randomMath = $randomMath ?? ObjectManager::getInstance()->get(Random::class);
        $this->userContext = $userContext ?? ObjectManager::getInstance()->get(UserContextInterface::class);
    }

    /**
     * Returns key with respect of current form ID
     *
     * @param string $key
     * @return string
     */
    private function getFormIdKey($key)
    {
        return $this->formId . '_' . $key;
    }

    /**
     * Get Block Name
     *
     * @return string
     */
    public function getBlockName()
    {
        return \Magento\Captcha\Block\Captcha\DefaultCaptcha::class;
    }

    /**
     * Whether captcha is required to be inserted to this form
     *
     * @param null|string $login
     * @return bool
     */
    public function isRequired($login = null)
    {
        if (($this->isUserAuth()
                && !$this->isShownToLoggedInUser())
            || !$this->isEnabled()
            || !in_array(
                $this->formId,
                $this->getTargetForms()
            )
            || $this->userContext->getUserType() === UserContextInterface::USER_TYPE_INTEGRATION
        ) {
            return false;
        }

        return $this->isShowAlways()
            || $this->isOverLimitAttempts($login)
            || $this->session->getData($this->getFormIdKey('show_captcha'));
    }

    /**
     * Check if CAPTCHA has to be shown to logged in user on this form
     *
     * @return bool
     */
    public function isShownToLoggedInUser()
    {
        $forms = (array)$this->captchaData->getConfig('shown_to_logged_in_user');
        foreach ($forms as $formId => $isShownToLoggedIn) {
            if ($isShownToLoggedIn && $this->formId == $formId) {
                return true;
            }
        }
        return false;
    }

    /**
     * Check is over limit attempts
     *
     * @param string $login
     * @return bool
     */
    private function isOverLimitAttempts($login)
    {
        return $this->isOverLimitIpAttempt() || $this->isOverLimitLoginAttempts($login);
    }

    /**
     * Returns number of allowed attempts for same login
     *
     * @return int
     */
    private function getAllowedAttemptsForSameLogin()
    {
        return (int)$this->captchaData->getConfig('failed_attempts_login');
    }

    /**
     * Returns number of allowed attempts from same IP
     *
     * @return int
     */
    private function getAllowedAttemptsFromSameIp()
    {
        return (int)$this->captchaData->getConfig('failed_attempts_ip');
    }

    /**
     * Check is over limit saved attempts from one ip
     *
     * @return bool
     */
    private function isOverLimitIpAttempt()
    {
        $countAttemptsByIp = $this->getResourceModel()->countAttemptsByRemoteAddress();
        return $countAttemptsByIp >= $this->getAllowedAttemptsFromSameIp();
    }

    /**
     * Is Over Limit Login Attempts
     *
     * @param string $login
     * @return bool
     */
    private function isOverLimitLoginAttempts($login)
    {
        if ($login != false) {
            $countAttemptsByLogin = $this->getResourceModel()->countAttemptsByUserLogin($login);
            return $countAttemptsByLogin >= $this->getAllowedAttemptsForSameLogin();
        }
        return false;
    }

    /**
     * Check is user auth
     *
     * @return bool
     */
    private function isUserAuth()
    {
        return $this->session->isLoggedIn() || $this->userContext->getUserId();
    }

    /**
     * Whether to respect case while checking the answer
     *
     * @return bool
     */
    public function isCaseSensitive()
    {
        return (string)$this->captchaData->getConfig('case_sensitive');
    }

    /**
     * Get font to use when generating captcha
     *
     * @return string
     */
    public function getFont()
    {
        $font = (string)$this->captchaData->getConfig('font');
        $fonts = $this->captchaData->getFonts();

        if (isset($fonts[$font])) {
            $fontPath = $fonts[$font]['path'];
        } else {
            $fontData = array_shift($fonts);
            $fontPath = $fontData['path'];
        }

        return $fontPath;
    }

    /**
     * After this time isCorrect() is going to return FALSE even if word was guessed correctly
     *
     * @return int
     */
    public function getExpiration()
    {
        if (!$this->expiration) {
            /**
             * as "timeout" configuration parameter specifies timeout in minutes - we multiply it on 60 to set
             * expiration in seconds
             */
            $this->expiration = (int)$this->captchaData->getConfig('timeout') * 60;
        }
        return $this->expiration;
    }

    /**
     * Get timeout for session token
     *
     * @return int
     */
    public function getTimeout()
    {
        return $this->getExpiration();
    }

    /**
     * Get captcha image directory
     *
     * @return string
     */
    public function getImgDir()
    {
        return $this->captchaData->getImgDir();
    }

    /**
     * Get captcha image base URL
     *
     * @return string
     */
    public function getImgUrl()
    {
        return $this->captchaData->getImgUrl();
    }

    /**
     * Checks whether captcha was guessed correctly by user
     *
     * @param string $word
     * @return bool
     */
    public function isCorrect($word)
    {
        $storedWords = $this->getWords();
        $this->clearWord();

        if (!$word || !$storedWords) {
            return false;
        }

        if (!$this->isCaseSensitive()) {
            $storedWords = strtolower($storedWords);
            $word = strtolower($word);
        }
        return in_array($word, explode(',', $storedWords));
    }

    /**
     * Return full URL to captcha image
     *
     * @return string
     */
    public function getImgSrc()
    {
        return $this->getImgUrl() . $this->getId() . $this->getSuffix();
    }

    /**
     * Log attempt
     *
     * @param string $login
     * @return $this
     */
    public function logAttempt($login)
    {
        if ($this->isEnabled() && in_array($this->formId, $this->getTargetForms())) {
            $this->getResourceModel()->logAttempt($login);
            if ($this->isOverLimitLoginAttempts($login)) {
                $this->setShowCaptchaInSession(true);
            }
        }
        return $this;
    }

    /**
     * Set show_captcha flag in session
     *
     * @param bool $value
     * @return void
     * @since 100.1.0
     */
    public function setShowCaptchaInSession($value = true)
    {
        if ($value !== true) {
            $value = false;
        }

        $this->session->setData($this->getFormIdKey('show_captcha'), $value);
    }

    /**
     * Generate word used for captcha render
     *
     * @return string
     * @throws \Magento\Framework\Exception\LocalizedException
     * @since 100.2.0
     */
    protected function generateWord()
    {
        $symbols = (string)$this->captchaData->getConfig('symbols');
        $wordLen = $this->getWordLen();
        return $this->randomMath->getRandomString($wordLen, $symbols);
    }

    /**
     * Returns length for generating captcha word. This value may be dynamic.
     *
     * @return int
     * @throws \Magento\Framework\Exception\LocalizedException
     * @since 100.2.0
     */
    public function getWordLen()
    {
        $from = 0;
        $to = 0;
        $length = (string)$this->captchaData->getConfig('length');
        if (!is_numeric($length)) {
            if (preg_match('/(\d+)-(\d+)/', $length, $matches)) {
                $from = (int)$matches[1];
                $to = (int)$matches[2];
            }
        } else {
            $from = (int)$length;
            $to = (int)$length;
        }

        if ($to < $from || $from < 1 || $to < 1) {
            $from = self::DEFAULT_WORD_LENGTH_FROM;
            $to = self::DEFAULT_WORD_LENGTH_TO;
        }

        return Random::getRandomNumber($from, $to);
    }

    /**
     * Whether to show captcha for this form every time
     *
     * @return bool
     */
    private function isShowAlways()
    {
        $captchaMode = (string)$this->captchaData->getConfig('mode');

        if ($captchaMode === Data::MODE_ALWAYS) {
            return true;
        }

        if ($captchaMode === Data::MODE_AFTER_FAIL
            && $this->getAllowedAttemptsForSameLogin() === 0
        ) {
            return true;
        }

        $alwaysFor = $this->captchaData->getConfig('always_for');
        foreach ($alwaysFor as $nodeFormId => $isAlwaysFor) {
            if ($isAlwaysFor && $this->formId == $nodeFormId) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether captcha is enabled at this area
     *
     * @return bool
     */
    private function isEnabled()
    {
        return (string)$this->captchaData->getConfig('enable');
    }

    /**
     * Retrieve list of forms where captcha must be shown
     *
     * For frontend this list is based on current website
     *
     * @return array
     */
    private function getTargetForms()
    {
        $formsString = (string)$this->captchaData->getConfig('forms');
        return explode(',', $formsString);
    }

    /**
     * Get captcha word
     *
     * @return string|null
     */
    public function getWord()
    {
        $sessionData = $this->session->getData($this->getFormIdKey(self::SESSION_WORD));
        return time() < $sessionData['expires'] ? $sessionData['data'] : null;
    }

    /**
     * Get captcha words
     *
     * @return string
     */
    private function getWords()
    {
        $sessionData = $this->session->getData($this->getFormIdKey(self::SESSION_WORD));
        $words = '';
        if (isset($sessionData['expires'], $sessionData['words']) && time() < $sessionData['expires']) {
            $words = $sessionData['words'];
        }

        return $words;
    }

    /**
     * Set captcha word
     *
     * @param string $word
     * @return $this
     * @since 100.2.0
     */
    protected function setWord($word)
    {
        $this->words = $this->words ? $this->words . ',' . $word : $word;
        $this->session->setData(
            $this->getFormIdKey(self::SESSION_WORD),
            ['data' => $word, 'words' => $this->words, 'expires' => time() + $this->getTimeout()]
        );
        $this->word = $word;
        return $this;
    }

    /**
     * Set captcha word
     *
     * @return $this
     */
    private function clearWord()
    {
        $this->session->unsetData($this->getFormIdKey(self::SESSION_WORD));
        $this->word = null;
        return $this;
    }

    /**
     * Override function to generate less curly captcha that will not cut off
     *
     * @see \Laminas\Captcha\Image::_randomSize()
     * @return int
     * @throws \Magento\Framework\Exception\LocalizedException
     * @since 100.2.0
     */
    protected function randomSize()
    {
        return Random::getRandomNumber(280, 300) / 100;
    }

    /**
     * Overlap of the parent method
     *
     * @return void
     *
     * Now deleting old captcha images make crontab script
     * @see \Magento\Captcha\Cron\DeleteExpiredImages::execute
     *
     * Added SuppressWarnings since this method is declared in parent class and we can not use other method name.
     * @SuppressWarnings(PHPMD.ShortMethodName)
     * @since 100.2.0
     */
    protected function gc()
    {
        return; // required for static testing to pass
    }

    /**
     * Get resource model
     *
     * @return \Magento\Captcha\Model\ResourceModel\Log
     */
    private function getResourceModel()
    {
        return $this->resLogFactory->create();
    }
}
