<?php declare(strict_types = 1);

namespace MailPoet\Automation\Integrations\MailPoet\Actions;

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


use MailPoet\AutomaticEmails\WooCommerce\Events\AbandonedCart;
use MailPoet\Automation\Engine\Control\AutomationController;
use MailPoet\Automation\Engine\Control\StepRunController;
use MailPoet\Automation\Engine\Data\Automation;
use MailPoet\Automation\Engine\Data\Step;
use MailPoet\Automation\Engine\Data\StepRunArgs;
use MailPoet\Automation\Engine\Data\StepValidationArgs;
use MailPoet\Automation\Engine\Exceptions\NotFoundException;
use MailPoet\Automation\Engine\Integration\Action;
use MailPoet\Automation\Engine\Integration\ValidationException;
use MailPoet\Automation\Engine\WordPress;
use MailPoet\Automation\Integrations\Core\Actions\DelayAction;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SegmentPayload;
use MailPoet\Automation\Integrations\MailPoet\Payloads\SubscriberPayload;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\AbandonedCartPayload;
use MailPoet\Automation\Integrations\WooCommerce\Payloads\OrderPayload;
use MailPoet\Automation\Integrations\WooCommerce\Subjects\OrderSubject;
use MailPoet\EmailEditor\Integrations\MailPoet\BlockEmailContentDetector;
use MailPoet\EmailEditor\Integrations\MailPoet\PersonalizationTags\OrderReviewUrl;
use MailPoet\Entities\NewsletterEntity;
use MailPoet\Entities\NewsletterOptionEntity;
use MailPoet\Entities\NewsletterOptionFieldEntity;
use MailPoet\Entities\ScheduledTaskSubscriberEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\InvalidStateException;
use MailPoet\Newsletter\NewsletterSaveController;
use MailPoet\Newsletter\NewslettersRepository;
use MailPoet\Newsletter\Options\NewsletterOptionFieldsRepository;
use MailPoet\Newsletter\Options\NewsletterOptionsRepository;
use MailPoet\Newsletter\Renderer\Blocks\DynamicProductsBlock;
use MailPoet\Newsletter\Scheduler\AutomationEmailScheduler;
use MailPoet\Segments\SegmentsRepository;
use MailPoet\Settings\SettingsController;
use MailPoet\Subscribers\SubscriberSegmentRepository;
use MailPoet\Subscribers\SubscribersRepository;
use MailPoet\Validator\Builder;
use MailPoet\Validator\Schema\ObjectSchema;
use Throwable;

class SendEmailAction implements Action {
  const KEY = 'mailpoet:send-email';

  // Intervals to poll for email status after sending. These are only
  // used when immediate status sync fails or the email is never sent.
  private const POLL_INTERVALS = [
    5 * MINUTE_IN_SECONDS, // ~5 minutes
    10 * MINUTE_IN_SECONDS, // ~15 minutes
    45 * MINUTE_IN_SECONDS, // ~1 hour
    4 * HOUR_IN_SECONDS, // ~5 hours           ...from email scheduling
    19 * HOUR_IN_SECONDS, // ~1 day
    4 * DAY_IN_SECONDS, // ~5 days
    25 * DAY_IN_SECONDS, // ~1 month
  ];

  // Retry intervals for sending. These are used when the email address
  // is not confirmed, and we need send non-transactional emails.
  private const OPTIN_RETRY_INTERVALS = [
    1 * MINUTE_IN_SECONDS, // ~1 minute
    5 * MINUTE_IN_SECONDS, // ~5 minutes
    20 * MINUTE_IN_SECONDS, // ~20 minutes
    1 * HOUR_IN_SECONDS, // ~1 hour
    12 * HOUR_IN_SECONDS, // ~12 hours
    1 * DAY_IN_SECONDS, // ~1 day
  ];
  private const WAIT_OPTIN = 'wait_optin';
  private const OPTIN_RETRIES = 'optin_retries';
  private const ORDER_REVIEW_URL_TOKEN = '[woocommerce/order-review-url]';

  public const TRANSACTIONAL_TRIGGERS = [
    'mailpoet:custom-trigger',
    'woocommerce:order-status-changed',
    'woocommerce:order-created',
    'woocommerce:order-paid',
    'woocommerce:order-completed',
    'woocommerce:order-cancelled',
    'woocommerce:abandoned-cart',
    'woocommerce-subscriptions:subscription-created',
    'woocommerce-subscriptions:subscription-expired',
    'woocommerce-subscriptions:subscription-payment-failed',
    'woocommerce-subscriptions:subscription-renewed',
    'woocommerce-subscriptions:subscription-status-changed',
    'woocommerce-subscriptions:trial-ended',
    'woocommerce-subscriptions:trial-started',
    'woocommerce:buys-from-a-tag',
    'woocommerce:buys-from-a-category',
    'woocommerce:buys-a-product',
    'woocommerce-bookings:booking-created',
    'woocommerce-bookings:booking-status-changed',
  ];

  private AutomationController $automationController;

  private SettingsController $settings;

  private NewslettersRepository $newslettersRepository;

  private SubscriberSegmentRepository $subscriberSegmentRepository;

  private SubscribersRepository $subscribersRepository;

  private SegmentsRepository $segmentsRepository;

  private AutomationEmailScheduler $automationEmailScheduler;

  private NewsletterOptionsRepository $newsletterOptionsRepository;

  private NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository;

  private WordPress $wp;

  private NewsletterSaveController $newsletterSaveController;

  private BlockEmailContentDetector $blockEmailContentDetector;
  private AutomationSendEmailSubjectResolver $subjectResolver;
  private OrderReviewUrl $orderReviewUrl;

  public function __construct(
    AutomationController $automationController,
    SettingsController $settings,
    NewslettersRepository $newslettersRepository,
    SubscriberSegmentRepository $subscriberSegmentRepository,
    SubscribersRepository $subscribersRepository,
    SegmentsRepository $segmentsRepository,
    AutomationEmailScheduler $automationEmailScheduler,
    NewsletterOptionsRepository $newsletterOptionsRepository,
    NewsletterOptionFieldsRepository $newsletterOptionFieldsRepository,
    WordPress $wp,
    NewsletterSaveController $newsletterSaveController,
    BlockEmailContentDetector $blockEmailContentDetector,
    AutomationSendEmailSubjectResolver $subjectResolver,
    OrderReviewUrl $orderReviewUrl
  ) {
    $this->automationController = $automationController;
    $this->settings = $settings;
    $this->newslettersRepository = $newslettersRepository;
    $this->subscriberSegmentRepository = $subscriberSegmentRepository;
    $this->subscribersRepository = $subscribersRepository;
    $this->segmentsRepository = $segmentsRepository;
    $this->automationEmailScheduler = $automationEmailScheduler;
    $this->newsletterOptionsRepository = $newsletterOptionsRepository;
    $this->newsletterOptionFieldsRepository = $newsletterOptionFieldsRepository;
    $this->wp = $wp;
    $this->newsletterSaveController = $newsletterSaveController;
    $this->blockEmailContentDetector = $blockEmailContentDetector;
    $this->subjectResolver = $subjectResolver;
    $this->orderReviewUrl = $orderReviewUrl;
  }

  public function getKey(): string {
    return self::KEY;
  }

  public function getName(): string {
    // translators: automation action title
    return __('Send email', 'mailpoet');
  }

  public function getArgsSchema(): ObjectSchema {
    $nameDefault = $this->settings->get('sender.name');
    $addressDefault = $this->settings->get('sender.address');
    $replyToNameDefault = $this->settings->get('reply_to.name');
    $replyToAddressDefault = $this->settings->get('reply_to.address');

    $nonEmptyString = Builder::string()->required()->minLength(1);
    return Builder::object([
      // required fields
      'email_id' => Builder::integer()->required(),
      'name' => $nonEmptyString->default(__('Send email', 'mailpoet')),
      'subject' => $nonEmptyString->default(__('Subject', 'mailpoet')),
      'preheader' => Builder::string()->required()->default(''),
      'sender_name' => $nonEmptyString->default($nameDefault),
      'sender_address' => $nonEmptyString->formatEmail()->default($addressDefault),

      // optional fields
      'reply_to_name' => ($replyToNameDefault && $replyToNameDefault !== $nameDefault)
        ? Builder::string()->minLength(1)->default($replyToNameDefault)
        : Builder::string()->minLength(1),
      'reply_to_address' => ($replyToAddressDefault && $replyToAddressDefault !== $addressDefault)
        ? Builder::string()->formatEmail()->default($replyToAddressDefault)
        : Builder::string()->formatEmail(),
      'ga_campaign' => Builder::string()->minLength(1),
      'email_wp_post_id' => Builder::integer(),
    ]);
  }

  public function getSubjectKeys(): array {
    return [
      'mailpoet:subscriber',
    ];
  }

  public function validate(StepValidationArgs $args): void {
    try {
      $email = $this->getEmailForStep($args->getStep());
    } catch (InvalidStateException $exception) {
      $exception = ValidationException::create()
        ->withMessage(__('Cannot send the email because it was not found. Please, go to the automation editor and update the email contents.', 'mailpoet'));

      $emailId = $args->getStep()->getArgs()['email_id'] ?? '';
      if (empty($emailId)) {
        $exception->withError('email_id', __("Automation email not found.", 'mailpoet'));
      } else {
        $exception->withError(
          'email_id',
          // translators: %s is the ID of email.
          sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId)
        );
      }
      throw $exception;
    }

    if ($args->getAutomation()->getStatus() !== Automation::STATUS_ACTIVE) {
      return;
    }

    $wpPostId = $email->getWpPostId();
    if (!$wpPostId) {
      return;
    }

    $wpPost = $this->wp->getPost($wpPostId);
    $subjectKeys = $this->subjectResolver->getGuaranteedSubjectKeysForStep($args->getAutomation(), $args->getStep());
    if ($this->newsletterContainsOrderReviewUrlToken($email)) {
      if (!in_array(OrderSubject::KEY, $subjectKeys, true)) {
        throw ValidationException::create()
          ->withMessage(__('Cannot activate the automation because order review links require a WooCommerce order trigger.', 'mailpoet'))
          ->withError('email_id', __('Use this email in an automation with a WooCommerce order subject or remove the order review link.', 'mailpoet'));
      }

      if (!$this->orderReviewUrl->isSupported()) {
        throw ValidationException::create()
          ->withMessage(__('Cannot activate the automation because WooCommerce cannot generate order review links.', 'mailpoet'))
          ->withError('email_id', __('Update WooCommerce or remove the order review link from this email.', 'mailpoet'));
      }
    }

    if (
      $wpPost instanceof \WP_Post
      && $this->blockEmailContentDetector->hasMeaningfulContent($wpPost)
    ) {
      return;
    }

    throw ValidationException::create()
      ->withMessage(__('Cannot activate the automation because an email has no content.', 'mailpoet'))
      ->withError('email_id', __('Add email content before activating the automation.', 'mailpoet'));
  }

  public function run(StepRunArgs $args, StepRunController $controller): void {
    $newsletter = $this->getEmailForStep($args->getStep());
    $subscriber = $this->getSubscriber($args);
    $state = null;

    if ($args->isFirstRun()) {
      $subscriberStatus = $subscriber->getStatus();
      if ($subscriberStatus === SubscriberEntity::STATUS_BOUNCED) {
        // translators: %s is the subscriber's status.
        throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus));
      }

      if ($this->isOptInRequired($newsletter, $subscriber)) {
        $controller->getRunLog()->saveLogData([self::WAIT_OPTIN => 1]);
        $this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber);
        return;
      }

      $this->scheduleEmail($args, $newsletter, $subscriber);
    } else {
      // Re-running for opt-in?
      $state = $this->getRunLogData($controller);

      if (array_key_exists(self::WAIT_OPTIN, $state) && $state[self::WAIT_OPTIN] === 1) {
        if ($this->isOptInRequired($newsletter, $subscriber)) {
          $this->rerunLater($args->getRunNumber(), $controller, $newsletter, $subscriber);
          return;
        }

        // Subscriber is now confirmed, so we can schedule an email.
        $controller->getRunLog()->saveLogData([
          self::WAIT_OPTIN => 0,
          self::OPTIN_RETRIES => $args->getRunNumber(),
        ]);
        $this->scheduleEmail($args, $newsletter, $subscriber);
      }

      // Check/sync sending status with the automation step
      $success = $this->checkSendingStatus($args, $newsletter, $subscriber);
      if ($success) {
        return;
      }
    }

    // At this point, we're re-running to check sending status. We need
    // to offset opt-in reruns count from sending reruns.
    $runNumber = $args->getRunNumber();
    $state = $state ?? $this->getRunLogData($controller);
    $optinRetryCount = $state[self::OPTIN_RETRIES] ?? 0;
    $runNumber -= $optinRetryCount;
    $this->rerunLater($runNumber, $controller, $newsletter, $subscriber);
  }

  private function scheduleEmail(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void {
    $this->validateOrderReviewUrlToken($args, $newsletter);

    $meta = $this->getNewsletterMeta($args);
    try {
      $this->automationEmailScheduler->createSendingTask($newsletter, $subscriber, $meta);
    } catch (Throwable $e) {
      throw InvalidStateException::create()->withMessage(__('Could not create sending task.', 'mailpoet'));
    }
  }

  private function validateOrderReviewUrlToken(StepRunArgs $args, NewsletterEntity $newsletter): void {
    if (!$this->newsletterContainsOrderReviewUrlToken($newsletter)) {
      return;
    }

    try {
      $orderPayload = $args->getSinglePayloadByClass(OrderPayload::class);
    } catch (NotFoundException $e) {
      throw InvalidStateException::create()->withMessage(__('Cannot send the email because an order is required to generate the review link.', 'mailpoet'));
    }

    if ($this->orderReviewUrl->getUrl(['order' => $orderPayload->getOrder()]) !== '') {
      return;
    }

    throw InvalidStateException::create()->withMessage(__('Cannot send the email because WooCommerce cannot generate an order review link for this order.', 'mailpoet'));
  }

  private function newsletterContainsOrderReviewUrlToken(NewsletterEntity $newsletter): bool {
    $wpPostId = $newsletter->getWpPostId();
    if ($wpPostId) {
      $wpPost = $this->wp->getPost($wpPostId);
      if ($wpPost instanceof \WP_Post && $this->contentContainsOrderReviewUrlToken($wpPost->post_content)) { // phpcs:ignore Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps
        return true;
      }
    }

    $body = json_encode($newsletter->getBody(), JSON_UNESCAPED_SLASHES);
    return is_string($body) && $this->contentContainsOrderReviewUrlToken($body);
  }

  private function contentContainsOrderReviewUrlToken(string $content): bool {
    $normalizedContent = rawurldecode(str_replace('\\/', '/', $content));
    return strpos($normalizedContent, self::ORDER_REVIEW_URL_TOKEN) !== false;
  }

  private function getRunLogData(StepRunController $controller): array {
    $runLog = $controller->getRunLog()->getLog();
    return $runLog->getData();
  }

  /**
   * Schedule a progress run to sync the email sending status to the automation step.
   * Normally, a progress run is executed immediately after sending; we're scheduling
   * these runs to poll for the status if sync fails or email never sends (timeout),
   * or if we need to wait for subscriber opt-in.
   */
  private function rerunLater(int $runNumber, StepRunController $controller, NewsletterEntity $newsletter, SubscriberEntity $subscriber): void {
    $nextInterval = self::POLL_INTERVALS[$runNumber - 1] ?? 0;

    // Use different intervals when retrying for opt-in.
    if ($this->isOptInRequired($newsletter, $subscriber)) {
      if ($runNumber > count(self::OPTIN_RETRY_INTERVALS)) {
        $subscriberStatus = $subscriber->getStatus();
        // translators: %s is the subscriber's status.
        throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber's status is '%s'.", 'mailpoet'), $subscriberStatus));
      }
      $nextInterval = self::OPTIN_RETRY_INTERVALS[$runNumber - 1];
    }

    $controller->scheduleProgress(time() + $nextInterval);
  }

  private function isOptInRequired(NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool {
    $subscriberStatus = $subscriber->getStatus();
    if ($newsletter->getType() === NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL) return false;
    return $subscriberStatus !== SubscriberEntity::STATUS_SUBSCRIBED;
  }

  /** @param mixed $data */
  public function handleEmailSent($data): void {
    if (!is_array($data)) {
      throw InvalidStateException::create()->withMessage(
      // translators: %s is the type of $data.
        sprintf(__('Invalid automation step data. Array expected, got: %s', 'mailpoet'), gettype($data))
      );
    }

    $runId = $data['run_id'] ?? null;
    if (!is_int($runId)) {
      throw InvalidStateException::create()->withMessage(
      // translators: %s is the type of $runId.
        sprintf(__("Invalid automation step data. Expected 'run_id' to be an integer, got: %s", 'mailpoet'), gettype($runId))
      );
    }

    $stepId = $data['step_id'] ?? null;
    if (!is_string($stepId)) {
      throw InvalidStateException::create()->withMessage(
        // translators: %s is the type of $runId.
        sprintf(__("Invalid automation step data. Expected 'step_id' to be a string, got: %s", 'mailpoet'), gettype($runId))
      );
    }

    $this->automationController->enqueueProgress($runId, $stepId);
  }

  private function checkSendingStatus(StepRunArgs $args, NewsletterEntity $newsletter, SubscriberEntity $subscriber): bool {
    $scheduledTaskSubscriber = $this->automationEmailScheduler->getScheduledTaskSubscriber($newsletter, $subscriber, $args->getAutomationRun());
    if (!$scheduledTaskSubscriber) {
      throw InvalidStateException::create()->withMessage(__('Email failed to schedule.', 'mailpoet'));
    }

    // email sending failed
    if ($scheduledTaskSubscriber->getFailed() === ScheduledTaskSubscriberEntity::FAIL_STATUS_FAILED) {
      throw InvalidStateException::create()->withMessage(
        // translators: %s is the error message.
        sprintf(__('Email failed to send. Error: %s', 'mailpoet'), $scheduledTaskSubscriber->getError() ?: 'Unknown error')
      );
    }

    $wasSent = $scheduledTaskSubscriber->getProcessed() === ScheduledTaskSubscriberEntity::STATUS_PROCESSED;
    $isLastRun = $args->getRunNumber() >= 1 + count(self::POLL_INTERVALS);

    // email was never sent
    if (!$wasSent && $isLastRun) {
      $error = __('Email sending process timed out.', 'mailpoet');
      $this->automationEmailScheduler->saveError($scheduledTaskSubscriber, $error);
      throw InvalidStateException::create()->withMessage($error);
    }

    return $wasSent;
  }

  private function getNewsletterMeta(StepRunArgs $args): array {
    $meta = [
      'automation' => [
        'id' => $args->getAutomation()->getId(),
        'run_id' => $args->getAutomationRun()->getId(),
        'step_id' => $args->getStep()->getId(),
        'run_number' => $args->getRunNumber(),
      ],
    ];

    if ($this->automationHasAbandonedCartTrigger($args->getAutomation())) {
      $payload = $args->getSinglePayloadByClass(AbandonedCartPayload::class);
      $meta[AbandonedCart::TASK_META_NAME] = $payload->getProductIds();
    }

    if ($this->automationHasWooCommerceTrigger($args->getAutomation())) {
      try {
        // Handle Order payload - get product IDs and cross-sell IDs
        $orderPayload = $args->getSinglePayloadByClass(OrderPayload::class);
        $orderProductIds = $orderPayload->getProductIds();
        $crossSellIds = $orderPayload->getCrossSellIds();

        if (!empty($orderProductIds)) {
          $meta[DynamicProductsBlock::ORDER_PRODUCTS_META_NAME] = array_unique($orderProductIds);
        }

        if (!empty($crossSellIds)) {
          $meta[DynamicProductsBlock::ORDER_CROSS_SELL_PRODUCTS_META_NAME] = array_unique($crossSellIds);
        }
      } catch (NotFoundException $e) {
        // No OrderPayload found, continue
      }
    }

    // Allow premium features to modify meta data
    return (array)$this->wp->applyFilters('mailpoet_automation_send_email_action_meta', $meta, $args);
  }

  private function getSubscriber(StepRunArgs $args): SubscriberEntity {
    $subscriberId = $args->getSinglePayloadByClass(SubscriberPayload::class)->getId();
    try {
      $segmentId = $args->getSinglePayloadByClass(SegmentPayload::class)->getId();
    } catch (NotFoundException $e) {
      $segmentId = null;
    }

    // Without segment, fetch subscriber by ID (needed e.g. for "mailpoet:custom-trigger").
    // Transactional emails don't need to be checked against segment, no matter if it's set.
    if (!$segmentId || $this->isTransactional($args->getStep(), $args->getAutomation())) {
      $subscriber = $this->subscribersRepository->findOneById($subscriberId);
      if (!$subscriber) {
        throw InvalidStateException::create();
      }
      return $subscriber;
    }

    // With segment, fetch subscriber segment and check if they are subscribed.
    $subscriberSegment = $this->subscriberSegmentRepository->findOneBy([
      'subscriber' => $subscriberId,
      'segment' => $segmentId,
      'status' => SubscriberEntity::STATUS_SUBSCRIBED,
    ]);

    if (!$subscriberSegment) {
      $segment = $this->segmentsRepository->findOneById($segmentId);
      if (!$segment) { // This state should not happen because it is checked in the validation.
        throw InvalidStateException::create()->withMessage(__('Cannot send the email because the list was not found.', 'mailpoet'));
      }
      // translators: %s is the name of the list.
      throw InvalidStateException::create()->withMessage(sprintf(__("Cannot send the email because the subscriber is not subscribed to the '%s' list.", 'mailpoet'), $segment->getName()));
    }

    $subscriber = $subscriberSegment->getSubscriber();
    if (!$subscriber) {
      throw InvalidStateException::create();
    }
    return $subscriber;
  }

  public function saveEmailSettings(Step $step, Automation $automation): void {
    $args = $step->getArgs();
    if (!isset($args['email_id']) || !$args['email_id']) {
      return;
    }

    $email = $this->getEmailForStep($step);
    $email->setType($this->isTransactional($step, $automation) ? NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL : NewsletterEntity::TYPE_AUTOMATION);
    $email->setStatus(NewsletterEntity::STATUS_ACTIVE);
    $email->setSubject($args['subject'] ?? '');
    $email->setPreheader($args['preheader'] ?? '');
    $email->setSenderName($args['sender_name'] ?? '');
    $email->setSenderAddress($args['sender_address'] ?? '');
    $email->setReplyToName($args['reply_to_name'] ?? '');
    $email->setReplyToAddress($args['reply_to_address'] ?? '');
    $email->setGaCampaign($args['ga_campaign'] ?? '');
    $this->storeNewsletterOption(
      $email,
      NewsletterOptionFieldEntity::NAME_GROUP,
      $this->automationHasWooCommerceTrigger($automation) ? 'woocommerce' : null
    );
    $this->storeNewsletterOption(
      $email,
      NewsletterOptionFieldEntity::NAME_EVENT,
      $this->automationHasAbandonedCartTrigger($automation) ? 'woocommerce_abandoned_shopping_cart' : null
    );

    $this->newslettersRepository->persist($email);
    $this->newslettersRepository->flush();
  }

  private function storeNewsletterOption(NewsletterEntity $newsletter, string $optionName, ?string $optionValue = null): void {
    $options = $newsletter->getOptions()->toArray();
    foreach ($options as $key => $option) {
      if ($option->getName() === $optionName) {
        if ($optionValue) {
          $option->setValue($optionValue);
          return;
        }
        $newsletter->getOptions()->remove($key);
        $this->newsletterOptionsRepository->remove($option);
        return;
      }
    }

    if (!$optionValue) {
      return;
    }

    $field = $this->newsletterOptionFieldsRepository->findOneBy([
      'name' => $optionName,
      'newsletterType' => $newsletter->getType(),
    ]);
    if (!$field) {
      return;
    }
    $option = new NewsletterOptionEntity($newsletter, $field);
    $option->setValue($optionValue);
    $this->newsletterOptionsRepository->persist($option);
    $newsletter->getOptions()->add($option);
  }

  private function isTransactional(Step $step, Automation $automation): bool {
    $triggers = $automation->getTriggers();
    $transactionalTriggers = array_filter(
      $triggers,
      function(Step $triggerStep): bool {
        return in_array($triggerStep->getKey(), self::TRANSACTIONAL_TRIGGERS, true);
      }
    );

    if (!$triggers || count($transactionalTriggers) !== count($triggers)) {
      return false;
    }

    foreach ($transactionalTriggers as $trigger) {
      if (!$this->hasDelayFreePathToStep($trigger, $step, $automation)) {
        return false;
      }
    }
    return true;
  }

  /**
   * Checks if there exists at least one path from $from step to $to step
   * that doesn't contain a delay action.
   */
  private function hasDelayFreePathToStep(Step $from, Step $to, Automation $automation): bool {
    $steps = $automation->getSteps();
    $stack = [[$from, false]]; // [step, hasDelayOnPath]
    $visited = [];

    while (!empty($stack)) {
      [$current, $hasDelayOnPath] = array_pop($stack);

      $stateKey = $current->getId() . ':' . ($hasDelayOnPath ? '1' : '0');
      if (isset($visited[$stateKey])) {
        continue;
      }
      $visited[$stateKey] = true;

      // Only delay actions convert transactional emails to marketing, as they introduce
      // a significant time gap between the trigger event and email delivery.
      // If new delay-like actions are added (e.g., "wait until date"), they should also be checked here.
      if ($current->getKey() === DelayAction::KEY) {
        $hasDelayOnPath = true;
      }

      if ($current->getId() === $to->getId()) {
        if (!$hasDelayOnPath) {
          return true;
        }
        continue;
      }

      foreach ($current->getNextStepIds() as $nextStepId) {
        $nextStep = $steps[$nextStepId] ?? null;
        if ($nextStep !== null) {
          $stack[] = [$nextStep, $hasDelayOnPath];
        }
      }
    }
    return false;
  }

  private function automationHasWooCommerceTrigger(Automation $automation): bool {
    return (bool)array_filter(
      $automation->getTriggers(),
      function(Step $step): bool {
        return strpos($step->getKey(), 'woocommerce:') === 0;
      }
    );
  }

  private function automationHasAbandonedCartTrigger(Automation $automation): bool {
    return (bool)array_filter(
      $automation->getTriggers(),
      function(Step $step): bool {
        return in_array($step->getKey(), ['woocommerce:abandoned-cart'], true);
      }
    );
  }

  private function getEmailForStep(Step $step): NewsletterEntity {
    $emailId = $step->getArgs()['email_id'] ?? null;
    if (!$emailId) {
      throw InvalidStateException::create();
    }

    $email = $this->newslettersRepository->findOneBy([
      'id' => $emailId,
    ]);
    if (!$email || !in_array($email->getType(), [NewsletterEntity::TYPE_AUTOMATION, NewsletterEntity::TYPE_AUTOMATION_TRANSACTIONAL], true)) {
      throw InvalidStateException::create()->withMessage(
        // translators: %s is the ID of email.
        sprintf(__("Automation email with ID '%s' not found.", 'mailpoet'), $emailId)
      );
    }
    return $email;
  }

  public function onDuplicate(Step $step): Step {
    $args = $step->getArgs();
    $emailId = (int)($args['email_id'] ?? 0);
    if (!$emailId) {
      // if the email is not yet designed, we don't need to duplicate it
      return $step;
    }

    $email = $this->newslettersRepository->findOneBy([
      'id' => $emailId,
    ]);
    if (!$email) {
      throw new \MailPoet\Automation\Engine\Exceptions\InvalidStateException('Automation email entity not found for duplication.');
    }
    try {
      $duplicatedNewsletter = $this->newsletterSaveController->duplicate($email);
    } catch (\Throwable $e) {
      throw new \MailPoet\Automation\Engine\Exceptions\InvalidStateException('Failed to duplicate automation email: ' . $e->getMessage());
    }
    $duplicatedNewsletter->setStatus($email->getStatus());
    $this->newslettersRepository->flush();

    $args['email_id'] = $duplicatedNewsletter->getId();
    $args['subject'] = $duplicatedNewsletter->getSubject();
    $wpPostId = $duplicatedNewsletter->getWpPostId();
    if ($wpPostId) {
      $args['email_wp_post_id'] = $wpPostId;
    } else {
      unset($args['email_wp_post_id']);
    }

    return new Step(
      $step->getId(),
      $step->getType(),
      $step->getKey(),
      $args,
      $step->getNextSteps(),
      $step->getFilters()
    );
  }
}
