<?php declare(strict_types = 1);

namespace MailPoet\Captcha\Validator;

if (!defined('ABSPATH')) exit;


use MailPoet\Captcha\CaptchaPhrase;
use MailPoet\Captcha\CaptchaSession;
use MailPoet\Captcha\CaptchaUrlFactory;
use MailPoet\Subscribers\SubscriberIPsRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Util\Helpers;
use MailPoet\WP\Functions as WPFunctions;

class CaptchaValidator {
  /** @var CaptchaUrlFactory */
  private $captchaUrlFactory;

  /** @var CaptchaPhrase */
  private $captchaPhrase;

  /** @var WPFunctions */
  private $wp;

  /** @var SubscriberIPsRepository */
  private $subscriberIPsRepository;

  /** @var SubscribersRepository */
  private $subscribersRepository;

  /** @var CaptchaSession */
  private $captchaSession;

  public function __construct(
    CaptchaUrlFactory $urlFactory,
    CaptchaPhrase $captchaPhrase,
    WPFunctions $wp,
    SubscriberIPsRepository $subscriberIPsRepository,
    SubscribersRepository $subscribersRepository,
    CaptchaSession $captchaSession
  ) {
    $this->captchaUrlFactory = $urlFactory;
    $this->captchaPhrase = $captchaPhrase;
    $this->wp = $wp;
    $this->subscriberIPsRepository = $subscriberIPsRepository;
    $this->subscribersRepository = $subscribersRepository;
    $this->captchaSession = $captchaSession;
  }

  public function validate(array $data): bool {
    $isBuiltinCaptchaRequired = $this->isRequired(isset($data['email']) ? $data['email'] : null);
    if (!$isBuiltinCaptchaRequired) {
      return true;
    }
    return $this->validateChallenge($data);
  }

  /**
   * Validates the user's CAPTCHA answer without consulting isRequired().
   * Use this when the caller has already decided a challenge is needed
   * (e.g. the behavioral-signal escalation path).
   */
  public function validateChallenge(array $data): bool {
    // session ID must be set at this point
    $sessionId = $data['captcha_session_id'] ?? null;
    if (!$sessionId) {
      throw new ValidationError(__('CAPTCHA verification failed.', 'mailpoet'));
    }

    if (empty($data['captcha'])) {
      $this->captchaPhrase->createPhrase($sessionId);
      throw new ValidationError(
        __('Please fill in the CAPTCHA.', 'mailpoet'),
        $this->getInlineCaptchaData($sessionId)
      );
    }

    $captchaHash = $this->captchaPhrase->getPhrase($sessionId);
    if (empty($captchaHash)) {
      $this->captchaPhrase->createPhrase($sessionId);
      throw new ValidationError(
        __('Please regenerate the CAPTCHA.', 'mailpoet'),
        $this->getInlineCaptchaData($sessionId)
      );
    }

    if (!hash_equals(strtolower($data['captcha']), strtolower($captchaHash))) {
      $this->captchaPhrase->createPhrase($sessionId);
      throw new ValidationError(
        __('The characters entered do not match with the previous CAPTCHA.', 'mailpoet'),
        [
          'refresh_captcha' => true,
          // Keep redirect_url for non-JavaScript form submissions
          'redirect_url' => $this->captchaUrlFactory->getCaptchaUrlForMPForm($sessionId),
        ]
      );
    }

    return true;
  }

  /**
   * Creates a fresh inline CAPTCHA challenge: generates a session, primes a
   * phrase, and returns the meta the client uses to render it inline.
   * Optionally stashes form data so the non-JS captcha-page fallback can
   * restore the original submission on resubmit.
   *
   * @param array<string, mixed>|null $formData Form data to stash, or null to skip stashing.
   */
  public function getInlineCaptchaChallenge(?array $formData = null): array {
    $sessionId = $this->captchaSession->generateSessionId();
    $this->captchaPhrase->createPhrase($sessionId);
    if ($formData !== null) {
      $this->captchaSession->setFormData($sessionId, $formData);
    }
    return $this->getInlineCaptchaData($sessionId);
  }

  public function isRequired($subscriberEmail = null) {
    if ($this->isUserExemptFromCaptcha()) {
      return false;
    }

    $subscriptionCaptchaRecipientLimit = $this->wp->applyFilters('mailpoet_subscription_captcha_recipient_limit', 0);
    if ($subscriptionCaptchaRecipientLimit === 0) {
      return true;
    }

    // Check limits per recipient if enabled
    if ($subscriberEmail) {
      $subscriber = $this->subscribersRepository->findOneBy(['email' => $subscriberEmail]);
      if (
        $subscriber && $subscriber->getConfirmationsCount() >= $subscriptionCaptchaRecipientLimit
      ) {
        return true;
      }
    }

    // Check limits per IP address
    /** @var int|string $subscriptionCaptchaWindow */
    $subscriptionCaptchaWindow = $this->wp->applyFilters('mailpoet_subscription_captcha_window', MONTH_IN_SECONDS);

    $subscriberIp = Helpers::getIP();
    if (empty($subscriberIp)) {
      return false;
    }

    $subscriptionCount = $this->subscriberIPsRepository->getCountByIPAndCreatedAtAfterTimeInSeconds(
      $subscriberIp,
      (int)$subscriptionCaptchaWindow
    );

    if ($subscriptionCount > 0) {
      return true;
    }

    return false;
  }

  public function isUserExemptFromCaptcha(): bool {
    if (!$this->wp->isUserLoggedIn()) {
      return false;
    }
    $user = $this->wp->wpGetCurrentUser();
    $roles = $this->wp->applyFilters('mailpoet_subscription_captcha_exclude_roles', ['administrator', 'editor']);
    $stringRoles = is_array($roles) ? array_values(array_filter($roles, 'is_string')) : [];
    return !empty(array_intersect($stringRoles, $user->roles));
  }

  /**
   * Returns data needed to display the captcha inline within the form.
   * Includes redirect_url as a fallback for non-JavaScript submissions.
   */
  private function getInlineCaptchaData(string $sessionId): array {
    return [
      'show_captcha' => true,
      'captcha_session_id' => $sessionId,
      'captcha_image_url' => $this->captchaUrlFactory->getCaptchaImageUrl($sessionId),
      'captcha_audio_url' => $this->captchaUrlFactory->getCaptchaAudioUrl($sessionId),
      // Keep redirect_url for non-JavaScript form submissions
      'redirect_url' => $this->captchaUrlFactory->getCaptchaUrlForMPForm($sessionId),
    ];
  }
}
